mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
refactor(ui): port the MCP server forms off antd Form onto react-hook-form (#37483)
* refactor(ui): port the MCP server forms off antd Form onto react-hook-form
The MCP create and edit forms were the last antd `Form` graph in the
dashboard. antd's `onFinish` hands back only the fields mounted at submit
time, while react-hook-form with `shouldUnregister: false` hands back the
whole store, so a direct port would quietly widen every create and update
request.
All 14 files now bind through `MountedFormField`, whose mount registry
reproduces antd's mounted-only submit: both roots build their payload from
`projectMountedValues` instead of `getValues`. `mcpFormStore` carries the
rest of the FormInstance surface the two roots relied on, each piece
matched to what rc-field-form actually does rather than to what the API
name suggests: `setFieldsValue` deep-merges plain objects and writes an
explicit `undefined`, `resetFields` restores the seeded values rather than
clearing the key, and `onValuesChange` is rebuilt from a `watch`
subscription filtered to user input, carrying a single changed branch.
Two watches needed the mount gate moved rather than translated.
`MCPPermissionManagement` renders outside the transport gate that mounts
`auth_type`, so antd's watch read `undefined` there and mounted the
pass-through toggle; the effective auth type now arrives as a prop that
each root computes with exactly that gate. The edit root reads every watch
off the mounted projection for the same reason, which also stops the token
material in a saved server's credentials from reaching the tool preview.
`Form.List` becomes `useFieldArray` plus a `useMountedName` registration
for the list key itself, because antd registers a list as one field: a
per-user variable row keeps the `value` its scope hides, and an empty list
still submits `env_vars: []` instead of dropping the key.
* test(ui): cover the mount registry's unregister path on the real primitive
The existing MountedFormField suite drove a hand-written registry whose
register returned a no-op, so nothing exercised useMountRegistry's
ref-counting or the cleanup that React wires from useMountedName's effect
return value. A reviewer read that gap as a missing unregister.
These three cases drive the real hook through a gated tree: a key leaves
the submitted payload when its gate unmounts the field, a required field
that unmounts stops blocking submission, and a name held by two fields
survives one of them releasing it.
Verified by mutation: rewriting the effect body to discard the cleanup
turns the first two red, the second reporting the reviewer's exact
symptom, "expected [ 'server_name', 'token_url' ] to not include
'token_url'".
* test(ui): prove the permission panel's booleans reach the create payload
CreateMCPServer.integration.test.tsx mocks MCPPermissionManagement, so the
four booleans createServerPayload writes were invisible to every existing
create-side test. vi.mock is file-scoped, so rendering the real panel needs
its own file.
Four cases, each killed by a different mutation:
unbind allow_all_keys -> "sends allow_all_keys true"
unbind available_on_public_internet -> "sends the panel's defaults"
invertedSwitchControl -> switchControl -> "sends ... false when the
operator restricts"
isOAuth2 gate forced open -> "omits delegate_auth_to_upstream"
A payload assertion expecting false cannot detect an unbound field, since
Boolean(undefined) is false too, so the two cases carrying unbinding
detection are the ones asserting true. The other two are pinned by the
switch-inversion and mount-gate mutations instead.
This commit is contained in:
parent
b402fea745
commit
481c08de4e
31 changed files with 2915 additions and 1833 deletions
|
|
@ -1,7 +1,25 @@
|
|||
import React from "react";
|
||||
import { Form, Input, Tooltip } from "antd";
|
||||
import { Input, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { antdRequired } from "@/components/common_components/antdFormRules";
|
||||
import { requiredWhenSiblingSet, textControl } from "./mcpFieldRules";
|
||||
|
||||
const fieldClassName = "rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500";
|
||||
|
||||
const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, tooltip }) => (
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
{label}
|
||||
<Tooltip title={tooltip}>
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
|
||||
const ACCESS_KEY_PATH = ["credentials", "aws_access_key_id"] as const;
|
||||
const SECRET_KEY_PATH = ["credentials", "aws_secret_access_key"] as const;
|
||||
|
||||
const AwsSigV4Fields: React.FC = () => (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
|
|
@ -15,140 +33,120 @@ const AwsSigV4Fields: React.FC = () => (
|
|||
View docs →
|
||||
</a>
|
||||
</p>
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
AWS Region
|
||||
<Tooltip title="AWS region for SigV4 signing (e.g., us-east-1)">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="AWS Region" tooltip="AWS region for SigV4 signing (e.g., us-east-1)" />}
|
||||
name={["credentials", "aws_region_name"]}
|
||||
rules={[{ required: true, message: "AWS region is required for SigV4 auth" }]}
|
||||
required
|
||||
rules={{ validate: { required: antdRequired("AWS region is required for SigV4 auth") } }}
|
||||
>
|
||||
<Input placeholder="us-east-1" className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => <Input {...textControl(control)} placeholder="us-east-1" className={fieldClassName} />}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
AWS Service Name
|
||||
<Tooltip title="AWS service name for SigV4 signing. Defaults to 'bedrock-agentcore'.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<FieldLabel
|
||||
label="AWS Service Name"
|
||||
tooltip="AWS service name for SigV4 signing. Defaults to 'bedrock-agentcore'."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "aws_service_name"]}
|
||||
>
|
||||
<Input
|
||||
placeholder="bedrock-agentcore"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => <Input {...textControl(control)} placeholder="bedrock-agentcore" className={fieldClassName} />}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
AWS Access Key ID
|
||||
<Tooltip title="Optional. If not provided, falls back to the boto3 credential chain (IAM role, env vars, etc.).">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<FieldLabel
|
||||
label="AWS Access Key ID"
|
||||
tooltip="Optional. If not provided, falls back to the boto3 credential chain (IAM role, env vars, etc.)."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "aws_access_key_id"]}
|
||||
dependencies={[["credentials", "aws_secret_access_key"]]}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
const secretKey = getFieldValue(["credentials", "aws_secret_access_key"]);
|
||||
if (secretKey && !value) {
|
||||
return Promise.reject(new Error("Access Key ID is required when Secret Access Key is provided"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
]}
|
||||
name={ACCESS_KEY_PATH}
|
||||
rules={{
|
||||
deps: ["credentials.aws_secret_access_key"],
|
||||
validate: {
|
||||
pairedWithSecret: requiredWhenSiblingSet(
|
||||
SECRET_KEY_PATH,
|
||||
"Access Key ID is required when Secret Access Key is provided",
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="AKIA... (optional — uses IAM role if blank)"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input.Password
|
||||
{...textControl(control)}
|
||||
placeholder="AKIA... (optional — uses IAM role if blank)"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
AWS Secret Access Key
|
||||
<Tooltip title="Optional. Required if AWS Access Key ID is provided.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<FieldLabel label="AWS Secret Access Key" tooltip="Optional. Required if AWS Access Key ID is provided." />
|
||||
}
|
||||
name={["credentials", "aws_secret_access_key"]}
|
||||
dependencies={[["credentials", "aws_access_key_id"]]}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
const accessKeyId = getFieldValue(["credentials", "aws_access_key_id"]);
|
||||
if (accessKeyId && !value) {
|
||||
return Promise.reject(new Error("Secret Access Key is required when Access Key ID is provided"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
]}
|
||||
name={SECRET_KEY_PATH}
|
||||
rules={{
|
||||
deps: ["credentials.aws_access_key_id"],
|
||||
validate: {
|
||||
pairedWithAccessKey: requiredWhenSiblingSet(
|
||||
ACCESS_KEY_PATH,
|
||||
"Secret Access Key is required when Access Key ID is provided",
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="Enter secret key (optional — uses IAM role if blank)"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
AWS Session Token
|
||||
<Tooltip title="Optional. Only needed for temporary STS credentials.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
{(control) => (
|
||||
<Input.Password
|
||||
{...textControl(control)}
|
||||
placeholder="Enter secret key (optional — uses IAM role if blank)"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="AWS Session Token" tooltip="Optional. Only needed for temporary STS credentials." />}
|
||||
name={["credentials", "aws_session_token"]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="Enter session token (optional)"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input.Password
|
||||
{...textControl(control)}
|
||||
placeholder="Enter session token (optional)"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
AWS Role ARN
|
||||
<Tooltip title="Optional. IAM role ARN to assume via STS before signing. If set, LiteLLM calls sts:AssumeRole to get temporary credentials. Uses ambient credentials (IAM role, env vars) as the source identity unless explicit keys are also provided.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<FieldLabel
|
||||
label="AWS Role ARN"
|
||||
tooltip="Optional. IAM role ARN to assume via STS before signing. If set, LiteLLM calls sts:AssumeRole to get temporary credentials. Uses ambient credentials (IAM role, env vars) as the source identity unless explicit keys are also provided."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "aws_role_name"]}
|
||||
>
|
||||
<Input
|
||||
placeholder="arn:aws:iam::123456789012:role/MyRole (optional)"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="arn:aws:iam::123456789012:role/MyRole (optional)"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
AWS Session Name
|
||||
<Tooltip title="Optional. Session name for the AssumeRole call — appears in CloudTrail logs. Auto-generated if omitted.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<FieldLabel
|
||||
label="AWS Session Name"
|
||||
tooltip="Optional. Session name for the AssumeRole call — appears in CloudTrail logs. Auto-generated if omitted."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "aws_session_name"]}
|
||||
>
|
||||
<Input
|
||||
placeholder="litellm-prod (optional, auto-generated if blank)"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="litellm-prod (optional, auto-generated if blank)"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -2147,7 +2147,7 @@ describe("CreateMCPServer dcr_bridge toggle", () => {
|
|||
});
|
||||
|
||||
// Forcing dcr_bridge false for every non-client-forwarded auth type is covered in
|
||||
// createServerPayload.test.ts. The two form-state cases below stay: they prove the Form.Item
|
||||
// createServerPayload.test.ts. The two form-state cases below stay: they prove the field
|
||||
// unmounts on a switch away, and that the live value survives a client-forwarded swap.
|
||||
|
||||
it("forces dcr_bridge: false when the auth type is switched away after toggling", async () => {
|
||||
|
|
@ -2179,7 +2179,7 @@ describe("CreateMCPServer dcr_bridge toggle", () => {
|
|||
});
|
||||
expect(getDcrToggle()).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
// The Form.Item is mounted in both client-forwarded modes, so switching between them keeps the
|
||||
// The field 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(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as networking from "@/components/networking";
|
||||
import CreateMCPServer from "./CreateMCPServer";
|
||||
import { selectAntOption } from "./testUtils";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
createMCPServer: vi.fn(),
|
||||
fetchOpenAPIRegistry: vi.fn().mockResolvedValue({ apis: [] }),
|
||||
registerMCPServer: vi.fn(),
|
||||
storeMCPOAuthUserCredential: vi.fn().mockResolvedValue({}),
|
||||
testMCPToolsListRequest: vi.fn().mockResolvedValue({ tools: [], error: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/mcpTokenStore", () => ({
|
||||
setToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./OpenAPIQuickPicker", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useMcpOAuthFlow", () => ({
|
||||
useMcpOAuthFlow: () => ({
|
||||
startOAuthFlow: vi.fn(),
|
||||
status: "idle",
|
||||
error: null,
|
||||
tokenResponse: null,
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./mcp_server_cost_config", () => ({
|
||||
default: () => <div data-testid="mcp-cost-config" />,
|
||||
}));
|
||||
|
||||
vi.mock("./mcp_tool_configuration", () => ({
|
||||
default: () => <div data-testid="mcp-tool-config" />,
|
||||
}));
|
||||
|
||||
vi.mock("./mcp_connection_status", () => ({
|
||||
default: () => <div data-testid="mcp-connection-status" />,
|
||||
}));
|
||||
|
||||
vi.mock("./StdioConfiguration", () => ({
|
||||
default: () => <div data-testid="stdio-config" />,
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
userRole: "Admin",
|
||||
accessToken: "test-token",
|
||||
onCreateSuccess: vi.fn(),
|
||||
isModalVisible: true,
|
||||
setModalVisible: vi.fn(),
|
||||
availableAccessGroups: ["group-a", "group-b"],
|
||||
};
|
||||
|
||||
const getServerNameInput = () => document.getElementById("server_name") as HTMLInputElement;
|
||||
|
||||
const switchFor = (labelText: string): HTMLElement => {
|
||||
const label = screen.getByText(labelText);
|
||||
const row = label.closest(".flex.items-start.justify-between");
|
||||
const control = row?.querySelector("button[role='switch']");
|
||||
if (control === null || control === undefined) {
|
||||
throw new Error(`no switch found for "${labelText}"`);
|
||||
}
|
||||
return control as HTMLElement;
|
||||
};
|
||||
|
||||
const fillMinimalHttpServer = async (name: string) => {
|
||||
await selectAntOption("Transport Type", "Streamable HTTP");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument();
|
||||
});
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.type(getServerNameInput(), name);
|
||||
await user.type(screen.getByPlaceholderText("https://your-mcp-server.com"), "https://example.com/mcp");
|
||||
await selectAntOption("Authentication", "None");
|
||||
};
|
||||
|
||||
const submitAndReadPayload = async () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add MCP Server" }));
|
||||
});
|
||||
await waitFor(() => expect(networking.createMCPServer).toHaveBeenCalledTimes(1));
|
||||
return vi.mocked(networking.createMCPServer).mock.calls[0][1];
|
||||
};
|
||||
|
||||
const createdServer = {
|
||||
server_id: "new-server-1",
|
||||
server_name: "Perm_Server",
|
||||
alias: "Perm_Server",
|
||||
url: "https://example.com/mcp",
|
||||
transport: "http",
|
||||
auth_type: "none",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
created_by: "user-1",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
updated_by: "user-1",
|
||||
};
|
||||
|
||||
describe("CreateMCPServer permission toggles reaching the payload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(networking.createMCPServer).mockResolvedValue(createdServer);
|
||||
});
|
||||
|
||||
it("sends the panel's untouched defaults rather than dropping the keys the panel owns", async () => {
|
||||
render(<CreateMCPServer {...defaultProps} />);
|
||||
await fillMinimalHttpServer("Perm_Server");
|
||||
|
||||
const payload = await submitAndReadPayload();
|
||||
|
||||
expect(payload.allow_all_keys).toBe(false);
|
||||
expect(payload.available_on_public_internet).toBe(true);
|
||||
});
|
||||
|
||||
it("sends allow_all_keys true once the operator turns the public-to-all-keys switch on", async () => {
|
||||
render(<CreateMCPServer {...defaultProps} />);
|
||||
await fillMinimalHttpServer("Perm_Server");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(switchFor("Allow All LiteLLM Keys"));
|
||||
});
|
||||
|
||||
const payload = await submitAndReadPayload();
|
||||
|
||||
expect(payload.allow_all_keys).toBe(true);
|
||||
});
|
||||
|
||||
it("sends available_on_public_internet false when the operator restricts the server to the internal network", async () => {
|
||||
render(<CreateMCPServer {...defaultProps} />);
|
||||
await fillMinimalHttpServer("Perm_Server");
|
||||
|
||||
const internalOnly = switchFor("Internal network only");
|
||||
expect(internalOnly).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(internalOnly);
|
||||
});
|
||||
expect(internalOnly).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
const payload = await submitAndReadPayload();
|
||||
|
||||
expect(payload.available_on_public_internet).toBe(false);
|
||||
});
|
||||
|
||||
it("omits delegate_auth_to_upstream's true value on a none-auth server, whose gate never mounts that switch", async () => {
|
||||
render(<CreateMCPServer {...defaultProps} />);
|
||||
await fillMinimalHttpServer("Perm_Server");
|
||||
|
||||
expect(screen.getByText("Allow All LiteLLM Keys")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delegate auth to upstream (PKCE passthrough)")).not.toBeInTheDocument();
|
||||
|
||||
const payload = await submitAndReadPayload();
|
||||
|
||||
expect(payload.delegate_auth_to_upstream).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import { Modal, Tooltip, Form, Select, Input as AntdInput, InputNumber, Collapse } from "antd";
|
||||
import { Modal, Tooltip, Select, Input as AntdInput, InputNumber, Collapse } from "antd";
|
||||
import { FormProvider, useForm, useWatch } from "react-hook-form";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -49,6 +50,16 @@ import { validateMCPServerUrl, validateMCPServerName } from "./utils";
|
|||
import { toast } from "@/lib/toast";
|
||||
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
|
||||
import { useTestMCPConnection } from "@/hooks/useTestMCPConnection";
|
||||
import {
|
||||
MountedFormField,
|
||||
MountedFormProvider,
|
||||
projectMountedValues,
|
||||
useMountRegistry,
|
||||
type MountedFormValues,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
import { antdRequired, antdRules } from "@/components/common_components/antdFormRules";
|
||||
import { allFieldsValue, mountedPaths, resetFields, setFieldsValue, singleBranchChange } from "./mcpFormStore";
|
||||
import { numberControl, notOnlyWhitespace, selectControl, textControl } from "./mcpFieldRules";
|
||||
import mcpLogo from "../../../../../public/assets/logos/mcp_logo.png";
|
||||
|
||||
export const mcpLogoImg = mcpLogo.src;
|
||||
|
|
@ -76,6 +87,13 @@ const payloadErrorMessage = (result: Exclude<BuildCreatePayloadResult, { kind: "
|
|||
}
|
||||
};
|
||||
|
||||
const CREATE_DEFAULTS: MountedFormValues = {
|
||||
allow_all_keys: false,
|
||||
available_on_public_internet: true,
|
||||
delegate_auth_to_upstream: false,
|
||||
oauth_passthrough: false,
|
||||
};
|
||||
|
||||
const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||
userID,
|
||||
userRole,
|
||||
|
|
@ -87,7 +105,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
prefillData,
|
||||
onBackToDiscovery,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const form = useForm<MountedFormValues>({ mode: "onChange", defaultValues: CREATE_DEFAULTS });
|
||||
const registry = useMountRegistry();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [costConfig, setCostConfig] = useState<MCPServerCostInfo>({});
|
||||
const [formValues, setFormValues] = useState<Record<string, any>>({});
|
||||
|
|
@ -136,6 +155,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
enabled: true,
|
||||
});
|
||||
|
||||
const authSectionMounted = transportType !== "stdio" && transportType !== "";
|
||||
const watchedAuthType = useWatch({ control: form.control, name: "auth_type" }) as string | undefined;
|
||||
const authType = formValues.auth_type as string | undefined;
|
||||
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
|
||||
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
|
||||
|
|
@ -147,7 +168,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
const persistCreateUiState = () => {
|
||||
writeCreateUiSnapshot({
|
||||
modalVisible: isModalVisible,
|
||||
formValues: form.getFieldsValue(true),
|
||||
formValues: allFieldsValue(form),
|
||||
transportType,
|
||||
costConfig,
|
||||
allowedTools,
|
||||
|
|
@ -170,11 +191,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
// Merge the ref-held DCR client so a re-authorize reuses the registered client instead of
|
||||
// re-registering; the form store itself never holds the DCR client (see onTokenReceived).
|
||||
getCredentials: () => ({
|
||||
...((form.getFieldValue("credentials") as Record<string, unknown> | undefined) ?? {}),
|
||||
...((allFieldsValue(form).credentials as Record<string, unknown> | undefined) ?? {}),
|
||||
...(dcrClientRef.current ?? {}),
|
||||
}),
|
||||
getTemporaryPayload: () => {
|
||||
const values = form.getFieldsValue(true);
|
||||
const values = allFieldsValue(form);
|
||||
const transport = values.transport || transportType;
|
||||
// For OpenAPI transport the form has spec_path instead of url.
|
||||
// We pass the spec_path as url so the temp-session endpoint has something
|
||||
|
|
@ -218,12 +239,12 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
if (isClientForwardedTokenMode(form.getFieldValue("auth_type"))) {
|
||||
if (isClientForwardedTokenMode(allFieldsValue(form).auth_type)) {
|
||||
// Browser-only modes: the token is held in local state (oauthAccessToken) for tool preview
|
||||
// and committed to sessionStorage on submit; it must never be written into form.credentials,
|
||||
// which would persist it as server-level credentials on the created server row. Mirrors the
|
||||
// edit form's onTokenReceived early return.
|
||||
setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true)));
|
||||
setAuthorizedIdentity(getOAuthAuthorizationIdentity(allFieldsValue(form)));
|
||||
toast.success(
|
||||
"Token held for this browser session. Tools can now be previewed and configured; the token is not saved to LiteLLM.",
|
||||
);
|
||||
|
|
@ -240,7 +261,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
}
|
||||
: null;
|
||||
|
||||
const current = (form.getFieldValue("credentials") as Record<string, unknown> | undefined) ?? {};
|
||||
const current = (allFieldsValue(form).credentials as Record<string, unknown> | undefined) ?? {};
|
||||
const nextCredentials = {
|
||||
...(preservedAdminCredentials(current) ?? {}),
|
||||
...(current.scopes !== undefined && { scopes: current.scopes }),
|
||||
|
|
@ -252,10 +273,10 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
// Path-replace (not deep-merge) so a re-authorize with fewer token fields does not leave stale
|
||||
// siblings from the previous token behind; the admin-typed client keys and scopes are carried
|
||||
// explicitly above.
|
||||
form.setFieldValue("credentials", nextCredentials);
|
||||
form.setValue("credentials", nextCredentials);
|
||||
// Capture the identity AFTER writing the token so the held token is not spuriously invalidated by
|
||||
// its own credential write.
|
||||
setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true)));
|
||||
setAuthorizedIdentity(getOAuthAuthorizationIdentity(allFieldsValue(form)));
|
||||
|
||||
toast.success("OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.");
|
||||
},
|
||||
|
|
@ -277,10 +298,10 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
// Capture the admin-typed app before resetFields destroys it, then re-apply it: the app is
|
||||
// upstream-scoped config, not minted material, so it survives every invalidation (the token is
|
||||
// what gets discarded). Token-shaped keys are excluded by the helper's key filter.
|
||||
const keptAdminCredentials = preservedAdminCredentials(form.getFieldValue("credentials"));
|
||||
form.resetFields([...CLEARED_ON_INVALIDATION]);
|
||||
const keptAdminCredentials = preservedAdminCredentials(allFieldsValue(form).credentials);
|
||||
resetFields(form, [...CLEARED_ON_INVALIDATION]);
|
||||
if (keptAdminCredentials) {
|
||||
form.setFieldsValue({ credentials: keptAdminCredentials });
|
||||
setFieldsValue(form, { credentials: keptAdminCredentials });
|
||||
}
|
||||
// Re-apply the in-flight edit last; rc-field-form deep-merges nested objects, so a changed
|
||||
// credentials sub-field composes with the preserved sibling instead of replacing the object.
|
||||
|
|
@ -288,7 +309,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
CLEARED_ON_INVALIDATION.filter((key) => key in changedValues).map((key) => [key, changedValues[key]]),
|
||||
);
|
||||
if (Object.keys(preserved).length > 0) {
|
||||
form.setFieldsValue(preserved);
|
||||
setFieldsValue(form, preserved);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -337,7 +358,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
// wait until transportType state catches up so the URL field is mounted
|
||||
return;
|
||||
}
|
||||
form.setFieldsValue(pendingRestoredValues.values);
|
||||
setFieldsValue(form, pendingRestoredValues.values);
|
||||
setFormValues(pendingRestoredValues.values);
|
||||
setPendingRestoredValues(null);
|
||||
}, [pendingRestoredValues, form, transportType]);
|
||||
|
|
@ -381,11 +402,20 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
prefillValues.url = prefillData.url;
|
||||
}
|
||||
|
||||
form.setFieldsValue(prefillValues);
|
||||
setFieldsValue(form, prefillValues);
|
||||
setFormValues(prefillValues);
|
||||
setAliasManuallyEdited(false);
|
||||
}, [isModalVisible, prefillData, form]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const isValid = await form.trigger(mountedPaths(registry) as string[]);
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
await handleCreate(projectMountedValues(registry, form.getValues));
|
||||
};
|
||||
|
||||
const handleCreate = async (values: Record<string, unknown>) => {
|
||||
const built = buildCreateServerPayload(values, {
|
||||
transportType,
|
||||
|
|
@ -446,7 +476,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
description: "Once an admin approves it, the server will appear in your MCP Servers list.",
|
||||
});
|
||||
}
|
||||
form.resetFields();
|
||||
form.reset(CREATE_DEFAULTS);
|
||||
setCostConfig({});
|
||||
clearTools();
|
||||
setAllowedTools([]);
|
||||
|
|
@ -466,7 +496,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
|
||||
// state
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
form.reset(CREATE_DEFAULTS);
|
||||
setCostConfig({});
|
||||
clearTools();
|
||||
setAllowedTools([]);
|
||||
|
|
@ -489,11 +519,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
? { url: undefined, command: undefined, args: undefined, env: undefined }
|
||||
: { spec_path: undefined, command: undefined, args: undefined, env: undefined };
|
||||
|
||||
form.setFieldsValue(transportValues);
|
||||
if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentity)) {
|
||||
setFieldsValue(form, transportValues);
|
||||
if (isHeldOAuthTokenStale(allFieldsValue(form), authorizedIdentity)) {
|
||||
clearHeldOAuthToken();
|
||||
}
|
||||
setFormValues(form.getFieldsValue(true));
|
||||
setFormValues(allFieldsValue(form));
|
||||
};
|
||||
|
||||
// Generate options with existing groups and potential new group
|
||||
|
|
@ -532,7 +562,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
React.useEffect(() => {
|
||||
if (!aliasManuallyEdited && formValues.server_name) {
|
||||
const normalized = formValues.server_name.replace(/\s+/g, "_");
|
||||
form.setFieldsValue({ alias: normalized });
|
||||
setFieldsValue(form, { alias: normalized });
|
||||
setFormValues((prev) => ({ ...prev, alias: normalized }));
|
||||
}
|
||||
}, [formValues.server_name]);
|
||||
|
|
@ -549,7 +579,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
const wasVisible = wasModalVisibleRef.current;
|
||||
wasModalVisibleRef.current = isModalVisible;
|
||||
if (!isModalVisible && wasVisible) {
|
||||
form.resetFields();
|
||||
form.reset(CREATE_DEFAULTS);
|
||||
setFormValues({});
|
||||
setOauthAccessToken(null);
|
||||
clearTools();
|
||||
|
|
@ -582,19 +612,35 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
const upstreamChanged = ["url", "spec_path", "issuer", "authorization_url", "token_url", "registration_url"].some(
|
||||
(key) => key in changedValues,
|
||||
);
|
||||
const hasDeclaredApp = preservedDeclaredAppCredentials(form.getFieldValue("credentials")) !== undefined;
|
||||
const hasDeclaredApp = preservedDeclaredAppCredentials(allFieldsValue(form).credentials) !== undefined;
|
||||
if (upstreamChanged && hasDeclaredApp) {
|
||||
setAppMayNotMatchUpstream(true);
|
||||
}
|
||||
}
|
||||
if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentity)) {
|
||||
if (isHeldOAuthTokenStale(allFieldsValue(form), authorizedIdentity)) {
|
||||
clearHeldOAuthToken(changedValues);
|
||||
setFormValues(form.getFieldsValue(true));
|
||||
setFormValues(allFieldsValue(form));
|
||||
return;
|
||||
}
|
||||
setFormValues(allValues);
|
||||
};
|
||||
|
||||
const valuesChangeRef = React.useRef(handleFormValuesChange);
|
||||
valuesChangeRef.current = handleFormValuesChange;
|
||||
|
||||
React.useEffect(() => {
|
||||
const subscription = form.watch((values, { name, type }) => {
|
||||
if (type !== "change" || name === undefined) {
|
||||
return;
|
||||
}
|
||||
valuesChangeRef.current(
|
||||
singleBranchChange(name, values as MountedFormValues),
|
||||
projectMountedValues(registry, form.getValues),
|
||||
);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, registry]);
|
||||
|
||||
// rendering
|
||||
return (
|
||||
<Modal
|
||||
|
|
@ -636,334 +682,370 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
}}
|
||||
>
|
||||
<div className="mt-6">
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleCreate}
|
||||
onValuesChange={handleFormValuesChange}
|
||||
layout="vertical"
|
||||
className="space-y-6"
|
||||
>
|
||||
{!isAdmin && (
|
||||
<div className="rounded-md bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-800">
|
||||
Your submission will be sent for admin review. Once approved, the server will appear in your MCP Servers
|
||||
list. The request must be made with a team-scoped API key.
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
MCP Server Name
|
||||
<Tooltip title="Best practice: Use a descriptive name that indicates the server's purpose (e.g., 'GitHub_MCP', 'Email_Service'). Cannot contain spaces or hyphens; use underscores instead. Names must comply with SEP-986 and will be rejected if invalid (https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names).">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="server_name"
|
||||
rules={[
|
||||
{ required: false, message: "Please enter a server name" },
|
||||
{ validator: (_, value) => validateMCPServerName(value) },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="e.g., GitHub_MCP, Zapier_MCP, etc."
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{!isAdmin && (
|
||||
<div className="rounded-md bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-800">
|
||||
Your submission will be sent for admin review. Once approved, the server will appear in your MCP
|
||||
Servers list. The request must be made with a team-scoped API key.
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
MCP Server Name
|
||||
<Tooltip title="Best practice: Use a descriptive name that indicates the server's purpose (e.g., 'GitHub_MCP', 'Email_Service'). Cannot contain spaces or hyphens; use underscores instead. Names must comply with SEP-986 and will be rejected if invalid (https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names).">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="server_name"
|
||||
rules={{ validate: antdRules({ validator: (_, value) => validateMCPServerName(value) }) }}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="e.g., GitHub_MCP, Zapier_MCP, etc."
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Alias
|
||||
<Tooltip title="A short, unique identifier for this server. Defaults to the server name if not provided. Cannot contain spaces or hyphens; use underscores instead.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="alias"
|
||||
rules={[{ required: false }, { validator: (_, value) => validateMCPServerName(value) }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="e.g., GitHub_MCP, Zapier_MCP, etc."
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
onChange={() => setAliasManuallyEdited(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Alias
|
||||
<Tooltip title="A short, unique identifier for this server. Defaults to the server name if not provided. Cannot contain spaces or hyphens; use underscores instead.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="alias"
|
||||
rules={{ validate: antdRules({ validator: (_, value) => validateMCPServerName(value) }) }}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="e.g., GitHub_MCP, Zapier_MCP, etc."
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
onChange={(event) => {
|
||||
control.onChange(event);
|
||||
setAliasManuallyEdited(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-sm font-medium text-gray-700">Description</span>}
|
||||
name="description"
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
message: "Please enter a server description",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="Brief description of what this server does"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-gray-700">Description</span>}
|
||||
name="description"
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="Brief description of what this server does"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<MCPLogoSelector value={logoUrl} onChange={setLogoUrl} />
|
||||
<MCPLogoSelector value={logoUrl} onChange={setLogoUrl} />
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-sm font-medium text-gray-700">GitHub / Source URL</span>}
|
||||
name="source_url"
|
||||
>
|
||||
<Input
|
||||
placeholder="https://github.com/org/mcp-server"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-gray-700">GitHub / Source URL</span>}
|
||||
name="source_url"
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="https://github.com/org/mcp-server"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-sm font-medium text-gray-700">Transport Type</span>}
|
||||
name="transport"
|
||||
rules={[{ required: true, message: "Please select a transport type" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Select transport"
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
onChange={handleTransportChange}
|
||||
value={transportType}
|
||||
>
|
||||
<Select.Option value="http">Streamable HTTP (Recommended)</Select.Option>
|
||||
<Select.Option value="sse">Server-Sent Events (SSE)</Select.Option>
|
||||
<Select.Option value="stdio">Standard Input/Output (stdio)</Select.Option>
|
||||
<Select.Option value={TRANSPORT.OPENAPI}>OpenAPI Spec</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-gray-700">Transport Type</span>}
|
||||
name="transport"
|
||||
required
|
||||
rules={{ validate: { required: antdRequired("Please select a transport type") } }}
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...selectControl<string>(control)}
|
||||
placeholder="Select transport"
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
onChange={(value: string) => {
|
||||
control.onChange(value);
|
||||
handleTransportChange(value);
|
||||
}}
|
||||
>
|
||||
<Select.Option value="http">Streamable HTTP (Recommended)</Select.Option>
|
||||
<Select.Option value="sse">Server-Sent Events (SSE)</Select.Option>
|
||||
<Select.Option value="stdio">Standard Input/Output (stdio)</Select.Option>
|
||||
<Select.Option value={TRANSPORT.OPENAPI}>OpenAPI Spec</Select.Option>
|
||||
</Select>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
{/* URL field - only show for HTTP and SSE */}
|
||||
{(transportType === "http" || transportType === "sse") && (
|
||||
<Form.Item
|
||||
label={<span className="text-sm font-medium text-gray-700">MCP Server URL</span>}
|
||||
name="url"
|
||||
rules={[
|
||||
{ required: true, message: "Please enter a server URL" },
|
||||
{ validator: (_, value) => validateMCPServerUrl(value) },
|
||||
]}
|
||||
>
|
||||
<AntdInput
|
||||
placeholder="https://your-mcp-server.com"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{/* URL field - only show for HTTP and SSE */}
|
||||
{(transportType === "http" || transportType === "sse") && (
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-gray-700">MCP Server URL</span>}
|
||||
name="url"
|
||||
required
|
||||
rules={{
|
||||
validate: {
|
||||
required: antdRequired("Please enter a server URL"),
|
||||
...antdRules({ validator: (_, value) => validateMCPServerUrl(value) }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<AntdInput
|
||||
{...textControl(control)}
|
||||
placeholder="https://your-mcp-server.com"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
)}
|
||||
|
||||
{/* OpenAPI: logo picker + spec URL input */}
|
||||
{transportType === TRANSPORT.OPENAPI && (
|
||||
<OpenAPIFormSection
|
||||
form={form}
|
||||
accessToken={isModalVisible ? accessToken : null}
|
||||
onValuesChange={(updates) =>
|
||||
handleFormValuesChange(updates, { ...form.getFieldsValue(true), ...updates })
|
||||
}
|
||||
onKeyToolsChange={setKeyTools}
|
||||
onLogoUrlChange={setLogoUrl}
|
||||
onOAuthDocsUrlChange={setOauthDocsUrl}
|
||||
/>
|
||||
)}
|
||||
{/* OpenAPI: logo picker + spec URL input */}
|
||||
{transportType === TRANSPORT.OPENAPI && (
|
||||
<OpenAPIFormSection
|
||||
form={form}
|
||||
accessToken={isModalVisible ? accessToken : null}
|
||||
onValuesChange={(updates) =>
|
||||
handleFormValuesChange(updates, { ...allFieldsValue(form), ...updates })
|
||||
}
|
||||
onKeyToolsChange={setKeyTools}
|
||||
onLogoUrlChange={setLogoUrl}
|
||||
onOAuthDocsUrlChange={setOauthDocsUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* BYOK toggle - only for OpenAPI */}
|
||||
{transportType === TRANSPORT.OPENAPI && <OpenApiByokFields />}
|
||||
{/* BYOK toggle - only for OpenAPI */}
|
||||
{transportType === TRANSPORT.OPENAPI && <OpenApiByokFields />}
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Max Concurrent Requests (optional)
|
||||
<Tooltip title="Maximum number of tool calls LiteLLM will run against this server at the same time. Additional calls wait for a free slot. Leave blank for no limit.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="max_concurrent_requests"
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
precision={0}
|
||||
placeholder="e.g. 10"
|
||||
style={{ width: "100%" }}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</Form.Item>
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Max Concurrent Requests (optional)
|
||||
<Tooltip title="Maximum number of tool calls LiteLLM will run against this server at the same time. Additional calls wait for a free slot. Leave blank for no limit.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="max_concurrent_requests"
|
||||
>
|
||||
{(control) => (
|
||||
<InputNumber
|
||||
{...numberControl(control)}
|
||||
min={1}
|
||||
precision={0}
|
||||
placeholder="e.g. 10"
|
||||
style={{ width: "100%" }}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
{/* Authentication - show for HTTP, SSE, and OpenAPI */}
|
||||
{transportType !== "stdio" && transportType !== "" && (
|
||||
<Collapse
|
||||
defaultActiveKey={["auth"]}
|
||||
className="mb-4"
|
||||
items={[
|
||||
{
|
||||
key: "auth",
|
||||
label: <span className="text-sm font-semibold text-gray-700">Authentication</span>,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="auth_type" rules={[{ required: true, message: "Please select an auth type" }]}>
|
||||
<Select placeholder="Select auth type" className="rounded-lg" size="large" virtual={false}>
|
||||
<Select.Option value="none">None</Select.Option>
|
||||
<Select.Option value="api_key">API Key</Select.Option>
|
||||
<Select.Option value="bearer_token">Bearer Token</Select.Option>
|
||||
<Select.Option value="token">Token</Select.Option>
|
||||
<Select.Option value="basic">Basic Auth</Select.Option>
|
||||
<Select.Option value="oauth2">OAuth</Select.Option>
|
||||
<Select.Option value="oauth2_token_exchange">OAuth Token Exchange (OBO)</Select.Option>
|
||||
<Select.Option value="oauth2_id_jag">ID-JAG (Okta Cross App Access)</Select.Option>
|
||||
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
|
||||
<Select.Option value="true_passthrough">True Passthrough (no LiteLLM auth)</Select.Option>
|
||||
<Select.Option value="oauth_delegate">
|
||||
OAuth Delegate (client-supplied upstream token)
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{/* Authentication - show for HTTP, SSE, and OpenAPI */}
|
||||
{transportType !== "stdio" && transportType !== "" && (
|
||||
<Collapse
|
||||
defaultActiveKey={["auth"]}
|
||||
className="mb-4"
|
||||
items={[
|
||||
{
|
||||
key: "auth",
|
||||
label: <span className="text-sm font-semibold text-gray-700">Authentication</span>,
|
||||
children: (
|
||||
<>
|
||||
<MountedFormField
|
||||
name="auth_type"
|
||||
required
|
||||
rules={{ validate: { required: antdRequired("Please select an auth type") } }}
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...selectControl<string>(control)}
|
||||
placeholder="Select auth type"
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
virtual={false}
|
||||
>
|
||||
<Select.Option value="none">None</Select.Option>
|
||||
<Select.Option value="api_key">API Key</Select.Option>
|
||||
<Select.Option value="bearer_token">Bearer Token</Select.Option>
|
||||
<Select.Option value="token">Token</Select.Option>
|
||||
<Select.Option value="basic">Basic Auth</Select.Option>
|
||||
<Select.Option value="oauth2">OAuth</Select.Option>
|
||||
<Select.Option value="oauth2_token_exchange">
|
||||
OAuth Token Exchange (OBO)
|
||||
</Select.Option>
|
||||
<Select.Option value="oauth2_id_jag">ID-JAG (Okta Cross App Access)</Select.Option>
|
||||
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
|
||||
<Select.Option value="true_passthrough">
|
||||
True Passthrough (no LiteLLM auth)
|
||||
</Select.Option>
|
||||
<Select.Option value="oauth_delegate">
|
||||
OAuth Delegate (client-supplied upstream token)
|
||||
</Select.Option>
|
||||
</Select>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<TruePassthroughWarning authType={authType} />
|
||||
<TruePassthroughWarning authType={authType} />
|
||||
|
||||
<PassthroughAuthorizeSection
|
||||
authType={authType}
|
||||
dcrBridgeInitialChecked
|
||||
oauthFlow={{
|
||||
startOAuthFlow,
|
||||
status: oauthStatus,
|
||||
error: oauthError,
|
||||
tokenResponse: oauthTokenResponse,
|
||||
}}
|
||||
appMayNotMatchUpstream={appMayNotMatchUpstream}
|
||||
/>
|
||||
|
||||
{shouldShowAuthValueField && (
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Authentication Value
|
||||
<Tooltip title="Token, password, or header value to send with each request for the selected auth type.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "auth_value"]}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) =>
|
||||
value && typeof value === "string" && value.trim() === ""
|
||||
? Promise.reject(new Error("Authentication value cannot be empty whitespace"))
|
||||
: Promise.resolve(),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<AntdInput.Password
|
||||
placeholder="Enter token or secret"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
<PassthroughAuthorizeSection
|
||||
authType={authType}
|
||||
dcrBridgeInitialChecked
|
||||
oauthFlow={{
|
||||
startOAuthFlow,
|
||||
status: oauthStatus,
|
||||
error: oauthError,
|
||||
tokenResponse: oauthTokenResponse,
|
||||
}}
|
||||
appMayNotMatchUpstream={appMayNotMatchUpstream}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{isOAuthAuthType && (
|
||||
<OAuthFormFields
|
||||
isM2M={isM2MFlow}
|
||||
initialFlowType={OAUTH_FLOW.INTERACTIVE}
|
||||
docsUrl={oauthDocsUrl}
|
||||
oauthFlow={{
|
||||
startOAuthFlow,
|
||||
status: oauthStatus,
|
||||
error: oauthError,
|
||||
tokenResponse: oauthTokenResponse,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{shouldShowAuthValueField && (
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Authentication Value
|
||||
<Tooltip title="Token, password, or header value to send with each request for the selected auth type.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "auth_value"]}
|
||||
rules={{
|
||||
validate: {
|
||||
notWhitespace: notOnlyWhitespace("Authentication value cannot be empty whitespace"),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<AntdInput.Password
|
||||
{...textControl(control)}
|
||||
placeholder="Enter token or secret"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
)}
|
||||
|
||||
{isTokenExchangeAuthType && <TokenExchangeFormFields />}
|
||||
{isOAuthAuthType && (
|
||||
<OAuthFormFields
|
||||
isM2M={isM2MFlow}
|
||||
initialFlowType={OAUTH_FLOW.INTERACTIVE}
|
||||
docsUrl={oauthDocsUrl}
|
||||
oauthFlow={{
|
||||
startOAuthFlow,
|
||||
status: oauthStatus,
|
||||
error: oauthError,
|
||||
tokenResponse: oauthTokenResponse,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isIdJagAuthType && <IdJagFormFields />}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{isTokenExchangeAuthType && <TokenExchangeFormFields />}
|
||||
|
||||
{transportType !== "stdio" && transportType !== "" && isAwsSigV4AuthType && <AwsSigV4Fields />}
|
||||
{isIdJagAuthType && <IdJagFormFields />}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stdio Configuration - only show for stdio transport */}
|
||||
<StdioConfiguration isVisible={transportType === "stdio"} />
|
||||
</div>
|
||||
{transportType !== "stdio" && transportType !== "" && isAwsSigV4AuthType && <AwsSigV4Fields />}
|
||||
|
||||
{/* Environment Variables Section */}
|
||||
<div className="mt-8">
|
||||
<EnvVarsSection />
|
||||
</div>
|
||||
{/* Stdio Configuration - only show for stdio transport */}
|
||||
<StdioConfiguration isVisible={transportType === "stdio"} />
|
||||
</div>
|
||||
|
||||
{/* Permission Management / Access Control Section */}
|
||||
<div className="mt-8">
|
||||
<MCPPermissionManagement
|
||||
availableAccessGroups={availableAccessGroups}
|
||||
mcpServer={null}
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
getAccessGroupOptions={getAccessGroupOptions}
|
||||
/>
|
||||
</div>
|
||||
{/* Environment Variables Section */}
|
||||
<div className="mt-8">
|
||||
<EnvVarsSection />
|
||||
</div>
|
||||
|
||||
{/* Connection Status Section */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<MCPConnectionStatus
|
||||
formValues={formValues}
|
||||
tools={tools}
|
||||
isLoadingTools={isLoadingTools}
|
||||
toolsError={toolsError}
|
||||
toolsErrorStatus={toolsErrorStatus}
|
||||
toolsErrorStackTrace={toolsErrorStackTrace}
|
||||
canFetchTools={canFetchTools}
|
||||
fetchTools={fetchTools}
|
||||
/>
|
||||
</div>
|
||||
{/* Permission Management / Access Control Section */}
|
||||
<div className="mt-8">
|
||||
<MCPPermissionManagement
|
||||
availableAccessGroups={availableAccessGroups}
|
||||
mcpServer={null}
|
||||
mountedAuthType={authSectionMounted ? watchedAuthType : undefined}
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
getAccessGroupOptions={getAccessGroupOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tool Configuration Section */}
|
||||
<div className="mt-6">
|
||||
<MCPToolConfiguration
|
||||
accessToken={accessToken}
|
||||
formValues={formValues}
|
||||
allowedTools={allowedTools}
|
||||
existingAllowedTools={null}
|
||||
onAllowedToolsChange={setAllowedTools}
|
||||
hasToolAllowlistInteraction={hasToolAllowlistInteraction}
|
||||
onToolAllowlistInteraction={() => setHasToolAllowlistInteraction(true)}
|
||||
toolNameToDisplayName={toolNameToDisplayName}
|
||||
toolNameToDescription={toolNameToDescription}
|
||||
onToolNameToDisplayNameChange={setToolNameToDisplayName}
|
||||
onToolNameToDescriptionChange={setToolNameToDescription}
|
||||
keyTools={keyTools}
|
||||
externalTools={tools}
|
||||
externalIsLoading={isLoadingTools}
|
||||
externalError={toolsError}
|
||||
externalErrorStatus={toolsErrorStatus}
|
||||
externalCanFetch={canFetchTools}
|
||||
/>
|
||||
</div>
|
||||
{/* Connection Status Section */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<MCPConnectionStatus
|
||||
formValues={formValues}
|
||||
tools={tools}
|
||||
isLoadingTools={isLoadingTools}
|
||||
toolsError={toolsError}
|
||||
toolsErrorStatus={toolsErrorStatus}
|
||||
toolsErrorStackTrace={toolsErrorStackTrace}
|
||||
canFetchTools={canFetchTools}
|
||||
fetchTools={fetchTools}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cost Configuration Section */}
|
||||
<div className="mt-6">
|
||||
<MCPServerCostConfig
|
||||
value={costConfig}
|
||||
onChange={setCostConfig}
|
||||
tools={tools.filter((tool) => allowedTools.includes(tool.name))}
|
||||
disabled={false}
|
||||
/>
|
||||
</div>
|
||||
{/* Tool Configuration Section */}
|
||||
<div className="mt-6">
|
||||
<MCPToolConfiguration
|
||||
accessToken={accessToken}
|
||||
formValues={formValues}
|
||||
allowedTools={allowedTools}
|
||||
existingAllowedTools={null}
|
||||
onAllowedToolsChange={setAllowedTools}
|
||||
hasToolAllowlistInteraction={hasToolAllowlistInteraction}
|
||||
onToolAllowlistInteraction={() => setHasToolAllowlistInteraction(true)}
|
||||
toolNameToDisplayName={toolNameToDisplayName}
|
||||
toolNameToDescription={toolNameToDescription}
|
||||
onToolNameToDisplayNameChange={setToolNameToDisplayName}
|
||||
onToolNameToDescriptionChange={setToolNameToDescription}
|
||||
keyTools={keyTools}
|
||||
externalTools={tools}
|
||||
externalIsLoading={isLoadingTools}
|
||||
externalError={toolsError}
|
||||
externalErrorStatus={toolsErrorStatus}
|
||||
externalCanFetch={canFetchTools}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
|
||||
<Button variant="secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading} aria-busy={isLoading}>
|
||||
{isLoading && <UiLoadingSpinner className="size-4" />}
|
||||
{isLoading ? "Creating..." : "Add MCP Server"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
{/* Cost Configuration Section */}
|
||||
<div className="mt-6">
|
||||
<MCPServerCostConfig
|
||||
value={costConfig}
|
||||
onChange={setCostConfig}
|
||||
tools={tools.filter((tool) => allowedTools.includes(tool.name))}
|
||||
disabled={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
|
||||
<Button variant="secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading} aria-busy={isLoading}>
|
||||
{isLoading && <UiLoadingSpinner className="size-4" />}
|
||||
{isLoading ? "Creating..." : "Add MCP Server"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</MountedFormProvider>
|
||||
</FormProvider>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import React from "react";
|
||||
import { Form, Switch, Tooltip } from "antd";
|
||||
import { Switch, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { isClientForwardedTokenMode } from "@/components/mcp_tools/types";
|
||||
import { switchControl } from "./mcpFieldRules";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* server's own OAuth metadata instead. `initialChecked` seeds the field's
|
||||
* default value (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,
|
||||
|
|
@ -21,7 +24,7 @@ export default function DcrBridgeToggle({
|
|||
}) {
|
||||
if (!isClientForwardedTokenMode(authType)) return null;
|
||||
return (
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Gateway-hosted sign-in (DCR bridge)
|
||||
|
|
@ -31,10 +34,9 @@ export default function DcrBridgeToggle({
|
|||
</span>
|
||||
}
|
||||
name="dcr_bridge"
|
||||
valuePropName="checked"
|
||||
initialValue={initialChecked}
|
||||
defaultValue={initialChecked}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
{(control) => <Switch {...switchControl(control)} />}
|
||||
</MountedFormField>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
MountedFormProvider,
|
||||
projectMountedValues,
|
||||
useMountRegistry,
|
||||
type MountedFormValues,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
import EnvVarsSection from "./EnvVarsSection";
|
||||
|
||||
const renderSection = (defaultValues: MountedFormValues) => {
|
||||
const onFinish = vi.fn();
|
||||
const Harness: React.FC = () => {
|
||||
const form = useForm<MountedFormValues>({ mode: "onChange", defaultValues });
|
||||
const registry = useMountRegistry();
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onFinish(projectMountedValues(registry, form.getValues));
|
||||
}}
|
||||
>
|
||||
<EnvVarsSection />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MountedFormProvider>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
render(<Harness />);
|
||||
return onFinish;
|
||||
};
|
||||
|
||||
describe("EnvVarsSection", () => {
|
||||
it("submits a per-user row whole, keeping the value key whose input the scope hides", async () => {
|
||||
const onFinish = renderSection({
|
||||
env_vars: [{ name: "DB_USER", value: "admin", scope: "user", description: "Your DB username" }],
|
||||
});
|
||||
|
||||
expect(screen.queryByPlaceholderText("e.g. postgresql")).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("Submit"));
|
||||
|
||||
expect(onFinish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env_vars: [{ name: "DB_USER", value: "admin", scope: "user", description: "Your DB username" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("submits an empty env_vars key when the list has no rows, rather than dropping the key", async () => {
|
||||
const onFinish = renderSection({ env_vars: [] });
|
||||
|
||||
await userEvent.click(screen.getByText("Submit"));
|
||||
|
||||
expect(onFinish.mock.calls[0][0]).toHaveProperty("env_vars", []);
|
||||
});
|
||||
|
||||
it("carries a row added after mount into the submitted list, scoped global without the user picking one", async () => {
|
||||
const onFinish = renderSection({ env_vars: [] });
|
||||
|
||||
await userEvent.click(screen.getByText("Add Variable"));
|
||||
await userEvent.type(screen.getByPlaceholderText("e.g. DB_PROTOCOL"), "DB_PROTOCOL");
|
||||
await userEvent.type(screen.getByPlaceholderText("e.g. postgresql"), "postgresql");
|
||||
await userEvent.click(screen.getByText("Submit"));
|
||||
|
||||
expect(onFinish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env_vars: [expect.objectContaining({ name: "DB_PROTOCOL", value: "postgresql", scope: "global" })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a variable name that starts with a digit", async () => {
|
||||
renderSection({ env_vars: [{ name: "", value: "", scope: "global", description: "" }] });
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText("e.g. DB_PROTOCOL"), "9LIVES");
|
||||
|
||||
expect(await screen.findByText("Use letters, digits, underscores; cannot start with a digit.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,16 @@
|
|||
import React from "react";
|
||||
import { Form, Input, Select, Button, Tooltip, Typography } from "antd";
|
||||
import { Input, Select, Button, Tooltip, Typography } from "antd";
|
||||
import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import {
|
||||
MountedFormField,
|
||||
useMountedName,
|
||||
type MountedFormValues,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
import { antdRequired } from "@/components/common_components/antdFormRules";
|
||||
import { matchesPattern, selectControl, textControl } from "./mcpFieldRules";
|
||||
import { listControl } from "./mcpFormStore";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
@ -9,6 +19,8 @@ const SCOPE_OPTIONS = [
|
|||
{ value: "user", label: "Per-user" },
|
||||
];
|
||||
|
||||
const VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
/**
|
||||
* Form section for admin-configured MCP environment variables.
|
||||
*
|
||||
|
|
@ -20,6 +32,10 @@ const SCOPE_OPTIONS = [
|
|||
* The parent form reads the ``env_vars`` field from the form values.
|
||||
*/
|
||||
const EnvVarsSection: React.FC = () => {
|
||||
const { control } = useFormContext<MountedFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control: listControl(control), name: "env_vars" });
|
||||
useMountedName("env_vars");
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
|
|
@ -48,60 +64,52 @@ const EnvVarsSection: React.FC = () => {
|
|||
</code>
|
||||
</Text>
|
||||
|
||||
<Form.List name="env_vars">
|
||||
{(fields, { add, remove }) => (
|
||||
<div className="space-y-2">
|
||||
{fields.length > 0 && (
|
||||
<div className="flex gap-3 px-1 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
<div style={{ flex: 1 }}>Variable Name</div>
|
||||
<div style={{ flex: 1 }}>Value / Description</div>
|
||||
<div style={{ width: 160 }}>Scope</div>
|
||||
<div style={{ width: 24 }} />
|
||||
</div>
|
||||
)}
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<div key={key} className="flex gap-3 items-start">
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, "name"]}
|
||||
className="mb-0"
|
||||
style={{ flex: 1 }}
|
||||
rules={[
|
||||
{ required: true, message: "Variable name is required" },
|
||||
{
|
||||
pattern: /^[A-Za-z_][A-Za-z0-9_]*$/,
|
||||
message: "Use letters, digits, underscores; cannot start with a digit.",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="e.g. DB_PROTOCOL" className="rounded-md font-mono" />
|
||||
</Form.Item>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ScopedValueOrDescription name={name} restField={restField} />
|
||||
</div>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, "scope"]}
|
||||
className="mb-0"
|
||||
initialValue="global"
|
||||
style={{ width: 160 }}
|
||||
>
|
||||
<Select options={SCOPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<div style={{ width: 24, height: 32 }} className="flex items-center justify-center">
|
||||
<MinusCircleOutlined
|
||||
onClick={() => remove(name)}
|
||||
className="text-gray-500 hover:text-red-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ scope: "global" })} icon={<PlusOutlined />} block>
|
||||
Add Variable
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
{fields.length > 0 && (
|
||||
<div className="flex gap-3 px-1 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
<div style={{ flex: 1 }}>Variable Name</div>
|
||||
<div style={{ flex: 1 }}>Value / Description</div>
|
||||
<div style={{ width: 160 }}>Scope</div>
|
||||
<div style={{ width: 24 }} />
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
{fields.map((item, index) => (
|
||||
<div key={item.id} className="flex gap-3 items-start">
|
||||
<MountedFormField
|
||||
name={["env_vars", String(index), "name"]}
|
||||
className="mb-0 flex-1"
|
||||
rules={{
|
||||
validate: {
|
||||
required: antdRequired("Variable name is required"),
|
||||
pattern: matchesPattern(
|
||||
VARIABLE_NAME_PATTERN,
|
||||
"Use letters, digits, underscores; cannot start with a digit.",
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<Input {...textControl(control)} placeholder="e.g. DB_PROTOCOL" className="rounded-md font-mono" />
|
||||
)}
|
||||
</MountedFormField>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ScopedValueOrDescription index={index} />
|
||||
</div>
|
||||
<MountedFormField name={["env_vars", String(index), "scope"]} className="mb-0 w-40" defaultValue="global">
|
||||
{(control) => <Select {...selectControl<string>(control)} options={SCOPE_OPTIONS} />}
|
||||
</MountedFormField>
|
||||
<div style={{ width: 24, height: 32 }} className="flex items-center justify-center">
|
||||
<MinusCircleOutlined
|
||||
onClick={() => remove(index)}
|
||||
className="text-gray-500 hover:text-red-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => append({ scope: "global" })} icon={<PlusOutlined />} block>
|
||||
Add Variable
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -109,33 +117,33 @@ const EnvVarsSection: React.FC = () => {
|
|||
// For instance-scoped vars this column holds the admin value. For per-user
|
||||
// vars the value comes from each user later, so the column instead captures an
|
||||
// optional description that the per-user fill-in modal shows as a hint.
|
||||
const ScopedValueOrDescription: React.FC<{
|
||||
name: number;
|
||||
restField: object;
|
||||
}> = ({ name, restField }) => {
|
||||
const isPerUser = Form.useWatch(["env_vars", name, "scope"]) === "user";
|
||||
const ScopedValueOrDescription: React.FC<{ index: number }> = ({ index }) => {
|
||||
const isPerUser = useWatch({ name: `env_vars.${index}.scope` }) === "user";
|
||||
if (isPerUser) {
|
||||
return (
|
||||
<Form.Item {...restField} name={[name, "description"]} className="mb-0">
|
||||
<Input
|
||||
addonBefore={
|
||||
<Tooltip title="Per-user variables have no shared value. This text is only a hint shown to each user when they fill in their own value.">
|
||||
<span className="text-xs text-gray-500 cursor-help whitespace-nowrap">
|
||||
<InfoCircleOutlined className="mr-1" />
|
||||
Hint
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
placeholder="e.g. Your DB username"
|
||||
styles={{ input: { color: "#9ca3af" } }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<MountedFormField name={["env_vars", String(index), "description"]} className="mb-0">
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
addonBefore={
|
||||
<Tooltip title="Per-user variables have no shared value. This text is only a hint shown to each user when they fill in their own value.">
|
||||
<span className="text-xs text-gray-500 cursor-help whitespace-nowrap">
|
||||
<InfoCircleOutlined className="mr-1" />
|
||||
Hint
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
placeholder="e.g. Your DB username"
|
||||
styles={{ input: { color: "#9ca3af" } }}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Form.Item {...restField} name={[name, "value"]} className="mb-0">
|
||||
<Input placeholder="e.g. postgresql" className="rounded-md font-mono" />
|
||||
</Form.Item>
|
||||
<MountedFormField name={["env_vars", String(index), "value"]} className="mb-0">
|
||||
{(control) => <Input {...textControl(control)} placeholder="e.g. postgresql" className="rounded-md font-mono" />}
|
||||
</MountedFormField>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import React from "react";
|
||||
import { Form, Input, Select, Tooltip } from "antd";
|
||||
import { Input, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { antdRequired } from "@/components/common_components/antdFormRules";
|
||||
import { requiredUnlessSiblingSet, selectControl, textControl } from "./mcpFieldRules";
|
||||
|
||||
interface IdJagFormFieldsProps {
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
|
@ -17,12 +21,16 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
|
|||
</span>
|
||||
);
|
||||
|
||||
const PRIVATE_KEY_PATH = ["credentials", "client_private_key"] as const;
|
||||
|
||||
const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false }) => {
|
||||
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
|
||||
const requiredWhenCreating = (message: string) =>
|
||||
isEditing ? undefined : { validate: { required: antdRequired(message) } };
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Org Token Endpoint (leg 1)"
|
||||
|
|
@ -30,11 +38,18 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
/>
|
||||
}
|
||||
name="token_exchange_endpoint"
|
||||
rules={[{ required: !isEditing, message: "The org token endpoint is required for ID-JAG" }]}
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("The org token endpoint is required for ID-JAG")}
|
||||
>
|
||||
<Input placeholder="https://your-org.okta.com/oauth2/v1/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="https://your-org.okta.com/oauth2/v1/token"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Resource Token Endpoint (leg 2)"
|
||||
|
|
@ -42,18 +57,32 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
/>
|
||||
}
|
||||
name={["credentials", "id_jag_resource_token_endpoint"]}
|
||||
rules={[{ required: !isEditing, message: "The resource token endpoint is required for ID-JAG" }]}
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("The resource token endpoint is required for ID-JAG")}
|
||||
>
|
||||
<Input placeholder="https://upstream.example.com/oauth2/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="https://upstream.example.com/oauth2/token"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="Client ID" tooltip="OAuth2 client ID LiteLLM authenticates as on both legs." />}
|
||||
name={["credentials", "client_id"]}
|
||||
rules={[{ required: !isEditing, message: "Client ID is required for ID-JAG" }]}
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("Client ID is required for ID-JAG")}
|
||||
>
|
||||
<Input.Password placeholder={`Enter OAuth client ID${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input.Password
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter OAuth client ID${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Secret"
|
||||
|
|
@ -61,36 +90,47 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
/>
|
||||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
dependencies={[["credentials", "client_private_key"]]}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator: (_, value) => {
|
||||
if (isEditing || value || getFieldValue(["credentials", "client_private_key"])) {
|
||||
return Promise.resolve();
|
||||
rules={
|
||||
isEditing
|
||||
? undefined
|
||||
: {
|
||||
deps: ["credentials.client_private_key"],
|
||||
validate: {
|
||||
secretOrPrivateKey: requiredUnlessSiblingSet(
|
||||
PRIVATE_KEY_PATH,
|
||||
"Provide either a client secret or a client private key",
|
||||
),
|
||||
},
|
||||
}
|
||||
return Promise.reject(new Error("Provide either a client secret or a client private key"));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
}
|
||||
>
|
||||
<Input.Password placeholder={`Enter OAuth client secret${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input.Password
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter OAuth client secret${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Private Key (PEM)"
|
||||
tooltip="PEM private key signing the RFC 7523 private_key_jwt client assertion. Okta Cross App Access normally requires this. When set it takes precedence over the client secret."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_private_key"]}
|
||||
name={PRIVATE_KEY_PATH}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder={`-----BEGIN PRIVATE KEY-----${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input.TextArea
|
||||
{...textControl(control)}
|
||||
rows={3}
|
||||
placeholder={`-----BEGIN PRIVATE KEY-----${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Private Key ID (optional)"
|
||||
|
|
@ -99,9 +139,9 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
}
|
||||
name={["credentials", "client_private_key_id"]}
|
||||
>
|
||||
<Input placeholder="my-signing-key-1" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => <Input {...textControl(control)} placeholder="my-signing-key-1" className={fieldClassName} />}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Assertion Signing Algorithm (optional)"
|
||||
|
|
@ -110,9 +150,9 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
}
|
||||
name={["credentials", "client_assertion_signing_alg"]}
|
||||
>
|
||||
<Input placeholder="RS256" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => <Input {...textControl(control)} placeholder="RS256" className={fieldClassName} />}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Audience (optional)"
|
||||
|
|
@ -121,9 +161,11 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
}
|
||||
name="audience"
|
||||
>
|
||||
<Input placeholder="https://upstream.example.com" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input {...textControl(control)} placeholder="https://upstream.example.com" className={fieldClassName} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Resource Indicator (optional)"
|
||||
|
|
@ -132,9 +174,11 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
}
|
||||
name={["credentials", "id_jag_resource"]}
|
||||
>
|
||||
<Input placeholder="https://upstream.example.com/mcp" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input {...textControl(control)} placeholder="https://upstream.example.com/mcp" className={fieldClassName} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Subject Token Type (optional)"
|
||||
|
|
@ -143,14 +187,29 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
}
|
||||
name="subject_token_type"
|
||||
>
|
||||
<Input placeholder="urn:ietf:params:oauth:token-type:id_token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="urn:ietf:params:oauth:token-type:id_token"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="Scopes (optional)" tooltip="Scopes requested on leg 1 of the exchange." />}
|
||||
name={["credentials", "scopes"]}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[","]} placeholder="Add scopes" className="rounded-lg" size="large" />
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...selectControl(control)}
|
||||
mode="tags"
|
||||
tokenSeparators={[","]}
|
||||
placeholder="Add scopes"
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Form } from "antd";
|
||||
|
||||
import MCPPermissionManagement from "./MCPPermissionManagement";
|
||||
import { renderInMcpForm } from "./McpFormTestHarness";
|
||||
|
||||
const defaultProps = {
|
||||
availableAccessGroups: [],
|
||||
|
|
@ -12,6 +12,7 @@ const defaultProps = {
|
|||
searchValue: "",
|
||||
setSearchValue: () => {},
|
||||
getAccessGroupOptions: () => [],
|
||||
mountedAuthType: undefined,
|
||||
};
|
||||
|
||||
describe("MCPPermissionManagement", () => {
|
||||
|
|
@ -24,22 +25,8 @@ describe("MCPPermissionManagement", () => {
|
|||
return user;
|
||||
};
|
||||
|
||||
const renderWithForm = (props = {}) => {
|
||||
const Wrapper: React.FC = ({ children }) => {
|
||||
const [form] = Form.useForm();
|
||||
return (
|
||||
<Form form={form} initialValues={{ allow_all_keys: false }}>
|
||||
{children}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
return render(
|
||||
<Wrapper>
|
||||
<MCPPermissionManagement {...defaultProps} {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
};
|
||||
const renderWithForm = (props = {}) =>
|
||||
renderInMcpForm(<MCPPermissionManagement {...defaultProps} {...props} />, { allow_all_keys: false });
|
||||
|
||||
it("should default allow_all_keys switch to unchecked for new servers", async () => {
|
||||
renderWithForm();
|
||||
|
|
@ -51,27 +38,15 @@ describe("MCPPermissionManagement", () => {
|
|||
expect(toggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
const renderWithInitialValues = (initialValues: Record<string, unknown>, props = {}) => {
|
||||
const Wrapper: React.FC = ({ children }) => {
|
||||
const [form] = Form.useForm();
|
||||
return (
|
||||
<Form form={form} initialValues={initialValues}>
|
||||
{/* In the real app auth_type is registered by the parent form; the
|
||||
component only watches it. Register a hidden field here so
|
||||
Form.useWatch("auth_type") resolves the initial value. */}
|
||||
<Form.Item name="auth_type" hidden>
|
||||
<input />
|
||||
</Form.Item>
|
||||
{children}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
return render(
|
||||
<Wrapper>
|
||||
<MCPPermissionManagement {...defaultProps} {...props} />
|
||||
</Wrapper>,
|
||||
const renderWithInitialValues = (initialValues: Record<string, unknown>, props = {}) =>
|
||||
renderInMcpForm(
|
||||
<MCPPermissionManagement
|
||||
{...defaultProps}
|
||||
mountedAuthType={initialValues.auth_type as string | undefined}
|
||||
{...props}
|
||||
/>,
|
||||
initialValues,
|
||||
);
|
||||
};
|
||||
|
||||
it("shows only the oauth2 PKCE-delegation toggle for oauth2 servers", async () => {
|
||||
renderWithInitialValues({ allow_all_keys: false, auth_type: "oauth2" });
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { Alert, Form, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd";
|
||||
import { Alert, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd";
|
||||
import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
|
||||
import { MCPServer, AUTH_TYPE } from "@/components/mcp_tools/types";
|
||||
import {
|
||||
MountedFormField,
|
||||
useMountedName,
|
||||
type MountedFormValues,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
import { antdRequired } from "@/components/common_components/antdFormRules";
|
||||
import { Field, FieldLabel } from "@/components/shared/form/field";
|
||||
import { invertedSwitchControl, selectControl, switchControl, textControl } from "./mcpFieldRules";
|
||||
import { listControl } from "./mcpFormStore";
|
||||
const { Panel } = Collapse;
|
||||
|
||||
interface MCPPermissionManagementProps {
|
||||
|
|
@ -13,20 +23,79 @@ interface MCPPermissionManagementProps {
|
|||
value: string;
|
||||
label: React.ReactNode;
|
||||
}>;
|
||||
/**
|
||||
* The auth type as seen through the gate that mounts the auth_type field.
|
||||
* Callers pass undefined whenever that field is unmounted, because both
|
||||
* toggles below are mounted from this value and the payload only carries
|
||||
* what is mounted.
|
||||
*/
|
||||
mountedAuthType: string | null | undefined;
|
||||
}
|
||||
|
||||
const StaticHeadersFieldArray: React.FC = () => {
|
||||
const { control } = useFormContext<MountedFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control: listControl(control), name: "static_headers" });
|
||||
useMountedName("static_headers");
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{fields.map((item, index) => (
|
||||
<Space key={item.id} className="flex w-full" align="baseline" size="middle">
|
||||
<MountedFormField
|
||||
name={["static_headers", String(index), "header"]}
|
||||
className="flex-1"
|
||||
rules={{ validate: { required: antdRequired("Header name is required") } }}
|
||||
>
|
||||
{(headerControl) => (
|
||||
<Input
|
||||
{...textControl(headerControl)}
|
||||
size="large"
|
||||
allowClear
|
||||
className="rounded-lg"
|
||||
placeholder="Header name (e.g., X-API-Key)"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
name={["static_headers", String(index), "value"]}
|
||||
className="flex-1"
|
||||
rules={{ validate: { required: antdRequired("Header value is required") } }}
|
||||
>
|
||||
{(valueControl) => (
|
||||
<Input
|
||||
{...textControl(valueControl)}
|
||||
size="large"
|
||||
allowClear
|
||||
className="rounded-lg"
|
||||
placeholder="Header value"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MinusCircleOutlined
|
||||
onClick={() => remove(index)}
|
||||
className="text-gray-500 hover:text-red-500 cursor-pointer"
|
||||
/>
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => append({})} icon={<PlusOutlined />} block>
|
||||
Add Static Header
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
||||
availableAccessGroups,
|
||||
mcpServer,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
getAccessGroupOptions,
|
||||
mountedAuthType,
|
||||
}) => {
|
||||
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 { setValue } = useFormContext<MountedFormValues>();
|
||||
const isOAuth2 = mountedAuthType === AUTH_TYPE.OAUTH2;
|
||||
const isNoneAuth = mountedAuthType === AUTH_TYPE.NONE || mountedAuthType == null;
|
||||
const watchedExtraHeaders = useWatch({ name: "extra_headers" });
|
||||
const hasAuthorizationHeader =
|
||||
Array.isArray(watchedExtraHeaders) &&
|
||||
watchedExtraHeaders.some((h) => typeof h === "string" && h.toLowerCase() === "authorization");
|
||||
|
|
@ -39,8 +108,8 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
// Kept as separate flags so neither silently implies the other and existing
|
||||
// oauth2 servers can't regress into pass-through behavior.
|
||||
const canEnableOAuthPassthrough = isNoneAuth && hasAuthorizationHeader;
|
||||
const watchedDelegateAuth = Form.useWatch("delegate_auth_to_upstream", form);
|
||||
const watchedPublicInternet = Form.useWatch("available_on_public_internet", form);
|
||||
const watchedDelegateAuth = useWatch({ name: "delegate_auth_to_upstream" });
|
||||
const watchedPublicInternet = useWatch({ name: "available_on_public_internet" });
|
||||
const showInternalDelegatePkceWarning = isOAuth2 && watchedDelegateAuth === true && watchedPublicInternet === false;
|
||||
|
||||
// Set initial values when mcpServer changes
|
||||
|
|
@ -51,10 +120,10 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
header,
|
||||
value: value != null ? String(value) : "",
|
||||
}));
|
||||
form.setFieldValue("static_headers", staticHeaders);
|
||||
setValue("static_headers", staticHeaders);
|
||||
}
|
||||
if (Array.isArray(mcpServer.env_vars) && mcpServer.env_vars.length > 0) {
|
||||
form.setFieldValue(
|
||||
setValue(
|
||||
"env_vars",
|
||||
mcpServer.env_vars.map((entry) => ({
|
||||
name: entry.name,
|
||||
|
|
@ -65,41 +134,41 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
);
|
||||
}
|
||||
if (typeof mcpServer.allow_all_keys === "boolean") {
|
||||
form.setFieldValue("allow_all_keys", mcpServer.allow_all_keys);
|
||||
setValue("allow_all_keys", mcpServer.allow_all_keys);
|
||||
}
|
||||
if (typeof mcpServer.available_on_public_internet === "boolean") {
|
||||
form.setFieldValue("available_on_public_internet", mcpServer.available_on_public_internet);
|
||||
setValue("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);
|
||||
setValue("delegate_auth_to_upstream", mcpServer.delegate_auth_to_upstream);
|
||||
}
|
||||
if (typeof mcpServer.oauth_passthrough === "boolean") {
|
||||
form.setFieldValue("oauth_passthrough", mcpServer.oauth_passthrough);
|
||||
setValue("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);
|
||||
setValue("allow_all_keys", false);
|
||||
setValue("available_on_public_internet", true);
|
||||
setValue("delegate_auth_to_upstream", false);
|
||||
setValue("oauth_passthrough", false);
|
||||
}
|
||||
}, [mcpServer, form]);
|
||||
}, [mcpServer, setValue]);
|
||||
|
||||
// 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);
|
||||
setValue("delegate_auth_to_upstream", false);
|
||||
}
|
||||
}, [isOAuth2, form]);
|
||||
}, [isOAuth2, setValue]);
|
||||
|
||||
// oauth_passthrough is only honored for auth_type=none servers that forward
|
||||
// Authorization upstream. Force it back to false otherwise.
|
||||
useEffect(() => {
|
||||
if (!canEnableOAuthPassthrough) {
|
||||
form.setFieldValue("oauth_passthrough", false);
|
||||
setValue("oauth_passthrough", false);
|
||||
}
|
||||
}, [canEnableOAuthPassthrough, form]);
|
||||
}, [canEnableOAuthPassthrough, setValue]);
|
||||
|
||||
return (
|
||||
<Collapse className="bg-gray-50 border border-gray-200 rounded-lg" expandIconPosition="end" ghost={false}>
|
||||
|
|
@ -130,14 +199,9 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
Enable if this server should be "public" to all keys.
|
||||
</p>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="allow_all_keys"
|
||||
valuePropName="checked"
|
||||
initialValue={mcpServer?.allow_all_keys ?? false}
|
||||
className="mb-0"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<MountedFormField name="allow_all_keys" defaultValue={mcpServer?.allow_all_keys ?? false} className="mb-0">
|
||||
{(control) => <Switch {...switchControl(control)} />}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
|
|
@ -152,16 +216,9 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
Turn on to restrict access to callers within your internal network only.
|
||||
</p>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="available_on_public_internet"
|
||||
valuePropName="checked"
|
||||
getValueProps={(value) => ({ checked: !value })}
|
||||
getValueFromEvent={(checked: boolean) => !checked}
|
||||
initialValue={true}
|
||||
className="mb-0"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<MountedFormField name="available_on_public_internet" defaultValue={true} className="mb-0">
|
||||
{(control) => <Switch {...invertedSwitchControl(control)} />}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
|
||||
{isOAuth2 && (
|
||||
|
|
@ -177,14 +234,13 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server.
|
||||
</p>
|
||||
</div>
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
name="delegate_auth_to_upstream"
|
||||
valuePropName="checked"
|
||||
initialValue={mcpServer?.delegate_auth_to_upstream ?? false}
|
||||
defaultValue={mcpServer?.delegate_auth_to_upstream ?? false}
|
||||
className="mb-0"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
{(control) => <Switch {...switchControl(control)} />}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -202,14 +258,13 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
upstream MCP server.
|
||||
</p>
|
||||
</div>
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
name="oauth_passthrough"
|
||||
valuePropName="checked"
|
||||
initialValue={mcpServer?.oauth_passthrough ?? false}
|
||||
defaultValue={mcpServer?.oauth_passthrough ?? false}
|
||||
className="mb-0"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
{(control) => <Switch {...switchControl(control)} />}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -223,7 +278,7 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
MCP Access Groups
|
||||
|
|
@ -235,21 +290,24 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
name="mcp_access_groups"
|
||||
className="mb-4"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
showSearch
|
||||
placeholder="Select existing groups or type to create new ones"
|
||||
optionFilterProp="value"
|
||||
filterOption={(input, option) => (option?.value ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
onSearch={(value) => setSearchValue(value)}
|
||||
tokenSeparators={[","]}
|
||||
options={getAccessGroupOptions()}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...selectControl(control)}
|
||||
mode="tags"
|
||||
showSearch
|
||||
placeholder="Select existing groups or type to create new ones"
|
||||
optionFilterProp="value"
|
||||
filterOption={(input, option) => (option?.value ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
onSearch={(value) => setSearchValue(value)}
|
||||
tokenSeparators={[","]}
|
||||
options={getAccessGroupOptions()}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Extra Headers
|
||||
|
|
@ -265,70 +323,34 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
}
|
||||
name="extra_headers"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={
|
||||
mcpServer?.extra_headers && mcpServer.extra_headers.length > 0
|
||||
? `Currently: ${mcpServer.extra_headers.join(", ")}`
|
||||
: "Enter header names (e.g., Authorization, X-Custom-Header)"
|
||||
}
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
tokenSeparators={[","]}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...selectControl(control)}
|
||||
mode="tags"
|
||||
placeholder={
|
||||
mcpServer?.extra_headers && mcpServer.extra_headers.length > 0
|
||||
? `Currently: ${mcpServer.extra_headers.join(", ")}`
|
||||
: "Enter header names (e.g., Authorization, X-Custom-Header)"
|
||||
}
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
tokenSeparators={[","]}
|
||||
allowClear
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Field>
|
||||
<FieldLabel>
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Static Headers
|
||||
<Tooltip title="Send these key-value headers with every request to this MCP server.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
required={false}
|
||||
>
|
||||
<Form.List name="static_headers">
|
||||
{(fields, { add, remove }) => (
|
||||
<div className="space-y-3">
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Space key={key} className="flex w-full" align="baseline" size="middle">
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, "header"]}
|
||||
className="flex-1"
|
||||
rules={[{ required: true, message: "Header name is required" }]}
|
||||
>
|
||||
<Input
|
||||
size="large"
|
||||
allowClear
|
||||
className="rounded-lg"
|
||||
placeholder="Header name (e.g., X-API-Key)"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, "value"]}
|
||||
className="flex-1"
|
||||
rules={[{ required: true, message: "Header value is required" }]}
|
||||
>
|
||||
<Input size="large" allowClear className="rounded-lg" placeholder="Header value" />
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined
|
||||
onClick={() => remove(name)}
|
||||
className="text-gray-500 hover:text-red-500 cursor-pointer"
|
||||
/>
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} icon={<PlusOutlined />} block>
|
||||
Add Static Header
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
</FieldLabel>
|
||||
<StaticHeadersFieldArray />
|
||||
</Field>
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import * as React from "react";
|
||||
import { render, type RenderResult } from "@testing-library/react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
MountedFormProvider,
|
||||
projectMountedValues,
|
||||
useMountRegistry,
|
||||
type MountedFormValues,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
|
||||
export const McpFormHarness: React.FC<{
|
||||
defaultValues?: MountedFormValues;
|
||||
onFinish?: (values: MountedFormValues) => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ defaultValues, onFinish, children }) => {
|
||||
const form = useForm<MountedFormValues>({ mode: "onChange", defaultValues });
|
||||
const registry = useMountRegistry();
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onFinish?.(projectMountedValues(registry, form.getValues));
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MountedFormProvider>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const renderInMcpForm = (ui: React.ReactNode, defaultValues: MountedFormValues = {}): RenderResult =>
|
||||
render(<McpFormHarness defaultValues={defaultValues}>{ui}</McpFormHarness>);
|
||||
|
|
@ -1,24 +1,8 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||
import { Form } from "antd";
|
||||
import OAuthFormFields from "./OAuthFormFields";
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Minimal Ant Form wrapper so Form.Item registers correctly. */
|
||||
const WithForm: React.FC<{ children: React.ReactNode; onFinish?: (values: any) => void }> = ({
|
||||
children,
|
||||
onFinish,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
return (
|
||||
<Form form={form} onFinish={onFinish}>
|
||||
{children}
|
||||
<button type="submit">Submit</button>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
import { McpFormHarness as WithForm } from "./McpFormTestHarness";
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import React from "react";
|
||||
import { Form, Input as AntdInput, InputNumber, Select, Tooltip } from "antd";
|
||||
import { Input as AntdInput, InputNumber, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { OAUTH_FLOW } from "@/components/mcp_tools/types";
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { antdRequired } from "@/components/common_components/antdFormRules";
|
||||
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
|
||||
import { numberControl, parsesAsJson, selectControl, textControl } from "./mcpFieldRules";
|
||||
|
||||
interface OAuthFlowStatus {
|
||||
startOAuthFlow: () => void;
|
||||
|
|
@ -41,12 +44,14 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
|
|||
);
|
||||
|
||||
const UpstreamResourceField: React.FC = () => (
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="Resource Indicator (optional)" tooltip={UPSTREAM_RESOURCE_TOOLTIP} />}
|
||||
name={["credentials", "upstream_resource"]}
|
||||
>
|
||||
<Input placeholder="auto, or https://mcp.example.com/mcp" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<Input {...textControl(control)} placeholder="auto, or https://mcp.example.com/mcp" className={fieldClassName} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
);
|
||||
|
||||
const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
||||
|
|
@ -57,11 +62,12 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
docsUrl,
|
||||
}) => {
|
||||
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
|
||||
const requiredWhenCreating = (message: string) => (isEditing ? [] : [{ required: true, message }]);
|
||||
const requiredWhenCreating = (message: string) =>
|
||||
isEditing ? undefined : { validate: { required: antdRequired(message) } };
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="OAuth Flow Type"
|
||||
|
|
@ -69,54 +75,74 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
/>
|
||||
}
|
||||
name="oauth_flow_type"
|
||||
{...(initialFlowType ? { initialValue: initialFlowType } : {})}
|
||||
{...(initialFlowType ? { defaultValue: initialFlowType } : {})}
|
||||
>
|
||||
<Select placeholder="Select OAuth flow" className="rounded-lg" size="large">
|
||||
<Select.Option value={OAUTH_FLOW.M2M}>
|
||||
<div>
|
||||
<span className="font-medium">Machine-to-Machine (M2M)</span>
|
||||
<span className="text-gray-400 text-xs ml-2">server-to-server, no user interaction</span>
|
||||
</div>
|
||||
</Select.Option>
|
||||
<Select.Option value={OAUTH_FLOW.INTERACTIVE}>
|
||||
<div>
|
||||
<span className="font-medium">Interactive (PKCE)</span>
|
||||
<span className="text-gray-400 text-xs ml-2">browser-based user authorization</span>
|
||||
</div>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<Select {...selectControl(control)} placeholder="Select OAuth flow" className="rounded-lg" size="large">
|
||||
<Select.Option value={OAUTH_FLOW.M2M}>
|
||||
<div>
|
||||
<span className="font-medium">Machine-to-Machine (M2M)</span>
|
||||
<span className="text-gray-400 text-xs ml-2">server-to-server, no user interaction</span>
|
||||
</div>
|
||||
</Select.Option>
|
||||
<Select.Option value={OAUTH_FLOW.INTERACTIVE}>
|
||||
<div>
|
||||
<span className="font-medium">Interactive (PKCE)</span>
|
||||
<span className="text-gray-400 text-xs ml-2">browser-based user authorization</span>
|
||||
</div>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
{isM2M ? (
|
||||
<>
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="Client ID" tooltip="OAuth2 client ID for the client_credentials grant." />}
|
||||
name={["credentials", "client_id"]}
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("Client ID is required for M2M OAuth")}
|
||||
>
|
||||
<AntdInput.Password placeholder={`Enter OAuth client ID${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<AntdInput.Password
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter OAuth client ID${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel label="Client Secret" tooltip="OAuth2 client secret for the client_credentials grant." />
|
||||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("Client Secret is required for M2M OAuth")}
|
||||
>
|
||||
<AntdInput.Password
|
||||
placeholder={`Enter OAuth client secret${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<AntdInput.Password
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter OAuth client secret${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="Token URL" tooltip="Token endpoint URL for the client_credentials grant." />}
|
||||
name="token_url"
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("Token URL is required for M2M OAuth")}
|
||||
>
|
||||
<Input placeholder="https://auth.example.com/oauth/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="https://auth.example.com/oauth/token"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<TokenEndpointAuthMethodField isEditing={isEditing} />
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Scopes (optional)"
|
||||
|
|
@ -125,13 +151,22 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
}
|
||||
name={["credentials", "scopes"]}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[","]} placeholder="Add scopes" className="rounded-lg" size="large" />
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...selectControl(control)}
|
||||
mode="tags"
|
||||
tokenSeparators={[","]}
|
||||
placeholder="Add scopes"
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<UpstreamResourceField />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="flex items-center justify-between w-full">
|
||||
<FieldLabel
|
||||
|
|
@ -153,9 +188,15 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
}
|
||||
name={["credentials", "client_id"]}
|
||||
>
|
||||
<AntdInput.Password placeholder={`Enter client ID${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<AntdInput.Password
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter client ID${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Secret (optional)"
|
||||
|
|
@ -164,9 +205,15 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
>
|
||||
<AntdInput.Password placeholder={`Enter client secret${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<AntdInput.Password
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter client secret${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Scopes (optional)"
|
||||
|
|
@ -175,10 +222,19 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
}
|
||||
name={["credentials", "scopes"]}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[","]} placeholder="Add scopes" className="rounded-lg" size="large" />
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...selectControl(control)}
|
||||
mode="tags"
|
||||
tokenSeparators={[","]}
|
||||
placeholder="Add scopes"
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<UpstreamResourceField />
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Issuer (optional)"
|
||||
|
|
@ -187,9 +243,11 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
}
|
||||
name="issuer"
|
||||
>
|
||||
<Input placeholder="https://issuer.example.com" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input {...textControl(control)} placeholder="https://issuer.example.com" className={fieldClassName} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Authorization URL (optional)"
|
||||
|
|
@ -198,16 +256,28 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
}
|
||||
name="authorization_url"
|
||||
>
|
||||
<Input placeholder="https://example.com/oauth/authorize" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="https://example.com/oauth/authorize"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="Token URL (optional)" tooltip="Optional override for the token endpoint." />}
|
||||
name="token_url"
|
||||
>
|
||||
<Input placeholder="https://example.com/oauth/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="https://example.com/oauth/token"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<TokenEndpointAuthMethodField isEditing={isEditing} />
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Registration URL (optional)"
|
||||
|
|
@ -216,9 +286,15 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
}
|
||||
name="registration_url"
|
||||
>
|
||||
<Input placeholder="https://example.com/oauth/register" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="https://example.com/oauth/register"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Token Validation Rules (optional)"
|
||||
|
|
@ -226,27 +302,18 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
/>
|
||||
}
|
||||
name="token_validation_json"
|
||||
rules={[
|
||||
{
|
||||
validator: (_: any, value: string) => {
|
||||
if (!value || value.trim() === "") return Promise.resolve();
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return Promise.resolve();
|
||||
} catch {
|
||||
return Promise.reject(new Error("Must be valid JSON"));
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
rules={{ validate: { json: parsesAsJson("Must be valid JSON") } }}
|
||||
>
|
||||
<AntdInput.TextArea
|
||||
placeholder={'{\n "organization": "my-org",\n "team.id": "123"\n}'}
|
||||
rows={4}
|
||||
className="font-mono text-sm rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<AntdInput.TextArea
|
||||
{...textControl(control)}
|
||||
placeholder={'{\n "organization": "my-org",\n "team.id": "123"\n}'}
|
||||
rows={4}
|
||||
className="font-mono text-sm rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Token Storage TTL (seconds, optional)"
|
||||
|
|
@ -255,8 +322,16 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
}
|
||||
name="token_storage_ttl_seconds"
|
||||
>
|
||||
<InputNumber min={1} placeholder="e.g. 3600" className="w-full rounded-lg" style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<InputNumber
|
||||
{...numberControl(control)}
|
||||
min={1}
|
||||
placeholder="e.g. 3600"
|
||||
className="w-full rounded-lg"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
{oauthFlow && (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 p-4 space-y-2">
|
||||
<p className="text-sm text-gray-600">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import React, { useState } from "react";
|
||||
import { Form, Input, Tooltip } from "antd";
|
||||
import { Input, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { FormInstance } from "antd/es/form";
|
||||
import { AUTH_TYPE, OAUTH_FLOW } from "@/components/mcp_tools/types";
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { antdRequired } from "@/components/common_components/antdFormRules";
|
||||
import OpenAPIQuickPicker, { OpenAPIRegistryEntry, OpenAPIKeyTool } from "./OpenAPIQuickPicker";
|
||||
import { McpForm, resetFields, setFieldsValue } from "./mcpFormStore";
|
||||
import { textControl } from "./mcpFieldRules";
|
||||
|
||||
interface OpenAPIFormSectionProps {
|
||||
form: FormInstance;
|
||||
form: McpForm;
|
||||
accessToken: string | null;
|
||||
/** Called when a preset is selected so the parent can sync its formValues state. */
|
||||
onValuesChange: (updates: Record<string, any>) => void;
|
||||
|
|
@ -47,13 +50,11 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
|
|||
updates.oauth_flow_type = OAUTH_FLOW.INTERACTIVE;
|
||||
updates.authorization_url = entry.oauth.authorization_url;
|
||||
updates.token_url = entry.oauth.token_url;
|
||||
form.setFieldsValue(updates);
|
||||
setFieldsValue(form, updates);
|
||||
onOAuthDocsUrlChange?.(entry.oauth.docs_url ?? null);
|
||||
} else {
|
||||
// resetFields is required to visually clear Ant Design form fields —
|
||||
// setFieldsValue with undefined silently skips undefined keys.
|
||||
form.resetFields(["auth_type", "authorization_url", "token_url"]);
|
||||
form.setFieldsValue(updates);
|
||||
resetFields(form, ["auth_type", "authorization_url", "token_url"]);
|
||||
setFieldsValue(form, updates);
|
||||
onOAuthDocsUrlChange?.(null);
|
||||
}
|
||||
onValuesChange(updates);
|
||||
|
|
@ -63,7 +64,7 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
|
|||
<>
|
||||
<OpenAPIQuickPicker accessToken={accessToken} selectedName={selectedPreset} onSelect={handlePresetSelect} />
|
||||
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
OpenAPI Spec URL
|
||||
|
|
@ -73,20 +74,25 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
|
|||
</span>
|
||||
}
|
||||
name="spec_path"
|
||||
rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]}
|
||||
required
|
||||
rules={{ validate: { required: antdRequired("Please enter an OpenAPI spec URL") } }}
|
||||
>
|
||||
<Input
|
||||
placeholder="https://petstore3.swagger.io/api/v3/openapi.json"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
onChange={() => {
|
||||
// Clear the preset selection when the user manually edits the spec URL
|
||||
// so stale suggested tools from a previous preset don't persist.
|
||||
setSelectedPreset(null);
|
||||
onKeyToolsChange?.([]);
|
||||
onOAuthDocsUrlChange?.(null);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="https://petstore3.swagger.io/api/v3/openapi.json"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
onChange={(event) => {
|
||||
control.onChange(event);
|
||||
// Clear the preset selection when the user manually edits the spec URL
|
||||
// so stale suggested tools from a previous preset don't persist.
|
||||
setSelectedPreset(null);
|
||||
onKeyToolsChange?.([]);
|
||||
onOAuthDocsUrlChange?.(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,91 +1,101 @@
|
|||
import React from "react";
|
||||
import { Form, Input, Select, Switch, Tooltip } from "antd";
|
||||
import { Input, Select, Switch, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useWatch } from "react-hook-form";
|
||||
|
||||
const OpenApiByokFields: React.FC = () => (
|
||||
<>
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
BYOK (Bring Your Own Key)
|
||||
<Tooltip title="When enabled, each user provides their own API key for this service. Keys are stored per-user and never shared.">
|
||||
<InfoCircleOutlined className="text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="is_byok"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { selectControl, switchControl, textControl } from "./mcpFieldRules";
|
||||
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.is_byok !== cur.is_byok || prev.auth_type !== cur.auth_type}>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue("is_byok") ? (
|
||||
<>
|
||||
{/* Auth format hint */}
|
||||
{getFieldValue("auth_type") && getFieldValue("auth_type") !== "none" && (
|
||||
<div className="mb-4 p-3 bg-blue-50 rounded-lg text-sm text-blue-700 flex items-start gap-2">
|
||||
<InfoCircleOutlined className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
User keys will be sent as:{" "}
|
||||
<code className="font-mono bg-blue-100 px-1 rounded-sm">
|
||||
{getFieldValue("auth_type") === "bearer_token" && "Authorization: Bearer {key}"}
|
||||
{getFieldValue("auth_type") === "token" && "Authorization: token {key}"}
|
||||
{getFieldValue("auth_type") === "api_key" && "x-api-key: {key}"}
|
||||
{getFieldValue("auth_type") === "basic" && "Authorization: Basic {key}"}
|
||||
{getFieldValue("auth_type") === "authorization" && "Authorization: {key}"}
|
||||
</code>
|
||||
{!getFieldValue("auth_type") && "Set Authentication Type below to specify the format."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!getFieldValue("auth_type") && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 rounded-lg text-sm text-yellow-700 flex items-start gap-2">
|
||||
<InfoCircleOutlined className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Set the <strong>Authentication Type</strong> below to specify how user keys are sent (e.g., Bearer
|
||||
Token, API Key header).
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Access Description
|
||||
<Tooltip title="List of permissions shown to users in the connection modal (e.g. 'Create and manage Jira issues')">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="byok_description"
|
||||
>
|
||||
const AUTH_HEADER_FORMATS: Readonly<Record<string, string>> = {
|
||||
bearer_token: "Authorization: Bearer {key}",
|
||||
token: "Authorization: token {key}",
|
||||
api_key: "x-api-key: {key}",
|
||||
basic: "Authorization: Basic {key}",
|
||||
authorization: "Authorization: {key}",
|
||||
};
|
||||
|
||||
const OpenApiByokFields: React.FC = () => {
|
||||
const isByok = Boolean(useWatch({ name: "is_byok" }));
|
||||
const authType = useWatch({ name: "auth_type" }) as string | undefined;
|
||||
const hasAuthType = Boolean(authType) && authType !== "none";
|
||||
|
||||
return (
|
||||
<>
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
BYOK (Bring Your Own Key)
|
||||
<Tooltip title="When enabled, each user provides their own API key for this service. Keys are stored per-user and never shared.">
|
||||
<InfoCircleOutlined className="text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="is_byok"
|
||||
>
|
||||
{(control) => <Switch {...switchControl(control)} />}
|
||||
</MountedFormField>
|
||||
|
||||
{isByok && (
|
||||
<>
|
||||
{hasAuthType && (
|
||||
<div className="mb-4 p-3 bg-blue-50 rounded-lg text-sm text-blue-700 flex items-start gap-2">
|
||||
<InfoCircleOutlined className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
User keys will be sent as:{" "}
|
||||
<code className="font-mono bg-blue-100 px-1 rounded-sm">
|
||||
{authType === undefined ? "" : AUTH_HEADER_FORMATS[authType]}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!authType && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 rounded-lg text-sm text-yellow-700 flex items-start gap-2">
|
||||
<InfoCircleOutlined className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Set the <strong>Authentication Type</strong> below to specify how user keys are sent (e.g., Bearer
|
||||
Token, API Key header).
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Access Description
|
||||
<Tooltip title="List of permissions shown to users in the connection modal (e.g. 'Create and manage Jira issues')">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="byok_description"
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...selectControl(control)}
|
||||
mode="tags"
|
||||
placeholder="Add access description items (press Enter after each)"
|
||||
className="w-full"
|
||||
tokenSeparators={[","]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
API Key Help URL
|
||||
<Tooltip title="Optional link shown to users to help them find their API key">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="byok_api_key_help_url"
|
||||
>
|
||||
<Input placeholder="https://docs.example.com/api-keys" />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
API Key Help URL
|
||||
<Tooltip title="Optional link shown to users to help them find their API key">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="byok_api_key_help_url"
|
||||
>
|
||||
{(control) => <Input {...textControl(control)} placeholder="https://docs.example.com/api-keys" />}
|
||||
</MountedFormField>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenApiByokFields;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Form } from "antd";
|
||||
import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
|
||||
import { McpFormHarness } from "./McpFormTestHarness";
|
||||
|
||||
const WithForm: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [form] = Form.useForm();
|
||||
return <Form form={form}>{children}</Form>;
|
||||
};
|
||||
const WithForm = McpFormHarness;
|
||||
|
||||
const noopFlow = { startOAuthFlow: () => {}, status: "idle", error: null, tokenResponse: null };
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React from "react";
|
||||
import { Button, Checkbox, Form, Input } from "antd";
|
||||
import { Button, Checkbox, Input } from "antd";
|
||||
import DcrBridgeToggle from "./DcrBridgeToggle";
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { textControl } from "./mcpFieldRules";
|
||||
import { credentialAuthClass, isClientForwardedTokenMode } from "@/components/mcp_tools/types";
|
||||
|
||||
interface PassthroughOAuthFlow {
|
||||
|
|
@ -81,27 +83,33 @@ export default function PassthroughAuthorizeSection({
|
|||
and may not be valid. Update the client ID, or clear it to use dynamic client registration.
|
||||
</p>
|
||||
)}
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-gray-700">OAuth Client ID (optional)</span>}
|
||||
name={["credentials", "client_id"]}
|
||||
extra={clientIdExtra}
|
||||
help={clientIdExtra}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={clientIdPlaceholder}
|
||||
disabled={removeStoredApp}
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input.Password
|
||||
{...textControl(control)}
|
||||
placeholder={clientIdPlaceholder}
|
||||
disabled={removeStoredApp}
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-gray-700">OAuth Client Secret (optional)</span>}
|
||||
name={["credentials", "client_secret"]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={clientSecretPlaceholder}
|
||||
disabled={removeStoredApp}
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<Input.Password
|
||||
{...textControl(control)}
|
||||
placeholder={clientSecretPlaceholder}
|
||||
disabled={removeStoredApp}
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<DcrBridgeToggle authType={authType} initialChecked={dcrBridgeInitialChecked} />
|
||||
{isEditing && onRemoveStoredAppChange && (
|
||||
<Checkbox checked={removeStoredApp} onChange={(e) => onRemoveStoredAppChange(e.target.checked)}>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import React from "react";
|
||||
import { Form, Input, Tooltip } from "antd";
|
||||
import { Input, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { antdRequired } from "@/components/common_components/antdFormRules";
|
||||
import { parsesAsJson, textControl } from "./mcpFieldRules";
|
||||
|
||||
interface StdioConfigurationProps {
|
||||
isVisible: boolean;
|
||||
/**
|
||||
|
|
@ -11,37 +15,7 @@ interface StdioConfigurationProps {
|
|||
required?: boolean;
|
||||
}
|
||||
|
||||
const StdioConfiguration: React.FC<StdioConfigurationProps> = ({ isVisible, required = true }) => {
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Stdio Configuration (JSON)
|
||||
<Tooltip title="Paste your stdio MCP server configuration in JSON format. You can use the full mcpServers structure from config.yaml or just the inner server configuration.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="stdio_config"
|
||||
rules={[
|
||||
...(required ? [{ required: true, message: "Please enter stdio configuration" }] : []),
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return Promise.resolve();
|
||||
} catch {
|
||||
return Promise.reject("Please enter valid JSON");
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={`{
|
||||
const PLACEHOLDER = `{
|
||||
"mcpServers": {
|
||||
"circleci-mcp-server": {
|
||||
"command": "npx",
|
||||
|
|
@ -52,11 +26,39 @@ const StdioConfiguration: React.FC<StdioConfigurationProps> = ({ isVisible, requ
|
|||
}
|
||||
}
|
||||
}
|
||||
}`}
|
||||
rows={12}
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm"
|
||||
/>
|
||||
</Form.Item>
|
||||
}`;
|
||||
|
||||
const StdioConfiguration: React.FC<StdioConfigurationProps> = ({ isVisible, required = true }) => {
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Stdio Configuration (JSON)
|
||||
<Tooltip title="Paste your stdio MCP server configuration in JSON format. You can use the full mcpServers structure from config.yaml or just the inner server configuration.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="stdio_config"
|
||||
required={required}
|
||||
rules={{
|
||||
validate: {
|
||||
...(required ? { required: antdRequired("Please enter stdio configuration") } : {}),
|
||||
json: parsesAsJson("Please enter valid JSON"),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<Input.TextArea
|
||||
{...textControl(control)}
|
||||
placeholder={PLACEHOLDER}
|
||||
rows={12}
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import React from "react";
|
||||
import { Form, Select, Tooltip } from "antd";
|
||||
import { Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { selectControl } from "./mcpFieldRules";
|
||||
|
||||
const TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS = [
|
||||
{ value: "client_secret_basic", label: "Client Secret Basic" },
|
||||
{ value: "client_secret_post", label: "Client Secret Post" },
|
||||
|
|
@ -12,7 +15,7 @@ interface TokenEndpointAuthMethodFieldProps {
|
|||
}
|
||||
|
||||
const TokenEndpointAuthMethodField: React.FC<TokenEndpointAuthMethodFieldProps> = ({ isEditing = false }) => (
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Token Endpoint Auth Method (optional)
|
||||
|
|
@ -23,16 +26,19 @@ const TokenEndpointAuthMethodField: React.FC<TokenEndpointAuthMethodFieldProps>
|
|||
}
|
||||
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>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...selectControl(control)}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
);
|
||||
|
||||
export default TokenEndpointAuthMethodField;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import React from "react";
|
||||
import { Form, Input, Select, Tooltip } from "antd";
|
||||
import { Input, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useWatch } from "react-hook-form";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { antdRequired } from "@/components/common_components/antdFormRules";
|
||||
import { selectControl, textControl } from "./mcpFieldRules";
|
||||
|
||||
interface TokenExchangeFormFieldsProps {
|
||||
isEditing?: boolean;
|
||||
|
|
@ -19,10 +24,13 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
|
|||
|
||||
const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEditing = false }) => {
|
||||
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
|
||||
const isEntraObo = useWatch({ name: "token_exchange_profile" }) === "entra_obo";
|
||||
const requiredWhenCreating = (message: string) =>
|
||||
isEditing ? undefined : { validate: { required: antdRequired(message) } };
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Profile"
|
||||
|
|
@ -30,18 +38,20 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
/>
|
||||
}
|
||||
name="token_exchange_profile"
|
||||
{...(isEditing ? {} : { initialValue: "rfc8693" })}
|
||||
{...(isEditing ? {} : { defaultValue: "rfc8693" })}
|
||||
>
|
||||
<Select className="rounded-lg" size="large">
|
||||
<Select.Option value="rfc8693">
|
||||
<span className="font-medium">RFC 8693 (standard)</span>
|
||||
</Select.Option>
|
||||
<Select.Option value="entra_obo">
|
||||
<span className="font-medium">Microsoft Entra OBO</span>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Select {...selectControl(control)} className="rounded-lg" size="large">
|
||||
<Select.Option value="rfc8693">
|
||||
<span className="font-medium">RFC 8693 (standard)</span>
|
||||
</Select.Option>
|
||||
<Select.Option value="entra_obo">
|
||||
<span className="font-medium">Microsoft Entra OBO</span>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Token Exchange Endpoint (optional)"
|
||||
|
|
@ -50,9 +60,15 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
}
|
||||
name="token_exchange_endpoint"
|
||||
>
|
||||
<Input placeholder="https://idp.example.com/oauth2/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="https://idp.example.com/oauth2/token"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client ID"
|
||||
|
|
@ -60,11 +76,18 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
/>
|
||||
}
|
||||
name={["credentials", "client_id"]}
|
||||
rules={[{ required: !isEditing, message: "Client ID is required for token exchange" }]}
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("Client ID is required for token exchange")}
|
||||
>
|
||||
<Input.Password placeholder={`Enter OAuth client ID${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(control) => (
|
||||
<Input.Password
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter OAuth client ID${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Secret"
|
||||
|
|
@ -72,76 +95,85 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
/>
|
||||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
rules={[{ required: !isEditing, message: "Client Secret is required for token exchange" }]}
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("Client Secret is required for token exchange")}
|
||||
>
|
||||
<Input.Password placeholder={`Enter OAuth client secret${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.token_exchange_profile !== cur.token_exchange_profile}>
|
||||
{({ getFieldValue }) => {
|
||||
const isEntraObo = getFieldValue("token_exchange_profile") === "entra_obo";
|
||||
return (
|
||||
<>
|
||||
{!isEntraObo && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Audience (optional)"
|
||||
tooltip="Target audience for the exchanged token (RFC 8693 audience). Identifies the upstream MCP server the token is for."
|
||||
/>
|
||||
}
|
||||
name="audience"
|
||||
>
|
||||
<Input placeholder="https://upstream.example.com" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Subject Token Type (optional)"
|
||||
tooltip="Type of the user's incoming token (RFC 8693 subject_token_type). Defaults to urn:ietf:params:oauth:token-type:access_token."
|
||||
/>
|
||||
}
|
||||
name="subject_token_type"
|
||||
>
|
||||
<Input placeholder="urn:ietf:params:oauth:token-type:access_token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label={isEntraObo ? "Scopes" : "Scopes (optional)"}
|
||||
tooltip={
|
||||
isEntraObo
|
||||
? "Microsoft Entra OBO carries the target resource in the scope, so at least one is required (e.g. api://<app-id>/.default)."
|
||||
: "Optional scopes to request during the token exchange."
|
||||
}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "scopes"]}
|
||||
rules={
|
||||
isEntraObo
|
||||
? [
|
||||
{
|
||||
required: true,
|
||||
message: "Microsoft Entra OBO requires a scope, e.g. api://<app-id>/.default",
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[","]}
|
||||
placeholder={isEntraObo ? "api://<app-id>/.default" : "Add scopes"}
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
{(control) => (
|
||||
<Input.Password
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter OAuth client secret${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
{!isEntraObo && (
|
||||
<>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Audience (optional)"
|
||||
tooltip="Target audience for the exchanged token (RFC 8693 audience). Identifies the upstream MCP server the token is for."
|
||||
/>
|
||||
}
|
||||
name="audience"
|
||||
>
|
||||
{(control) => (
|
||||
<Input {...textControl(control)} placeholder="https://upstream.example.com" className={fieldClassName} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Subject Token Type (optional)"
|
||||
tooltip="Type of the user's incoming token (RFC 8693 subject_token_type). Defaults to urn:ietf:params:oauth:token-type:access_token."
|
||||
/>
|
||||
}
|
||||
name="subject_token_type"
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="urn:ietf:params:oauth:token-type:access_token"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
)}
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label={isEntraObo ? "Scopes" : "Scopes (optional)"}
|
||||
tooltip={
|
||||
isEntraObo
|
||||
? "Microsoft Entra OBO carries the target resource in the scope, so at least one is required (e.g. api://<app-id>/.default)."
|
||||
: "Optional scopes to request during the token exchange."
|
||||
}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "scopes"]}
|
||||
required={isEntraObo}
|
||||
rules={
|
||||
isEntraObo
|
||||
? {
|
||||
validate: {
|
||||
required: antdRequired("Microsoft Entra OBO requires a scope, e.g. api://<app-id>/.default"),
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...selectControl(control)}
|
||||
mode="tags"
|
||||
tokenSeparators={[","]}
|
||||
placeholder={isEntraObo ? "api://<app-id>/.default" : "Add scopes"}
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ const legacyBuild = (values: Record<string, any>, ui: EditServerUiState) => {
|
|||
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
|
||||
// ``auth_type=oauth2`` (PKCE passthrough). The field 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
|
||||
|
|
@ -258,7 +258,7 @@ const legacyBuild = (values: Record<string, any>, ui: EditServerUiState) => {
|
|||
return isNoneAuth && hasAuthorizationHeader ? 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
|
||||
// modes (true_passthrough / oauth_delegate). The field 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import type { Validate } from "react-hook-form";
|
||||
|
||||
import type { MountedFieldControlProps, MountedFormValues } from "@/components/common_components/MountedFormField";
|
||||
|
||||
type McpValidate = Validate<unknown, MountedFormValues>;
|
||||
|
||||
const ariaOf = (control: MountedFieldControlProps) => ({
|
||||
id: control.id,
|
||||
onBlur: control.onBlur,
|
||||
"aria-required": control["aria-required"],
|
||||
"aria-invalid": control["aria-invalid"],
|
||||
"aria-describedby": control["aria-describedby"],
|
||||
});
|
||||
|
||||
export const textControl = (control: MountedFieldControlProps) => ({
|
||||
...ariaOf(control),
|
||||
name: control.name,
|
||||
value: control.value === null || control.value === undefined ? "" : String(control.value),
|
||||
onChange: control.onChange,
|
||||
});
|
||||
|
||||
export const selectControl = <TValue = unknown>(control: MountedFieldControlProps) => ({
|
||||
...ariaOf(control),
|
||||
value: control.value as TValue,
|
||||
onChange: control.onChange,
|
||||
});
|
||||
|
||||
export const numberControl = (control: MountedFieldControlProps) => ({
|
||||
...ariaOf(control),
|
||||
value: control.value as number | null | undefined,
|
||||
onChange: control.onChange,
|
||||
});
|
||||
|
||||
export const switchControl = (control: MountedFieldControlProps) => ({
|
||||
...ariaOf(control),
|
||||
checked: control.value === true,
|
||||
onChange: control.onChange,
|
||||
});
|
||||
|
||||
export const invertedSwitchControl = (control: MountedFieldControlProps) => ({
|
||||
...ariaOf(control),
|
||||
checked: control.value !== true,
|
||||
onChange: (checked: boolean) => control.onChange(!checked),
|
||||
});
|
||||
|
||||
export const valueAt = (values: MountedFormValues, path: readonly string[]): unknown =>
|
||||
path.reduce<unknown>(
|
||||
(node, segment) => (node === null || node === undefined ? undefined : (node as Record<string, unknown>)[segment]),
|
||||
values,
|
||||
);
|
||||
|
||||
export const parsesAsJson =
|
||||
(message: string): McpValidate =>
|
||||
(value) => {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
};
|
||||
|
||||
export const parsesAsJsonObject =
|
||||
(message: string, notObjectMessage: string): McpValidate =>
|
||||
(value) => {
|
||||
if (typeof value !== "string" || value === "") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? true : notObjectMessage;
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
};
|
||||
|
||||
export const matchesPattern =
|
||||
(pattern: RegExp, message: string): McpValidate =>
|
||||
(value) =>
|
||||
typeof value === "string" && value !== "" && !pattern.test(value) ? message : true;
|
||||
|
||||
export const notOnlyWhitespace =
|
||||
(message: string): McpValidate =>
|
||||
(value) =>
|
||||
typeof value === "string" && value !== "" && value.trim() === "" ? message : true;
|
||||
|
||||
export const requiredWhenSiblingSet =
|
||||
(siblingPath: readonly string[], message: string): McpValidate =>
|
||||
(value, values) =>
|
||||
valueAt(values, siblingPath) && !value ? message : true;
|
||||
|
||||
export const requiredUnlessSiblingSet =
|
||||
(siblingPath: readonly string[], message: string): McpValidate =>
|
||||
(value, values) =>
|
||||
value || valueAt(values, siblingPath) ? true : message;
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import React from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import type { MountedFormValues } from "@/components/common_components/MountedFormField";
|
||||
import { allFieldsValue, deepMergedFieldsValue, resetFields, setFieldsValue, singleBranchChange } from "./mcpFormStore";
|
||||
|
||||
const withForm = (
|
||||
defaultValues: MountedFormValues,
|
||||
act: (form: ReturnType<typeof useForm<MountedFormValues>>) => void,
|
||||
) => {
|
||||
let store: MountedFormValues = {};
|
||||
const Probe: React.FC = () => {
|
||||
const form = useForm<MountedFormValues>({ defaultValues });
|
||||
React.useEffect(() => {
|
||||
act(form);
|
||||
store = allFieldsValue(form);
|
||||
}, [form]);
|
||||
return null;
|
||||
};
|
||||
render(<Probe />);
|
||||
return store;
|
||||
};
|
||||
|
||||
describe("deepMergedFieldsValue", () => {
|
||||
it("keeps a sibling key when a nested object is written, which is what preserves a declared app", () => {
|
||||
expect(
|
||||
deepMergedFieldsValue(
|
||||
{ credentials: { client_id: "kept", access_token: "tok" } },
|
||||
{ credentials: { client_id: "typed" } },
|
||||
),
|
||||
).toStrictEqual({ credentials: { client_id: "typed", access_token: "tok" } });
|
||||
});
|
||||
|
||||
it("replaces an array rather than merging it index by index", () => {
|
||||
expect(deepMergedFieldsValue({ extra_headers: ["a", "b", "c"] }, { extra_headers: ["z"] })).toStrictEqual({
|
||||
extra_headers: ["z"],
|
||||
});
|
||||
});
|
||||
|
||||
it("writes an explicit undefined instead of skipping the key, which is how a transport switch clears a field", () => {
|
||||
const merged = deepMergedFieldsValue({ url: "https://old", auth_type: "api_key" }, { url: undefined });
|
||||
expect(merged).toStrictEqual({ url: undefined, auth_type: "api_key" });
|
||||
expect("url" in merged).toBe(true);
|
||||
});
|
||||
|
||||
it("writes an explicit null rather than treating it as a merge target", () => {
|
||||
expect(deepMergedFieldsValue({ credentials: { client_id: "x" } }, { credentials: null })).toStrictEqual({
|
||||
credentials: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces a primitive with an object when the incoming value is an object", () => {
|
||||
expect(deepMergedFieldsValue({ credentials: "not-an-object" }, { credentials: { client_id: "x" } })).toStrictEqual({
|
||||
credentials: { client_id: "x" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the store it was handed", () => {
|
||||
const store = { credentials: { client_id: "kept" } };
|
||||
deepMergedFieldsValue(store, { credentials: { client_secret: "added" } });
|
||||
expect(store).toStrictEqual({ credentials: { client_id: "kept" } });
|
||||
});
|
||||
|
||||
it("treats a missing store as empty rather than throwing", () => {
|
||||
expect(deepMergedFieldsValue(undefined, { alias: "a" })).toStrictEqual({ alias: "a" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("singleBranchChange", () => {
|
||||
it("carries only the changed leaf, so re-applying it cannot resurrect a sibling token key", () => {
|
||||
expect(
|
||||
singleBranchChange("credentials.client_id", { credentials: { client_id: "typed", access_token: "stale" } }),
|
||||
).toStrictEqual({ credentials: { client_id: "typed" } });
|
||||
});
|
||||
|
||||
it("exposes the changed top-level key so an upstream-field check can test membership", () => {
|
||||
const changed = singleBranchChange("url", { url: "https://new", alias: "a" });
|
||||
expect("url" in changed).toBe(true);
|
||||
expect("alias" in changed).toBe(false);
|
||||
});
|
||||
|
||||
it("builds an array for a numeric segment so a list row does not become an object keyed by index", () => {
|
||||
expect(
|
||||
singleBranchChange("static_headers.1.value", { static_headers: [{ value: "a" }, { value: "b" }] }),
|
||||
).toStrictEqual({ static_headers: [undefined, { value: "b" }] });
|
||||
});
|
||||
|
||||
it("yields an undefined leaf rather than throwing when the path is not in the store", () => {
|
||||
expect(singleBranchChange("credentials.client_secret", {})).toStrictEqual({
|
||||
credentials: { client_secret: undefined },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetFields", () => {
|
||||
it("restores the seeded value rather than clearing the key, so an edit reset keeps the saved server's credentials", () => {
|
||||
const store = withForm({ credentials: { client_id: "saved", access_token: "tok" } }, (form) => {
|
||||
form.setValue("credentials", { client_id: "typed" });
|
||||
resetFields(form, ["credentials"], { credentials: { client_id: "saved", access_token: "tok" } });
|
||||
});
|
||||
|
||||
expect(store.credentials).toStrictEqual({ client_id: "saved", access_token: "tok" });
|
||||
});
|
||||
|
||||
it("clears the key when no seed is supplied, which is what the create form's blank store means", () => {
|
||||
const store = withForm({ credentials: { client_id: "typed" } }, (form) => {
|
||||
resetFields(form, ["credentials"]);
|
||||
});
|
||||
|
||||
expect(store).toHaveProperty("credentials", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFieldsValue", () => {
|
||||
it("writes an undefined leaf into the live store, so a transport switch really clears the field", () => {
|
||||
const store = withForm({ url: "https://example.com", command: "npx" }, (form) => {
|
||||
setFieldsValue(form, { url: undefined });
|
||||
});
|
||||
|
||||
expect(store).toStrictEqual({ url: undefined, command: "npx" });
|
||||
});
|
||||
|
||||
it("merges a nested write into the live store instead of replacing the whole object", () => {
|
||||
const store = withForm({ credentials: { client_id: "kept", scopes: ["a"] } }, (form) => {
|
||||
setFieldsValue(form, { credentials: { client_secret: "new" } });
|
||||
});
|
||||
|
||||
expect(store.credentials).toStrictEqual({ client_id: "kept", scopes: ["a"], client_secret: "new" });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { useWatch } from "react-hook-form";
|
||||
import type { Control, UseFormReturn } from "react-hook-form";
|
||||
|
||||
import {
|
||||
projectMountedValues,
|
||||
type MountRegistry,
|
||||
type MountedFormValues,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
|
||||
export type McpForm = UseFormReturn<MountedFormValues>;
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype;
|
||||
|
||||
export const deepMergedFieldsValue = (store: unknown, values: Record<string, unknown>): Record<string, unknown> =>
|
||||
Object.entries(values).reduce<Record<string, unknown>>(
|
||||
(merged, [key, value]) => ({
|
||||
...merged,
|
||||
[key]: isPlainObject(value) ? deepMergedFieldsValue(merged[key], value) : value,
|
||||
}),
|
||||
isPlainObject(store) ? { ...store } : {},
|
||||
);
|
||||
|
||||
export const setFieldsValue = (form: McpForm, values: Record<string, unknown>): void => {
|
||||
const merged = deepMergedFieldsValue(form.getValues(), values);
|
||||
Object.keys(values).forEach((key) => form.setValue(key, merged[key]));
|
||||
};
|
||||
|
||||
export const resetFields = (form: McpForm, names: readonly string[], defaults: MountedFormValues = {}): void => {
|
||||
names.forEach((name) => {
|
||||
form.setValue(name, defaults[name]);
|
||||
form.clearErrors(name);
|
||||
});
|
||||
};
|
||||
|
||||
const branchAt = (segments: readonly string[], leaf: unknown): unknown => {
|
||||
const [head, ...rest] = segments;
|
||||
if (head === undefined) {
|
||||
return leaf;
|
||||
}
|
||||
const child = branchAt(rest, leaf);
|
||||
if (!/^\d+$/.test(head)) {
|
||||
return { [head]: child };
|
||||
}
|
||||
const index = Number(head);
|
||||
return Array.from({ length: index + 1 }, (_, position) => (position === index ? child : undefined));
|
||||
};
|
||||
|
||||
export const singleBranchChange = (path: string, values: MountedFormValues): Record<string, unknown> => {
|
||||
const segments = path.split(".");
|
||||
const leaf = segments.reduce<unknown>(
|
||||
(node, segment) => (node === null || node === undefined ? undefined : (node as Record<string, unknown>)[segment]),
|
||||
values,
|
||||
);
|
||||
return branchAt(segments, leaf) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface McpStaticHeaderRow {
|
||||
header?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface McpEnvVarRow {
|
||||
name?: string;
|
||||
value?: string;
|
||||
scope?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface McpListValues {
|
||||
static_headers: McpStaticHeaderRow[];
|
||||
env_vars: McpEnvVarRow[];
|
||||
}
|
||||
|
||||
export interface McpFormSnapshot extends MountedFormValues {
|
||||
readonly server_name?: string;
|
||||
readonly alias?: string;
|
||||
readonly description?: string;
|
||||
readonly url?: string;
|
||||
readonly spec_path?: string;
|
||||
readonly transport?: string;
|
||||
readonly auth_type?: string;
|
||||
readonly oauth_flow_type?: string;
|
||||
readonly credentials?: Record<string, unknown>;
|
||||
readonly issuer?: string;
|
||||
readonly authorization_url?: string;
|
||||
readonly token_url?: string;
|
||||
readonly registration_url?: string;
|
||||
readonly mcp_access_groups?: string[];
|
||||
readonly static_headers?: readonly McpStaticHeaderRow[];
|
||||
readonly command?: string;
|
||||
readonly args?: string[];
|
||||
readonly env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const allFieldsValue = (form: McpForm): McpFormSnapshot => form.getValues() as McpFormSnapshot;
|
||||
|
||||
export const listControl = (control: Control<MountedFormValues>): Control<McpListValues> =>
|
||||
control as unknown as Control<McpListValues>;
|
||||
|
||||
export const mountedPaths = (registry: MountRegistry): readonly string[] =>
|
||||
registry.mountedNames().map((name) => (Array.isArray(name) ? name.join(".") : (name as string)));
|
||||
|
||||
export const useMountedValues = (form: McpForm, registry: MountRegistry): MountedFormValues => {
|
||||
useWatch({ control: form.control });
|
||||
return projectMountedValues(registry, form.getValues);
|
||||
};
|
||||
|
|
@ -781,7 +781,7 @@ describe("MCPServerEdit (interactive OAuth)", () => {
|
|||
});
|
||||
|
||||
// Note: The M2M flow hiding logic is tested via OAuthFormFields.test.tsx (isM2M prop directly),
|
||||
// since Form.useWatch doesn't synchronously reflect initialValues in jsdom.
|
||||
// since a mounted-values read doesn't synchronously reflect the seeded defaults in jsdom.
|
||||
|
||||
it("pre-populates token_validation_json from existing server token_validation", async () => {
|
||||
const tokenValidation = { organization: "my-org", "team.id": "123" };
|
||||
|
|
@ -1003,7 +1003,7 @@ describe("MCPServerEdit (interactive OAuth)", () => {
|
|||
fireEvent.click(saveButtons[0]);
|
||||
});
|
||||
|
||||
// The Form.Item inline validator intercepts invalid JSON before handleSave runs,
|
||||
// The field's inline validator intercepts invalid JSON before handleSave runs,
|
||||
// so the inline error message appears and updateMCPServer is never called.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Must be valid JSON")).toBeInTheDocument();
|
||||
|
|
@ -2190,7 +2190,7 @@ describe("MCPServerEdit (dcr_bridge toggle)", () => {
|
|||
});
|
||||
expect(getDcrToggle()).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
// The Form.Item stays mounted across the two client-forwarded modes, so the live toggle value is
|
||||
// The field 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(() => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -482,7 +482,7 @@ describe("projection shape", () => {
|
|||
expect(projected.command).toBe("npx");
|
||||
});
|
||||
|
||||
it("passes Form.List rows through whole, since antd does not project a row to its mounted sub-fields", () => {
|
||||
it("passes list rows through whole, since a list field is projected as one key and not per mounted sub-field", () => {
|
||||
const row = { name: "N", value: "V", scope: "user", description: "D" };
|
||||
const projected = projectMountedEditValues({ ...HTTP_NONE, env_vars: [row] });
|
||||
expect(projected.env_vars).toStrictEqual([row]);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { expect } from "vitest";
|
|||
export async function selectAntOption(labelText: string, optionText: string) {
|
||||
const label = screen.getByText(labelText);
|
||||
const select =
|
||||
label.closest("[data-slot='field']")?.querySelector(".ant-select") ??
|
||||
label.closest(".ant-form-item")?.querySelector(".ant-select") ??
|
||||
label.closest(".ant-collapse-item")?.querySelector(".ant-select") ??
|
||||
label.closest("div")?.querySelector(".ant-select") ??
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { UseFormGetValues } from "react-hook-form";
|
||||
|
||||
import {
|
||||
projectMountedValues,
|
||||
type MountedFieldName,
|
||||
type MountedFormValues,
|
||||
type MountRegistry,
|
||||
} from "./MountedFormField";
|
||||
|
||||
const registryOf = (names: readonly MountedFieldName[]): MountRegistry => ({
|
||||
register: () => () => undefined,
|
||||
mountedNames: () => names,
|
||||
});
|
||||
|
||||
const getValuesOf = (store: Readonly<Record<string, unknown>>): UseFormGetValues<MountedFormValues> =>
|
||||
((names: readonly string[]) => names.map((name) => store[name])) as unknown as UseFormGetValues<MountedFormValues>;
|
||||
|
||||
const project = (store: Readonly<Record<string, unknown>>) =>
|
||||
projectMountedValues(registryOf(Object.keys(store)), getValuesOf(store));
|
||||
|
||||
const projectPaths = (entries: readonly (readonly [MountedFieldName, unknown])[]) => {
|
||||
const store = Object.fromEntries(
|
||||
entries.map(([name, value]) => [Array.isArray(name) ? name.join(".") : (name as string), value]),
|
||||
);
|
||||
return projectMountedValues(registryOf(entries.map(([name]) => name)), getValuesOf(store));
|
||||
};
|
||||
|
||||
describe("projectMountedValues", () => {
|
||||
it("keeps a flat name flat", () => {
|
||||
expect(project({ server_name: "s1", transport: "http" })).toStrictEqual({ server_name: "s1", transport: "http" });
|
||||
});
|
||||
|
||||
it("nests an ARRAY name into a credentials object", () => {
|
||||
expect(
|
||||
projectPaths([
|
||||
[["credentials", "aws_region_name"], "us-east-1"],
|
||||
[["credentials", "aws_access_key_id"], "AKIA"],
|
||||
]),
|
||||
).toStrictEqual({ credentials: { aws_region_name: "us-east-1", aws_access_key_id: "AKIA" } });
|
||||
});
|
||||
|
||||
it("keeps a literal dotted STRING name flat, matching antd getNamePath toArray", () => {
|
||||
expect(projectPaths([["a.b", 1]])).toStrictEqual({ "a.b": 1 });
|
||||
expect(projectPaths([["schema.property.with.dots", "v"]])).toStrictEqual({ "schema.property.with.dots": "v" });
|
||||
});
|
||||
|
||||
it("rebuilds Form.List rows as an array, not an object keyed by digits", () => {
|
||||
const projected = projectPaths([
|
||||
[["env_vars", "0", "name"], "API_KEY"],
|
||||
[["env_vars", "0", "description"], "the key"],
|
||||
[["env_vars", "1", "name"], "REGION"],
|
||||
]);
|
||||
expect(projected).toStrictEqual({
|
||||
env_vars: [{ name: "API_KEY", description: "the key" }, { name: "REGION" }],
|
||||
});
|
||||
expect(Array.isArray(projected.env_vars)).toBe(true);
|
||||
});
|
||||
|
||||
it("rebuilds static_headers rows, the second Form.List site", () => {
|
||||
expect(
|
||||
projectPaths([
|
||||
[["static_headers", "0", "key"], "X-Tenant"],
|
||||
[["static_headers", "0", "value"], "acme"],
|
||||
]),
|
||||
).toStrictEqual({
|
||||
static_headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a mounted-but-unset field as a key holding undefined, matching antd onFinish", () => {
|
||||
const projected = project({ alias: undefined });
|
||||
expect(Object.keys(projected)).toStrictEqual(["alias"]);
|
||||
expect(projected.alias).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves a sparse row index as a hole rather than shifting later rows down", () => {
|
||||
const projected = projectPaths([[["env_vars", "2", "name"], "THIRD"]]) as { env_vars: readonly unknown[] };
|
||||
expect(projected.env_vars).toHaveLength(3);
|
||||
expect(projected.env_vars[2]).toStrictEqual({ name: "THIRD" });
|
||||
});
|
||||
|
||||
it("mixes flat, nested and list names in one projection", () => {
|
||||
expect(
|
||||
projectPaths([
|
||||
["transport", "http"],
|
||||
[["credentials", "client_id"], "cid"],
|
||||
[["env_vars", "0", "name"], "K"],
|
||||
]),
|
||||
).toStrictEqual({
|
||||
transport: "http",
|
||||
credentials: { client_id: "cid" },
|
||||
env_vars: [{ name: "K" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import React from "react";
|
||||
import { render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { UseFormGetValues } from "react-hook-form";
|
||||
|
||||
import {
|
||||
MountedFormField,
|
||||
MountedFormProvider,
|
||||
projectMountedValues,
|
||||
useMountRegistry,
|
||||
type MountedFieldName,
|
||||
type MountedFormValues,
|
||||
type MountRegistry,
|
||||
} from "./MountedFormField";
|
||||
|
||||
const registryOf = (names: readonly MountedFieldName[]): MountRegistry => ({
|
||||
register: () => () => undefined,
|
||||
mountedNames: () => names,
|
||||
});
|
||||
|
||||
const getValuesOf = (store: Readonly<Record<string, unknown>>): UseFormGetValues<MountedFormValues> =>
|
||||
((names: readonly string[]) => names.map((name) => store[name])) as unknown as UseFormGetValues<MountedFormValues>;
|
||||
|
||||
const project = (store: Readonly<Record<string, unknown>>) =>
|
||||
projectMountedValues(registryOf(Object.keys(store)), getValuesOf(store));
|
||||
|
||||
const projectPaths = (entries: readonly (readonly [MountedFieldName, unknown])[]) => {
|
||||
const store = Object.fromEntries(
|
||||
entries.map(([name, value]) => [Array.isArray(name) ? name.join(".") : (name as string), value]),
|
||||
);
|
||||
return projectMountedValues(registryOf(entries.map(([name]) => name)), getValuesOf(store));
|
||||
};
|
||||
|
||||
describe("projectMountedValues", () => {
|
||||
it("keeps a flat name flat", () => {
|
||||
expect(project({ server_name: "s1", transport: "http" })).toStrictEqual({ server_name: "s1", transport: "http" });
|
||||
});
|
||||
|
||||
it("nests an ARRAY name into a credentials object", () => {
|
||||
expect(
|
||||
projectPaths([
|
||||
[["credentials", "aws_region_name"], "us-east-1"],
|
||||
[["credentials", "aws_access_key_id"], "AKIA"],
|
||||
]),
|
||||
).toStrictEqual({ credentials: { aws_region_name: "us-east-1", aws_access_key_id: "AKIA" } });
|
||||
});
|
||||
|
||||
it("keeps a literal dotted STRING name flat, matching antd getNamePath toArray", () => {
|
||||
expect(projectPaths([["a.b", 1]])).toStrictEqual({ "a.b": 1 });
|
||||
expect(projectPaths([["schema.property.with.dots", "v"]])).toStrictEqual({ "schema.property.with.dots": "v" });
|
||||
});
|
||||
|
||||
it("rebuilds Form.List rows as an array, not an object keyed by digits", () => {
|
||||
const projected = projectPaths([
|
||||
[["env_vars", "0", "name"], "API_KEY"],
|
||||
[["env_vars", "0", "description"], "the key"],
|
||||
[["env_vars", "1", "name"], "REGION"],
|
||||
]);
|
||||
expect(projected).toStrictEqual({
|
||||
env_vars: [{ name: "API_KEY", description: "the key" }, { name: "REGION" }],
|
||||
});
|
||||
expect(Array.isArray(projected.env_vars)).toBe(true);
|
||||
});
|
||||
|
||||
it("rebuilds static_headers rows, the second Form.List site", () => {
|
||||
expect(
|
||||
projectPaths([
|
||||
[["static_headers", "0", "key"], "X-Tenant"],
|
||||
[["static_headers", "0", "value"], "acme"],
|
||||
]),
|
||||
).toStrictEqual({
|
||||
static_headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a mounted-but-unset field as a key holding undefined, matching antd onFinish", () => {
|
||||
const projected = project({ alias: undefined });
|
||||
expect(Object.keys(projected)).toStrictEqual(["alias"]);
|
||||
expect(projected.alias).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves a sparse row index as a hole rather than shifting later rows down", () => {
|
||||
const projected = projectPaths([[["env_vars", "2", "name"], "THIRD"]]) as { env_vars: readonly unknown[] };
|
||||
expect(projected.env_vars).toHaveLength(3);
|
||||
expect(projected.env_vars[2]).toStrictEqual({ name: "THIRD" });
|
||||
});
|
||||
|
||||
it("mixes flat, nested and list names in one projection", () => {
|
||||
expect(
|
||||
projectPaths([
|
||||
["transport", "http"],
|
||||
[["credentials", "client_id"], "cid"],
|
||||
[["env_vars", "0", "name"], "K"],
|
||||
]),
|
||||
).toStrictEqual({
|
||||
transport: "http",
|
||||
credentials: { client_id: "cid" },
|
||||
env_vars: [{ name: "K" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMountRegistry lifecycle", () => {
|
||||
const GatedForm: React.FC<{
|
||||
showOptional: boolean;
|
||||
showRequired: boolean;
|
||||
onFinish: (v: MountedFormValues) => void;
|
||||
}> = ({ showOptional, showRequired, onFinish }) => {
|
||||
const form = useForm<MountedFormValues>({ mode: "onChange", defaultValues: { server_name: "keep" } });
|
||||
const registry = useMountRegistry();
|
||||
return (
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void form
|
||||
.trigger(registry.mountedNames().map((n) => (Array.isArray(n) ? n.join(".") : (n as string))))
|
||||
.then((valid) => {
|
||||
if (valid) onFinish(projectMountedValues(registry, form.getValues));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MountedFormField name="server_name">
|
||||
{(control) => (
|
||||
<input aria-label="server_name" value={String(control.value ?? "")} onChange={control.onChange} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
{showOptional && (
|
||||
<MountedFormField name="alias">
|
||||
{(control) => (
|
||||
<input aria-label="alias" value={String(control.value ?? "")} onChange={control.onChange} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
)}
|
||||
{showRequired && (
|
||||
<MountedFormField name="token_url" rules={{ required: "Token URL is required" }}>
|
||||
{(control) => (
|
||||
<input aria-label="token_url" value={String(control.value ?? "")} onChange={control.onChange} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
)}
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MountedFormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it("drops a field's key from the submitted payload once its gate unmounts it", async () => {
|
||||
const onFinish = vi.fn();
|
||||
const { rerender } = render(<GatedForm showOptional showRequired={false} onFinish={onFinish} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
|
||||
expect(Object.keys(onFinish.mock.calls[0][0] as object)).toContain("alias");
|
||||
|
||||
rerender(<GatedForm showOptional={false} showRequired={false} onFinish={onFinish} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(2));
|
||||
expect(Object.keys(onFinish.mock.calls[1][0] as object)).not.toContain("alias");
|
||||
});
|
||||
|
||||
it("submits after a required field is unmounted, rather than validating a field the user can no longer see", async () => {
|
||||
const onFinish = vi.fn();
|
||||
const { rerender } = render(<GatedForm showOptional={false} showRequired onFinish={onFinish} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
expect(await screen.findByText("Token URL is required")).toBeInTheDocument();
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<GatedForm showOptional={false} showRequired={false} onFinish={onFinish} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
|
||||
expect(Object.keys(onFinish.mock.calls[0][0] as object)).not.toContain("token_url");
|
||||
});
|
||||
|
||||
it("keeps a name mounted while a second field still holds a registration on it", () => {
|
||||
const registry = renderHook(() => useMountRegistry()).result.current;
|
||||
const releaseFirst = registry.register("credentials.scopes");
|
||||
registry.register("credentials.scopes");
|
||||
|
||||
releaseFirst();
|
||||
|
||||
expect(registry.mountedNames()).toStrictEqual(["credentials.scopes"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -94,6 +94,11 @@ export const projectMountedValues = (
|
|||
);
|
||||
};
|
||||
|
||||
export const useMountedName = (name: MountedFieldName): void => {
|
||||
const { registry } = React.useContext(MountedFormContext);
|
||||
React.useEffect(() => registry.register(name), [registry, name]);
|
||||
};
|
||||
|
||||
export type MountedFieldControlProps = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
|
|
@ -131,9 +136,9 @@ export const MountedFormField: React.FC<MountedFormFieldProps> = ({
|
|||
className,
|
||||
children,
|
||||
}) => {
|
||||
const { control, registry } = React.useContext(MountedFormContext);
|
||||
const { control } = React.useContext(MountedFormContext);
|
||||
const path = fieldKey(name);
|
||||
React.useEffect(() => registry.register(name), [registry, name]);
|
||||
useMountedName(name);
|
||||
|
||||
const helpId = `${path}_help`;
|
||||
const hasHelp = help !== undefined && help !== null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue