mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
refactor(ui): port MCP server forms from antd Form to react-hook-form
The MCP server create and edit screens carry the dashboard's largest antd `Form` graph: 92 `Form.Item`s across 14 files, against 39 in the next largest. This replaces `Form` and `Form.Item` with react-hook-form plus the shared shadcn field wrapper, and leaves every antd widget in place, so the rendered form is unchanged. antd submits only the fields that are currently mounted, which the conditional transport and auth sections rely on. react-hook-form keeps unmounted values instead, so the port adds an explicit mount registry and projects the store through it at submit time. `MountedFormField` also reproduces the antd behaviours the graph depended on: an unmounted watch reading as undefined, `setFieldsValue` deep-merging objects while replacing arrays, and `resetFields` restoring values for names that have no registered field. Field-level `initialValue` props do not survive the port, so the four defaults they carried now live in `defaultValues`. The edit form builds `defaultValues` from named keys rather than spreading the server record, which keeps read-only columns such as `created_at` and `approval_status` out of the store as well as out of the payload. The registry itself is shared with the create key port, so it keeps that lane's reference counting: a name stays mounted while any field still binds it, which matters here because 23 of the 58 names are bound in more than one place. `projectMountedValues` accepts either a store or a `getValues` function so both call styles keep working. Removes an auth hint in OpenApiByokFields that sat inside a block already guarded by the same condition and could never render.
This commit is contained in:
parent
6cf019a933
commit
ad3f324f3d
22 changed files with 2731 additions and 1772 deletions
|
|
@ -558,9 +558,6 @@
|
|||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx": {
|
||||
|
|
@ -583,11 +580,6 @@
|
|||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -609,11 +601,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
|
|
@ -627,7 +614,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": {
|
||||
|
|
@ -640,11 +627,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -732,12 +714,6 @@
|
|||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": {
|
||||
|
|
@ -3014,4 +2990,4 @@
|
|||
"count": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,155 +1,156 @@
|
|||
import React from "react";
|
||||
import { Form, Input, Tooltip } from "antd";
|
||||
import { Input, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { MountedFormField, bindControl, type MountedFormValues } from "@/components/common_components/MountedFormField";
|
||||
|
||||
const AwsSigV4Fields: React.FC = () => (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
For MCP servers hosted on AWS Bedrock AgentCore.{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/mcp_aws_sigv4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
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>
|
||||
}
|
||||
name={["credentials", "aws_region_name"]}
|
||||
rules={[{ required: true, message: "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
|
||||
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>
|
||||
}
|
||||
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
|
||||
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>
|
||||
}
|
||||
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();
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<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
|
||||
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>
|
||||
}
|
||||
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();
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<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>
|
||||
}
|
||||
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
|
||||
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>
|
||||
}
|
||||
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
|
||||
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>
|
||||
}
|
||||
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>
|
||||
</>
|
||||
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 AwsSigV4Fields: React.FC = () => {
|
||||
const { getValues } = useFormContext<MountedFormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
For MCP servers hosted on AWS Bedrock AgentCore.{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/mcp_aws_sigv4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
View docs →
|
||||
</a>
|
||||
</p>
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="AWS Region" tooltip="AWS region for SigV4 signing (e.g., us-east-1)" />}
|
||||
name="credentials.aws_region_name"
|
||||
required
|
||||
rules={{ required: "AWS region is required for SigV4 auth" }}
|
||||
>
|
||||
{(field) => (
|
||||
<Input {...bindControl<string | undefined>(field)} placeholder="us-east-1" className={fieldClassName} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="AWS Service Name"
|
||||
tooltip="AWS service name for SigV4 signing. Defaults to 'bedrock-agentcore'."
|
||||
/>
|
||||
}
|
||||
name="credentials.aws_service_name"
|
||||
>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="bedrock-agentcore"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<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"
|
||||
rules={{
|
||||
deps: ["credentials.aws_secret_access_key"],
|
||||
validate: (value) =>
|
||||
getValues("credentials.aws_secret_access_key") && !value
|
||||
? "Access Key ID is required when Secret Access Key is provided"
|
||||
: true,
|
||||
}}
|
||||
>
|
||||
{(field) => (
|
||||
<Input.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="AKIA... (optional — uses IAM role if blank)"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel label="AWS Secret Access Key" tooltip="Optional. Required if AWS Access Key ID is provided." />
|
||||
}
|
||||
name="credentials.aws_secret_access_key"
|
||||
rules={{
|
||||
deps: ["credentials.aws_access_key_id"],
|
||||
validate: (value) =>
|
||||
getValues("credentials.aws_access_key_id") && !value
|
||||
? "Secret Access Key is required when Access Key ID is provided"
|
||||
: true,
|
||||
}}
|
||||
>
|
||||
{(field) => (
|
||||
<Input.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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"
|
||||
>
|
||||
{(field) => (
|
||||
<Input.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="Enter session token (optional)"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<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"
|
||||
>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="arn:aws:iam::123456789012:role/MyRole (optional)"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<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"
|
||||
>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="litellm-prod (optional, auto-generated if blank)"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AwsSigV4Fields;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
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 { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import {
|
||||
MountedFormField,
|
||||
MountedFormProvider,
|
||||
applyFieldValues,
|
||||
bindControl,
|
||||
changedValuesFor,
|
||||
projectMountedValues,
|
||||
resetFieldsToDefaults,
|
||||
useMountRegistry,
|
||||
type MountedFormValues,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
|
|
@ -45,7 +57,7 @@ import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection";
|
|||
import MCPLogoSelector from "./MCPLogoSelector";
|
||||
import EnvVarsSection from "./EnvVarsSection";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
|
||||
import { antdValidator, validateMCPServerUrl, validateMCPServerName } from "./utils";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
|
||||
import { useTestMCPConnection } from "@/hooks/useTestMCPConnection";
|
||||
|
|
@ -76,6 +88,16 @@ const payloadErrorMessage = (result: Exclude<BuildCreatePayloadResult, { kind: "
|
|||
}
|
||||
};
|
||||
|
||||
const CREATE_DEFAULTS: MountedFormValues = {
|
||||
dcr_bridge: true,
|
||||
token_exchange_profile: "rfc8693",
|
||||
oauth_flow_type: OAUTH_FLOW.INTERACTIVE,
|
||||
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 +109,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
prefillData,
|
||||
onBackToDiscovery,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const form = useForm<MountedFormValues>({ defaultValues: CREATE_DEFAULTS });
|
||||
const registry = useMountRegistry();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [costConfig, setCostConfig] = useState<MCPServerCostInfo>({});
|
||||
const [formValues, setFormValues] = useState<Record<string, any>>({});
|
||||
|
|
@ -147,7 +170,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
const persistCreateUiState = () => {
|
||||
writeCreateUiSnapshot({
|
||||
modalVisible: isModalVisible,
|
||||
formValues: form.getFieldsValue(true),
|
||||
formValues: form.getValues() as Record<string, any>,
|
||||
transportType,
|
||||
costConfig,
|
||||
allowedTools,
|
||||
|
|
@ -170,11 +193,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) ?? {}),
|
||||
...((form.getValues("credentials") as Record<string, unknown> | undefined) ?? {}),
|
||||
...(dcrClientRef.current ?? {}),
|
||||
}),
|
||||
getTemporaryPayload: () => {
|
||||
const values = form.getFieldsValue(true);
|
||||
const values: Record<string, any> = form.getValues();
|
||||
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 +241,12 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
if (isClientForwardedTokenMode(form.getFieldValue("auth_type"))) {
|
||||
if (isClientForwardedTokenMode(form.getValues("auth_type") as string | undefined)) {
|
||||
// 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(form.getValues()));
|
||||
toast.success(
|
||||
"Token held for this browser session. Tools can now be previewed and configured; the token is not saved to LiteLLM.",
|
||||
);
|
||||
|
|
@ -240,7 +263,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
}
|
||||
: null;
|
||||
|
||||
const current = (form.getFieldValue("credentials") as Record<string, unknown> | undefined) ?? {};
|
||||
const current = (form.getValues("credentials") as Record<string, unknown> | undefined) ?? {};
|
||||
const nextCredentials = {
|
||||
...(preservedAdminCredentials(current) ?? {}),
|
||||
...(current.scopes !== undefined && { scopes: current.scopes }),
|
||||
|
|
@ -252,10 +275,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(form.getValues()));
|
||||
|
||||
toast.success("OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.");
|
||||
},
|
||||
|
|
@ -277,10 +300,12 @@ 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(
|
||||
form.getValues("credentials") as Record<string, unknown> | undefined,
|
||||
);
|
||||
resetFieldsToDefaults(form, CREATE_DEFAULTS, CLEARED_ON_INVALIDATION);
|
||||
if (keptAdminCredentials) {
|
||||
form.setFieldsValue({ credentials: keptAdminCredentials });
|
||||
applyFieldValues(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 +313,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);
|
||||
applyFieldValues(form, preserved);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -337,7 +362,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
// wait until transportType state catches up so the URL field is mounted
|
||||
return;
|
||||
}
|
||||
form.setFieldsValue(pendingRestoredValues.values);
|
||||
applyFieldValues(form, pendingRestoredValues.values);
|
||||
setFormValues(pendingRestoredValues.values);
|
||||
setPendingRestoredValues(null);
|
||||
}, [pendingRestoredValues, form, transportType]);
|
||||
|
|
@ -381,7 +406,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
prefillValues.url = prefillData.url;
|
||||
}
|
||||
|
||||
form.setFieldsValue(prefillValues);
|
||||
applyFieldValues(form, prefillValues);
|
||||
setFormValues(prefillValues);
|
||||
setAliasManuallyEdited(false);
|
||||
}, [isModalVisible, prefillData, form]);
|
||||
|
|
@ -446,7 +471,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 +491,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
|
||||
// state
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
form.reset(CREATE_DEFAULTS);
|
||||
setCostConfig({});
|
||||
clearTools();
|
||||
setAllowedTools([]);
|
||||
|
|
@ -489,11 +514,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)) {
|
||||
applyFieldValues(form, transportValues);
|
||||
if (isHeldOAuthTokenStale(form.getValues(), authorizedIdentity)) {
|
||||
clearHeldOAuthToken();
|
||||
}
|
||||
setFormValues(form.getFieldsValue(true));
|
||||
setFormValues(form.getValues() as Record<string, any>);
|
||||
};
|
||||
|
||||
// Generate options with existing groups and potential new group
|
||||
|
|
@ -532,7 +557,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 });
|
||||
applyFieldValues(form, { alias: normalized });
|
||||
setFormValues((prev) => ({ ...prev, alias: normalized }));
|
||||
}
|
||||
}, [formValues.server_name]);
|
||||
|
|
@ -549,7 +574,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 +607,38 @@ 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(form.getValues("credentials") as Record<string, unknown> | undefined) !==
|
||||
undefined;
|
||||
if (upstreamChanged && hasDeclaredApp) {
|
||||
setAppMayNotMatchUpstream(true);
|
||||
}
|
||||
}
|
||||
if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentity)) {
|
||||
if (isHeldOAuthTokenStale(form.getValues(), authorizedIdentity)) {
|
||||
clearHeldOAuthToken(changedValues);
|
||||
setFormValues(form.getFieldsValue(true));
|
||||
setFormValues(form.getValues() as Record<string, any>);
|
||||
return;
|
||||
}
|
||||
setFormValues(allValues);
|
||||
};
|
||||
|
||||
const valuesChangeRef = React.useRef(handleFormValuesChange);
|
||||
React.useEffect(() => {
|
||||
valuesChangeRef.current = handleFormValuesChange;
|
||||
});
|
||||
React.useEffect(() => {
|
||||
const subscription = form.watch((values, { name, type }) => {
|
||||
if (type !== "change" || !name) {
|
||||
return;
|
||||
}
|
||||
valuesChangeRef.current(
|
||||
changedValuesFor(name, values as MountedFormValues),
|
||||
projectMountedValues(registry, values as MountedFormValues),
|
||||
);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, registry]);
|
||||
|
||||
// rendering
|
||||
return (
|
||||
<Modal
|
||||
|
|
@ -636,334 +680,371 @@ 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) },
|
||||
]}
|
||||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((store) => handleCreate(projectMountedValues(registry, store)))}
|
||||
className="space-y-6"
|
||||
>
|
||||
<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>
|
||||
{!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: (value) => antdValidator(validateMCPServerName, value) }}
|
||||
>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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: (value) => antdValidator(validateMCPServerName, value) }}
|
||||
>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="e.g., GitHub_MCP, Zapier_MCP, etc."
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
onChange={(event) => {
|
||||
setAliasManuallyEdited(true);
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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"
|
||||
>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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={{ required: "Please select a transport type" }}
|
||||
>
|
||||
{(field) => (
|
||||
<Select
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="Select transport"
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
onChange={(value, option) => {
|
||||
field.onChange(value);
|
||||
handleTransportChange(value);
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
</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={{
|
||||
required: "Please enter a server URL",
|
||||
validate: (value) => antdValidator(validateMCPServerUrl, value),
|
||||
}}
|
||||
>
|
||||
{(field) => (
|
||||
<AntdInput
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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}
|
||||
defaultValues={CREATE_DEFAULTS}
|
||||
accessToken={isModalVisible ? accessToken : null}
|
||||
onValuesChange={(updates) => handleFormValuesChange(updates, { ...form.getValues(), ...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"
|
||||
>
|
||||
{(field) => (
|
||||
<InputNumber
|
||||
{...bindControl<number | null>(field)}
|
||||
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={{ required: "Please select an auth type" }}
|
||||
>
|
||||
{(field) => (
|
||||
<Select
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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: (value) =>
|
||||
value && typeof value === "string" && value.trim() === ""
|
||||
? "Authentication value cannot be empty whitespace"
|
||||
: true,
|
||||
}}
|
||||
>
|
||||
{(field) => (
|
||||
<AntdInput.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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}
|
||||
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,17 @@
|
|||
import React from "react";
|
||||
import { Form, Switch, Tooltip } from "antd";
|
||||
import { Switch, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { isClientForwardedTokenMode } from "@/components/mcp_tools/types";
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
|
||||
/**
|
||||
* 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 +22,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 +32,9 @@ export default function DcrBridgeToggle({
|
|||
</span>
|
||||
}
|
||||
name="dcr_bridge"
|
||||
valuePropName="checked"
|
||||
initialValue={initialChecked}
|
||||
defaultValue={initialChecked}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
{(field) => <Switch id={field.id} checked={Boolean(field.value)} onChange={field.onChange} />}
|
||||
</MountedFormField>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
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 {
|
||||
MountedFormField,
|
||||
bindControl,
|
||||
useMountedFieldArray,
|
||||
useMountedFormContext,
|
||||
useMountedWatch,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
@ -20,6 +27,9 @@ const SCOPE_OPTIONS = [
|
|||
* The parent form reads the ``env_vars`` field from the form values.
|
||||
*/
|
||||
const EnvVarsSection: React.FC = () => {
|
||||
const { control } = useMountedFormContext();
|
||||
const { fields, append, remove } = useMountedFieldArray(control, "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 +58,59 @@ 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((row, index) => (
|
||||
<div key={row.id} className="flex gap-3 items-start">
|
||||
<div style={{ flex: 1 }}>
|
||||
<MountedFormField
|
||||
name={`env_vars.${index}.name`}
|
||||
className="mb-0"
|
||||
required
|
||||
rules={{
|
||||
required: "Variable name is required",
|
||||
pattern: {
|
||||
value: /^[A-Za-z_][A-Za-z0-9_]*$/,
|
||||
message: "Use letters, digits, underscores; cannot start with a digit.",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="e.g. DB_PROTOCOL"
|
||||
className="rounded-md font-mono"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<ScopedValueOrDescription index={index} />
|
||||
</div>
|
||||
<div style={{ width: 160 }}>
|
||||
<MountedFormField name={`env_vars.${index}.scope`} className="mb-0" defaultValue="global">
|
||||
{(field) => <Select {...bindControl<string | undefined>(field)} options={SCOPE_OPTIONS} />}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
<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 +118,39 @@ 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 = useMountedWatch(`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.${index}.description`} className="mb-0">
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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.${index}.value`} className="mb-0">
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="e.g. postgresql"
|
||||
className="rounded-md font-mono"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React from "react";
|
||||
import { Form, Input, Select, Tooltip } from "antd";
|
||||
import { Input, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { MountedFormField, bindControl, type MountedFormValues } from "@/components/common_components/MountedFormField";
|
||||
|
||||
interface IdJagFormFieldsProps {
|
||||
isEditing?: boolean;
|
||||
|
|
@ -19,10 +21,11 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
|
|||
|
||||
const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false }) => {
|
||||
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
|
||||
const { getValues } = useFormContext<MountedFormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Org Token Endpoint (leg 1)"
|
||||
|
|
@ -30,89 +33,123 @@ 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={isEditing ? {} : { required: "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
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="https://your-org.okta.com/oauth2/v1/token"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Resource Token Endpoint (leg 2)"
|
||||
tooltip="The upstream resource authorization server's token endpoint. LiteLLM posts the ID-JAG assertion here as an RFC 7523 jwt-bearer grant to get the access token the MCP server accepts."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "id_jag_resource_token_endpoint"]}
|
||||
rules={[{ required: !isEditing, message: "The resource token endpoint is required for ID-JAG" }]}
|
||||
name="credentials.id_jag_resource_token_endpoint"
|
||||
required={!isEditing}
|
||||
rules={isEditing ? {} : { required: "The resource token endpoint is required for ID-JAG" }}
|
||||
>
|
||||
<Input placeholder="https://upstream.example.com/oauth2/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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" }]}
|
||||
name="credentials.client_id"
|
||||
required={!isEditing}
|
||||
rules={isEditing ? {} : { required: "Client ID is required for ID-JAG" }}
|
||||
>
|
||||
<Input.Password placeholder={`Enter OAuth client ID${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<Input.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder={`Enter OAuth client ID${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Secret"
|
||||
tooltip="Authenticates LiteLLM as the OAuth client via client_secret_post. Leave blank when using a private key instead; a private key takes precedence over this secret."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
dependencies={[["credentials", "client_private_key"]]}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator: (_, value) => {
|
||||
if (isEditing || value || getFieldValue(["credentials", "client_private_key"])) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error("Provide either a client secret or a client private key"));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
name="credentials.client_secret"
|
||||
rules={{
|
||||
deps: ["credentials.client_private_key"],
|
||||
validate: (value) =>
|
||||
isEditing || value || getValues("credentials.client_private_key")
|
||||
? true
|
||||
: "Provide either a client secret or a client private key",
|
||||
}}
|
||||
>
|
||||
<Input.Password placeholder={`Enter OAuth client secret${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<Input.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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="credentials.client_private_key"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder={`-----BEGIN PRIVATE KEY-----${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<Input.TextArea
|
||||
{...bindControl<string | undefined>(field)}
|
||||
rows={3}
|
||||
placeholder={`-----BEGIN PRIVATE KEY-----${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Private Key ID (optional)"
|
||||
tooltip="The kid advertised in the client assertion JWT header, so the IdP can select the right registered key."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_private_key_id"]}
|
||||
name="credentials.client_private_key_id"
|
||||
>
|
||||
<Input placeholder="my-signing-key-1" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="my-signing-key-1"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Assertion Signing Algorithm (optional)"
|
||||
tooltip="Algorithm signing the client assertion JWT. Defaults to RS256."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_assertion_signing_alg"]}
|
||||
name="credentials.client_assertion_signing_alg"
|
||||
>
|
||||
<Input placeholder="RS256" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<Input {...bindControl<string | undefined>(field)} placeholder="RS256" className={fieldClassName} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Audience (optional)"
|
||||
|
|
@ -121,20 +158,32 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
}
|
||||
name="audience"
|
||||
>
|
||||
<Input placeholder="https://upstream.example.com" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="https://upstream.example.com"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Resource Indicator (optional)"
|
||||
tooltip="RFC 8707 resource indicator sent on leg 1. Separate from Audience, which is the RFC 8693 parameter."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "id_jag_resource"]}
|
||||
name="credentials.id_jag_resource"
|
||||
>
|
||||
<Input placeholder="https://upstream.example.com/mcp" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="https://upstream.example.com/mcp"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Subject Token Type (optional)"
|
||||
|
|
@ -143,14 +192,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
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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"]}
|
||||
name="credentials.scopes"
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[","]} placeholder="Add scopes" className="rounded-lg" size="large" />
|
||||
</Form.Item>
|
||||
{(field) => (
|
||||
<Select
|
||||
{...bindControl<string[] | undefined>(field)}
|
||||
mode="tags"
|
||||
tokenSeparators={[","]}
|
||||
placeholder="Add scopes"
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,10 +2,37 @@ import React from "react";
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Form } from "antd";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
MountedFormField,
|
||||
MountedFormProvider,
|
||||
useMountRegistry,
|
||||
type MountedFormValues,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
import MCPPermissionManagement from "./MCPPermissionManagement";
|
||||
|
||||
const Wrapper: React.FC<{ children: React.ReactNode; defaultValues: MountedFormValues; withAuthType?: boolean }> = ({
|
||||
children,
|
||||
defaultValues,
|
||||
withAuthType = false,
|
||||
}) => {
|
||||
const form = useForm<MountedFormValues>({ defaultValues });
|
||||
const registry = useMountRegistry();
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
{withAuthType && (
|
||||
<MountedFormField name="auth_type" bare>
|
||||
{(field) => <input type="hidden" value={String(field.value ?? "")} onChange={field.onChange} />}
|
||||
</MountedFormField>
|
||||
)}
|
||||
{children}
|
||||
</MountedFormProvider>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
availableAccessGroups: [],
|
||||
mcpServer: null,
|
||||
|
|
@ -24,22 +51,12 @@ 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>
|
||||
const renderWithForm = (props = {}) =>
|
||||
render(
|
||||
<Wrapper defaultValues={{ allow_all_keys: false }}>
|
||||
<MCPPermissionManagement {...defaultProps} {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
};
|
||||
|
||||
it("should default allow_all_keys switch to unchecked for new servers", async () => {
|
||||
renderWithForm();
|
||||
|
|
@ -51,27 +68,12 @@ 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>
|
||||
const renderWithInitialValues = (initialValues: Record<string, unknown>, props = {}) =>
|
||||
render(
|
||||
<Wrapper defaultValues={initialValues} withAuthType>
|
||||
<MCPPermissionManagement {...defaultProps} {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
};
|
||||
|
||||
it("shows only the oauth2 PKCE-delegation toggle for oauth2 servers", async () => {
|
||||
renderWithInitialValues({ allow_all_keys: false, auth_type: "oauth2" });
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
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 { useFormContext } from "react-hook-form";
|
||||
import { MCPServer, AUTH_TYPE } from "@/components/mcp_tools/types";
|
||||
import {
|
||||
MountedFormField,
|
||||
bindControl,
|
||||
useMountedFieldArray,
|
||||
useMountedFormContext,
|
||||
useMountedWatch,
|
||||
type MountedFormValues,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
const { Panel } = Collapse;
|
||||
|
||||
interface MCPPermissionManagementProps {
|
||||
|
|
@ -22,11 +31,13 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
setSearchValue,
|
||||
getAccessGroupOptions,
|
||||
}) => {
|
||||
const form = Form.useFormInstance();
|
||||
const watchedAuthType = Form.useWatch("auth_type", form);
|
||||
const form = useFormContext<MountedFormValues>();
|
||||
const { control } = useMountedFormContext();
|
||||
const staticHeaders = useMountedFieldArray(control, "static_headers");
|
||||
const watchedAuthType = useMountedWatch("auth_type");
|
||||
const isOAuth2 = watchedAuthType === AUTH_TYPE.OAUTH2;
|
||||
const isNoneAuth = watchedAuthType === AUTH_TYPE.NONE || watchedAuthType == null;
|
||||
const watchedExtraHeaders = Form.useWatch("extra_headers", form);
|
||||
const watchedExtraHeaders = useMountedWatch("extra_headers");
|
||||
const hasAuthorizationHeader =
|
||||
Array.isArray(watchedExtraHeaders) &&
|
||||
watchedExtraHeaders.some((h) => typeof h === "string" && h.toLowerCase() === "authorization");
|
||||
|
|
@ -39,22 +50,22 @@ 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 = useMountedWatch("delegate_auth_to_upstream");
|
||||
const watchedPublicInternet = useMountedWatch("available_on_public_internet");
|
||||
const showInternalDelegatePkceWarning = isOAuth2 && watchedDelegateAuth === true && watchedPublicInternet === false;
|
||||
|
||||
// Set initial values when mcpServer changes
|
||||
useEffect(() => {
|
||||
if (mcpServer) {
|
||||
if (mcpServer.static_headers) {
|
||||
const staticHeaders = Object.entries(mcpServer.static_headers).map(([header, value]) => ({
|
||||
const headerRows = Object.entries(mcpServer.static_headers).map(([header, value]) => ({
|
||||
header,
|
||||
value: value != null ? String(value) : "",
|
||||
}));
|
||||
form.setFieldValue("static_headers", staticHeaders);
|
||||
form.setValue("static_headers", headerRows);
|
||||
}
|
||||
if (Array.isArray(mcpServer.env_vars) && mcpServer.env_vars.length > 0) {
|
||||
form.setFieldValue(
|
||||
form.setValue(
|
||||
"env_vars",
|
||||
mcpServer.env_vars.map((entry) => ({
|
||||
name: entry.name,
|
||||
|
|
@ -65,22 +76,22 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
);
|
||||
}
|
||||
if (typeof mcpServer.allow_all_keys === "boolean") {
|
||||
form.setFieldValue("allow_all_keys", mcpServer.allow_all_keys);
|
||||
form.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);
|
||||
form.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);
|
||||
form.setValue("delegate_auth_to_upstream", mcpServer.delegate_auth_to_upstream);
|
||||
}
|
||||
if (typeof mcpServer.oauth_passthrough === "boolean") {
|
||||
form.setFieldValue("oauth_passthrough", mcpServer.oauth_passthrough);
|
||||
form.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);
|
||||
form.setValue("allow_all_keys", false);
|
||||
form.setValue("available_on_public_internet", true);
|
||||
form.setValue("delegate_auth_to_upstream", false);
|
||||
form.setValue("oauth_passthrough", false);
|
||||
}
|
||||
}, [mcpServer, form]);
|
||||
|
||||
|
|
@ -89,7 +100,7 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
// stale toggle value doesn't get persisted unexpectedly.
|
||||
useEffect(() => {
|
||||
if (!isOAuth2) {
|
||||
form.setFieldValue("delegate_auth_to_upstream", false);
|
||||
form.setValue("delegate_auth_to_upstream", false);
|
||||
}
|
||||
}, [isOAuth2, form]);
|
||||
|
||||
|
|
@ -97,7 +108,7 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
// Authorization upstream. Force it back to false otherwise.
|
||||
useEffect(() => {
|
||||
if (!canEnableOAuthPassthrough) {
|
||||
form.setFieldValue("oauth_passthrough", false);
|
||||
form.setValue("oauth_passthrough", false);
|
||||
}
|
||||
}, [canEnableOAuthPassthrough, form]);
|
||||
|
||||
|
|
@ -130,14 +141,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">
|
||||
{(field) => <Switch id={field.id} checked={Boolean(field.value)} onChange={field.onChange} />}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
|
|
@ -152,16 +158,11 @@ 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">
|
||||
{(field) => (
|
||||
<Switch id={field.id} checked={!field.value} onChange={(checked) => field.onChange(!checked)} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
|
||||
{isOAuth2 && (
|
||||
|
|
@ -177,14 +178,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>
|
||||
{(field) => <Switch id={field.id} checked={Boolean(field.value)} onChange={field.onChange} />}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -202,14 +202,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>
|
||||
{(field) => <Switch id={field.id} checked={Boolean(field.value)} onChange={field.onChange} />}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -223,7 +222,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 +234,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>
|
||||
{(field) => (
|
||||
<Select
|
||||
{...bindControl<string[] | undefined>(field)}
|
||||
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 +267,78 @@ 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>
|
||||
{(field) => (
|
||||
<Select
|
||||
{...bindControl<string[] | undefined>(field)}
|
||||
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={
|
||||
<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" }]}
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 flex items-center mb-2">
|
||||
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>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{staticHeaders.fields.map((row, index) => (
|
||||
<Space key={row.id} className="flex w-full" align="baseline" size="middle">
|
||||
<div className="flex-1">
|
||||
<MountedFormField
|
||||
name={`static_headers.${index}.header`}
|
||||
required
|
||||
rules={{ required: "Header name is required" }}
|
||||
>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<MountedFormField
|
||||
name={`static_headers.${index}.value`}
|
||||
required
|
||||
rules={{ required: "Header value is required" }}
|
||||
>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
size="large"
|
||||
allowClear
|
||||
className="rounded-lg"
|
||||
placeholder="Header value"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
<MinusCircleOutlined
|
||||
onClick={() => staticHeaders.remove(index)}
|
||||
className="text-gray-500 hover:text-red-500 cursor-pointer"
|
||||
/>
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => staticHeaders.append({})} icon={<PlusOutlined />} block>
|
||||
Add Static Header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
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 { FormProvider, useForm } from "react-hook-form";
|
||||
import {
|
||||
MountedFormProvider,
|
||||
projectMountedValues,
|
||||
useMountRegistry,
|
||||
type MountedFormValues,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
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();
|
||||
const form = useForm<MountedFormValues>({ defaultValues: {} });
|
||||
const registry = useMountRegistry();
|
||||
return (
|
||||
<Form form={form} onFinish={onFinish}>
|
||||
{children}
|
||||
<button type="submit">Submit</button>
|
||||
</Form>
|
||||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
<form onSubmit={form.handleSubmit((store) => onFinish?.(projectMountedValues(registry, store)))}>
|
||||
{children}
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MountedFormProvider>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
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, bindControl } from "@/components/common_components/MountedFormField";
|
||||
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
|
||||
|
||||
interface OAuthFlowStatus {
|
||||
|
|
@ -41,12 +42,19 @@ 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"]}
|
||||
name="credentials.upstream_resource"
|
||||
>
|
||||
<Input placeholder="auto, or https://mcp.example.com/mcp" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
value={(field.value as string | undefined) ?? ""}
|
||||
placeholder="auto, or https://mcp.example.com/mcp"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
);
|
||||
|
||||
const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
||||
|
|
@ -57,11 +65,11 @@ 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 ? {} : { required: message });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="OAuth Flow Type"
|
||||
|
|
@ -69,69 +77,104 @@ 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>
|
||||
{(field) => (
|
||||
<Select
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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"]}
|
||||
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
|
||||
{(field) => (
|
||||
<AntdInput.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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"]}
|
||||
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
|
||||
{(field) => (
|
||||
<AntdInput.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
value={(field.value as string | undefined) ?? ""}
|
||||
placeholder="https://auth.example.com/oauth/token"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<TokenEndpointAuthMethodField isEditing={isEditing} />
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Scopes (optional)"
|
||||
tooltip="Optional scopes to request with the client_credentials grant."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "scopes"]}
|
||||
name="credentials.scopes"
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[","]} placeholder="Add scopes" className="rounded-lg" size="large" />
|
||||
</Form.Item>
|
||||
{(field) => (
|
||||
<Select
|
||||
{...bindControl<string[] | undefined>(field)}
|
||||
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
|
||||
|
|
@ -151,34 +194,55 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
)}
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "client_id"]}
|
||||
name="credentials.client_id"
|
||||
>
|
||||
<AntdInput.Password placeholder={`Enter client ID${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<AntdInput.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder={`Enter client ID${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Secret (optional)"
|
||||
tooltip="Provide only if your MCP server cannot handle dynamic client registration."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
name="credentials.client_secret"
|
||||
>
|
||||
<AntdInput.Password placeholder={`Enter client secret${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<AntdInput.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder={`Enter client secret${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Scopes (optional)"
|
||||
tooltip="Optional scopes requested during token exchange. Separate multiple scopes with enter or commas."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "scopes"]}
|
||||
name="credentials.scopes"
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[","]} placeholder="Add scopes" className="rounded-lg" size="large" />
|
||||
</Form.Item>
|
||||
{(field) => (
|
||||
<Select
|
||||
{...bindControl<string[] | undefined>(field)}
|
||||
mode="tags"
|
||||
tokenSeparators={[","]}
|
||||
placeholder="Add scopes"
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<UpstreamResourceField />
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Issuer (optional)"
|
||||
|
|
@ -187,9 +251,16 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
}
|
||||
name="issuer"
|
||||
>
|
||||
<Input placeholder="https://issuer.example.com" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
value={(field.value as string | undefined) ?? ""}
|
||||
placeholder="https://issuer.example.com"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Authorization URL (optional)"
|
||||
|
|
@ -198,16 +269,30 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
}
|
||||
name="authorization_url"
|
||||
>
|
||||
<Input placeholder="https://example.com/oauth/authorize" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
value={(field.value as string | undefined) ?? ""}
|
||||
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>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
value={(field.value as string | undefined) ?? ""}
|
||||
placeholder="https://example.com/oauth/token"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<TokenEndpointAuthMethodField isEditing={isEditing} />
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Registration URL (optional)"
|
||||
|
|
@ -216,9 +301,16 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
}
|
||||
name="registration_url"
|
||||
>
|
||||
<Input placeholder="https://example.com/oauth/register" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
value={(field.value as string | undefined) ?? ""}
|
||||
placeholder="https://example.com/oauth/register"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Token Validation Rules (optional)"
|
||||
|
|
@ -226,27 +318,28 @@ 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: (value) => {
|
||||
if (typeof value !== "string" || value.trim() === "") return true;
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return "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
|
||||
{(field) => (
|
||||
<AntdInput.TextArea
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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 +348,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>
|
||||
{(field) => (
|
||||
<InputNumber
|
||||
{...bindControl<number | null>(field)}
|
||||
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,20 @@
|
|||
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 { UseFormReturn } from "react-hook-form";
|
||||
import { AUTH_TYPE, OAUTH_FLOW } from "@/components/mcp_tools/types";
|
||||
import {
|
||||
MountedFormField,
|
||||
applyFieldValues,
|
||||
bindControl,
|
||||
resetFieldsToDefaults,
|
||||
type MountedFormValues,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
import OpenAPIQuickPicker, { OpenAPIRegistryEntry, OpenAPIKeyTool } from "./OpenAPIQuickPicker";
|
||||
|
||||
interface OpenAPIFormSectionProps {
|
||||
form: FormInstance;
|
||||
form: UseFormReturn<MountedFormValues>;
|
||||
defaultValues: MountedFormValues;
|
||||
accessToken: string | null;
|
||||
/** Called when a preset is selected so the parent can sync its formValues state. */
|
||||
onValuesChange: (updates: Record<string, any>) => void;
|
||||
|
|
@ -25,6 +33,7 @@ interface OpenAPIFormSectionProps {
|
|||
*/
|
||||
const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
|
||||
form,
|
||||
defaultValues,
|
||||
accessToken,
|
||||
onValuesChange,
|
||||
onKeyToolsChange,
|
||||
|
|
@ -47,13 +56,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);
|
||||
applyFieldValues(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);
|
||||
resetFieldsToDefaults(form, defaultValues, ["auth_type", "authorization_url", "token_url"]);
|
||||
applyFieldValues(form, updates);
|
||||
onOAuthDocsUrlChange?.(null);
|
||||
}
|
||||
onValuesChange(updates);
|
||||
|
|
@ -63,7 +70,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 +80,25 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
|
|||
</span>
|
||||
}
|
||||
name="spec_path"
|
||||
rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]}
|
||||
required
|
||||
rules={{ required: "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>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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) => {
|
||||
// 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);
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,91 +1,98 @@
|
|||
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";
|
||||
import { MountedFormField, bindControl, useMountedFormContext } from "@/components/common_components/MountedFormField";
|
||||
|
||||
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>
|
||||
const OpenApiByokFields: React.FC = () => {
|
||||
const { control } = useMountedFormContext();
|
||||
const isByok = useWatch({ control, name: "is_byok" });
|
||||
const authType = useWatch({ control, name: "auth_type" }) as string | undefined;
|
||||
|
||||
<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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
{(field) => <Switch id={field.id} checked={Boolean(field.value)} onChange={field.onChange} />}
|
||||
</MountedFormField>
|
||||
|
||||
{isByok ? (
|
||||
<>
|
||||
{/* Auth format hint */}
|
||||
{authType && authType !== "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">
|
||||
{authType === "bearer_token" && "Authorization: Bearer {key}"}
|
||||
{authType === "token" && "Authorization: token {key}"}
|
||||
{authType === "api_key" && "x-api-key: {key}"}
|
||||
{authType === "basic" && "Authorization: Basic {key}"}
|
||||
{authType === "authorization" && "Authorization: {key}"}
|
||||
</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"
|
||||
>
|
||||
{(field) => (
|
||||
<Select
|
||||
{...bindControl<string[] | undefined>(field)}
|
||||
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"
|
||||
>
|
||||
{(field) => (
|
||||
<Input {...bindControl<string | undefined>(field)} placeholder="https://docs.example.com/api-keys" />
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenApiByokFields;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Form } from "antd";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import {
|
||||
MountedFormProvider,
|
||||
useMountRegistry,
|
||||
type MountedFormValues,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
|
||||
|
||||
const WithForm: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [form] = Form.useForm();
|
||||
return <Form form={form}>{children}</Form>;
|
||||
const form = useForm<MountedFormValues>({ defaultValues: {} });
|
||||
const registry = useMountRegistry();
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>{children}</MountedFormProvider>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const noopFlow = { startOAuthFlow: () => {}, status: "idle", error: null, tokenResponse: null };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { Button, Checkbox, Form, Input } from "antd";
|
||||
import { Button, Checkbox, Input } from "antd";
|
||||
import { MountedFormField, bindControl } from "@/components/common_components/MountedFormField";
|
||||
import DcrBridgeToggle from "./DcrBridgeToggle";
|
||||
import { credentialAuthClass, isClientForwardedTokenMode } from "@/components/mcp_tools/types";
|
||||
|
||||
|
|
@ -81,27 +82,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}
|
||||
name="credentials.client_id"
|
||||
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
|
||||
{(field) => (
|
||||
<Input.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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"]}
|
||||
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>
|
||||
{(field) => (
|
||||
<Input.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { Form, Input, Tooltip } from "antd";
|
||||
import { Input, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { MountedFormField, bindControl } from "@/components/common_components/MountedFormField";
|
||||
|
||||
interface StdioConfigurationProps {
|
||||
isVisible: boolean;
|
||||
|
|
@ -15,7 +16,7 @@ const StdioConfiguration: React.FC<StdioConfigurationProps> = ({ isVisible, requ
|
|||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Stdio Configuration (JSON)
|
||||
|
|
@ -25,23 +26,23 @@ const StdioConfiguration: React.FC<StdioConfigurationProps> = ({ isVisible, requ
|
|||
</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");
|
||||
}
|
||||
},
|
||||
required={required}
|
||||
rules={{
|
||||
validate: (value) => {
|
||||
if (!value) return required ? "Please enter stdio configuration" : true;
|
||||
try {
|
||||
JSON.parse(String(value));
|
||||
return true;
|
||||
} catch {
|
||||
return "Please enter valid JSON";
|
||||
}
|
||||
},
|
||||
]}
|
||||
}}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={`{
|
||||
{(field) => (
|
||||
<Input.TextArea
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder={`{
|
||||
"mcpServers": {
|
||||
"circleci-mcp-server": {
|
||||
"command": "npx",
|
||||
|
|
@ -53,10 +54,11 @@ 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>
|
||||
rows={12}
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { Form, Select, Tooltip } from "antd";
|
||||
import { Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { MountedFormField, bindControl } from "@/components/common_components/MountedFormField";
|
||||
|
||||
const TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS = [
|
||||
{ value: "client_secret_basic", label: "Client Secret Basic" },
|
||||
|
|
@ -12,7 +13,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)
|
||||
|
|
@ -21,18 +22,21 @@ const TokenEndpointAuthMethodField: React.FC<TokenEndpointAuthMethodFieldProps>
|
|||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "token_endpoint_auth_method"]}
|
||||
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>
|
||||
{(field) => (
|
||||
<Select
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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,8 @@
|
|||
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, bindControl, useMountedFormContext } from "@/components/common_components/MountedFormField";
|
||||
|
||||
interface TokenExchangeFormFieldsProps {
|
||||
isEditing?: boolean;
|
||||
|
|
@ -19,10 +21,12 @@ 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 { control } = useMountedFormContext();
|
||||
const isEntraObo = useWatch({ control, name: "token_exchange_profile" }) === "entra_obo";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Profile"
|
||||
|
|
@ -30,18 +34,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
|
||||
{(field) => (
|
||||
<Select {...bindControl<string | undefined>(field)} 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,98 +56,116 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
}
|
||||
name="token_exchange_endpoint"
|
||||
>
|
||||
<Input placeholder="https://idp.example.com/oauth2/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder="https://idp.example.com/oauth2/token"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client ID"
|
||||
tooltip="OAuth2 client ID used to authenticate to the token exchange endpoint."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_id"]}
|
||||
rules={[{ required: !isEditing, message: "Client ID is required for token exchange" }]}
|
||||
name="credentials.client_id"
|
||||
required={!isEditing}
|
||||
rules={isEditing ? {} : { required: "Client ID is required for token exchange" }}
|
||||
>
|
||||
<Input.Password placeholder={`Enter OAuth client ID${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{(field) => (
|
||||
<Input.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
placeholder={`Enter OAuth client ID${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Secret"
|
||||
tooltip="OAuth2 client secret used to authenticate to the token exchange endpoint."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
rules={[{ required: !isEditing, message: "Client Secret is required for token exchange" }]}
|
||||
name="credentials.client_secret"
|
||||
required={!isEditing}
|
||||
rules={isEditing ? {} : { required: "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>
|
||||
{(field) => (
|
||||
<Input.Password
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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"
|
||||
>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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"
|
||||
>
|
||||
{(field) => (
|
||||
<Input
|
||||
{...bindControl<string | undefined>(field)}
|
||||
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 ? { required: "Microsoft Entra OBO requires a scope, e.g. api://<app-id>/.default" } : {}}
|
||||
>
|
||||
{(field) => (
|
||||
<Select
|
||||
{...bindControl<string[] | undefined>(field)}
|
||||
mode="tags"
|
||||
tokenSeparators={[","]}
|
||||
placeholder={isEntraObo ? "api://<app-id>/.default" : "Add scopes"}
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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") ??
|
||||
|
|
|
|||
|
|
@ -54,6 +54,18 @@ export const validateMCPServerName = (value: string) => {
|
|||
: Promise.resolve();
|
||||
};
|
||||
|
||||
export const antdValidator = async (
|
||||
validate: (value: string) => Promise<unknown>,
|
||||
value: unknown,
|
||||
): Promise<true | string> => {
|
||||
try {
|
||||
await validate(value as string);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
return reason instanceof Error ? reason.message : String(reason);
|
||||
}
|
||||
};
|
||||
|
||||
export const TOOL_DISPLAY_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export const validateToolDisplayName = (value: string) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
import React from "react";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { useForm, type UseFormReturn } from "react-hook-form";
|
||||
|
||||
import {
|
||||
MountedFormField,
|
||||
MountedFormProvider,
|
||||
applyFieldValues,
|
||||
changedValuesFor,
|
||||
projectMountedValues,
|
||||
resetFieldsToDefaults,
|
||||
useMountRegistry,
|
||||
useMountedWatch,
|
||||
type MountRegistry,
|
||||
type MountedFormValues,
|
||||
} from "./MountedFormField";
|
||||
|
||||
const harness: {
|
||||
form?: UseFormReturn<MountedFormValues>;
|
||||
registry?: MountRegistry;
|
||||
} = {};
|
||||
|
||||
interface HarnessProps {
|
||||
readonly defaultValues: MountedFormValues;
|
||||
readonly showGated?: boolean;
|
||||
readonly showNested?: boolean;
|
||||
readonly showRows?: number;
|
||||
readonly duplicateGated?: boolean;
|
||||
}
|
||||
|
||||
const Watcher: React.FC = () => {
|
||||
const gated = useMountedWatch("gated");
|
||||
const credentials = useMountedWatch("credentials");
|
||||
const rows = useMountedWatch("rows");
|
||||
return (
|
||||
<>
|
||||
<div data-testid="watch-gated">{JSON.stringify(gated) ?? "undefined"}</div>
|
||||
<div data-testid="watch-credentials">{JSON.stringify(credentials) ?? "undefined"}</div>
|
||||
<div data-testid="watch-rows">{JSON.stringify(rows) ?? "undefined"}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Harness: React.FC<HarnessProps> = ({
|
||||
defaultValues,
|
||||
showGated = true,
|
||||
showNested = true,
|
||||
showRows = 0,
|
||||
duplicateGated = false,
|
||||
}) => {
|
||||
const form = useForm<MountedFormValues>({ defaultValues });
|
||||
const registry = useMountRegistry();
|
||||
React.useEffect(() => {
|
||||
harness.form = form;
|
||||
harness.registry = registry;
|
||||
}, [form, registry]);
|
||||
return (
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
<Watcher />
|
||||
<MountedFormField name="always" label="Always">
|
||||
{(field) => (
|
||||
<input aria-label="always" value={String(field.value ?? "")} onChange={field.onChange} id={field.id} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
{showGated && (
|
||||
<MountedFormField name="gated" label="Gated">
|
||||
{(field) => (
|
||||
<input aria-label="gated" value={String(field.value ?? "")} onChange={field.onChange} id={field.id} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
)}
|
||||
{duplicateGated && (
|
||||
<MountedFormField name="gated" label="Gated elsewhere">
|
||||
{(field) => (
|
||||
<input aria-label="gated-2" value={String(field.value ?? "")} onChange={field.onChange} id={field.id} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
)}
|
||||
{showNested && (
|
||||
<MountedFormField name="credentials.client_id" label="Client id">
|
||||
{(field) => (
|
||||
<input aria-label="client_id" value={String(field.value ?? "")} onChange={field.onChange} id={field.id} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
)}
|
||||
{Array.from({ length: showRows }, (_, index) => (
|
||||
<MountedFormField key={index} name={`rows.${index}.header`} label={`Header ${index}`}>
|
||||
{(field) => (
|
||||
<input
|
||||
aria-label={`header-${index}`}
|
||||
value={String(field.value ?? "")}
|
||||
onChange={field.onChange}
|
||||
id={field.id}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
))}
|
||||
</MountedFormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const DEFAULTS: MountedFormValues = {
|
||||
always: "a",
|
||||
gated: "seeded",
|
||||
credentials: { client_id: "cid", access_token: "tok" },
|
||||
rows: [{ header: "h0" }, { header: "h1" }],
|
||||
never_bound: "leaked",
|
||||
};
|
||||
|
||||
describe("projectMountedValues", () => {
|
||||
it("drops store keys that no field mounted, which is what keeps a spread payload out of the request", () => {
|
||||
render(<Harness defaultValues={DEFAULTS} />);
|
||||
const projected = projectMountedValues(harness.registry!, harness.form!.getValues());
|
||||
expect(projected).not.toHaveProperty("never_bound");
|
||||
expect(harness.form!.getValues()).toHaveProperty("never_bound", "leaked");
|
||||
});
|
||||
|
||||
it("rebuilds a container from only its mounted descendants", () => {
|
||||
render(<Harness defaultValues={DEFAULTS} />);
|
||||
const projected = projectMountedValues(harness.registry!, harness.form!.getValues());
|
||||
expect(projected.credentials).toStrictEqual({ client_id: "cid" });
|
||||
});
|
||||
|
||||
it("rebuilds an indexed path as an array, not an object", () => {
|
||||
render(<Harness defaultValues={DEFAULTS} showRows={2} />);
|
||||
const projected = projectMountedValues(harness.registry!, harness.form!.getValues());
|
||||
expect(projected.rows).toStrictEqual([{ header: "h0" }, { header: "h1" }]);
|
||||
});
|
||||
|
||||
it("keeps a name mounted while a second field still binds it, so a shared name is not dropped early", () => {
|
||||
const { rerender } = render(<Harness defaultValues={DEFAULTS} duplicateGated />);
|
||||
rerender(<Harness defaultValues={DEFAULTS} duplicateGated showGated={false} />);
|
||||
expect(projectMountedValues(harness.registry!, harness.form!.getValues())).toHaveProperty("gated", "seeded");
|
||||
});
|
||||
|
||||
it("accepts a getValues function as well as a plain store", () => {
|
||||
render(<Harness defaultValues={DEFAULTS} />);
|
||||
expect(projectMountedValues(harness.registry!, harness.form!.getValues)).toStrictEqual(
|
||||
projectMountedValues(harness.registry!, harness.form!.getValues()),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits a gated field once it unmounts", () => {
|
||||
const { rerender } = render(<Harness defaultValues={DEFAULTS} />);
|
||||
expect(projectMountedValues(harness.registry!, harness.form!.getValues())).toHaveProperty("gated");
|
||||
rerender(<Harness defaultValues={DEFAULTS} showGated={false} />);
|
||||
expect(projectMountedValues(harness.registry!, harness.form!.getValues())).not.toHaveProperty("gated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMountedWatch", () => {
|
||||
it("is undefined for a seeded field that never mounted, so a `watched ?? saved` fallback still reads the saved value", () => {
|
||||
render(<Harness defaultValues={DEFAULTS} showGated={false} />);
|
||||
expect(screen.getByTestId("watch-gated")).toHaveTextContent("undefined");
|
||||
});
|
||||
|
||||
it("reports the live value while the field is mounted", async () => {
|
||||
render(<Harness defaultValues={DEFAULTS} />);
|
||||
await userEvent.clear(screen.getByLabelText("gated"));
|
||||
await userEvent.type(screen.getByLabelText("gated"), "typed");
|
||||
expect(screen.getByTestId("watch-gated")).toHaveTextContent('"typed"');
|
||||
});
|
||||
|
||||
it("goes back to undefined after the field unmounts even though the store keeps the value", async () => {
|
||||
const { rerender } = render(<Harness defaultValues={DEFAULTS} />);
|
||||
await userEvent.clear(screen.getByLabelText("gated"));
|
||||
await userEvent.type(screen.getByLabelText("gated"), "typed");
|
||||
rerender(<Harness defaultValues={DEFAULTS} showGated={false} />);
|
||||
expect(screen.getByTestId("watch-gated")).toHaveTextContent("undefined");
|
||||
expect(harness.form!.getValues("gated")).toBe("typed");
|
||||
});
|
||||
|
||||
it("narrows a container to its mounted descendants", () => {
|
||||
render(<Harness defaultValues={DEFAULTS} />);
|
||||
expect(screen.getByTestId("watch-credentials")).toHaveTextContent('{"client_id":"cid"}');
|
||||
});
|
||||
|
||||
it("is undefined for a container whose descendants all unmounted", () => {
|
||||
render(<Harness defaultValues={DEFAULTS} showNested={false} />);
|
||||
expect(screen.getByTestId("watch-credentials")).toHaveTextContent("undefined");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyFieldValues", () => {
|
||||
it("deep-merges a partial object instead of replacing it", () => {
|
||||
render(<Harness defaultValues={DEFAULTS} />);
|
||||
act(() => applyFieldValues(harness.form!, { credentials: { client_id: "next" } }));
|
||||
expect(harness.form!.getValues("credentials")).toStrictEqual({ client_id: "next", access_token: "tok" });
|
||||
});
|
||||
|
||||
it("replaces arrays rather than merging them index by index", () => {
|
||||
render(<Harness defaultValues={DEFAULTS} />);
|
||||
act(() => applyFieldValues(harness.form!, { rows: [{ header: "only" }] }));
|
||||
expect(harness.form!.getValues("rows")).toStrictEqual([{ header: "only" }]);
|
||||
});
|
||||
|
||||
it("clears a key when the patch carries undefined", () => {
|
||||
render(<Harness defaultValues={DEFAULTS} />);
|
||||
act(() => applyFieldValues(harness.form!, { credentials: undefined }));
|
||||
expect(harness.form!.getValues("credentials")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetFieldsToDefaults", () => {
|
||||
it("restores a container path that has no field registered under that exact name", () => {
|
||||
render(<Harness defaultValues={DEFAULTS} />);
|
||||
act(() => harness.form!.setValue("credentials", { client_id: "dirty", minted: "token" }));
|
||||
act(() => resetFieldsToDefaults(harness.form!, DEFAULTS, ["credentials"]));
|
||||
expect(harness.form!.getValues("credentials")).toStrictEqual({ client_id: "cid", access_token: "tok" });
|
||||
});
|
||||
|
||||
it("restores a path whose field is not mounted at all", () => {
|
||||
render(<Harness defaultValues={DEFAULTS} showGated={false} />);
|
||||
act(() => harness.form!.setValue("gated", "dirty"));
|
||||
act(() => resetFieldsToDefaults(harness.form!, DEFAULTS, ["gated"]));
|
||||
expect(harness.form!.getValues("gated")).toBe("seeded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("changedValuesFor", () => {
|
||||
it("nests a dotted path the way an antd onValuesChange payload is shaped", () => {
|
||||
expect(changedValuesFor("credentials.client_id", { credentials: { client_id: "x", other: "y" } })).toStrictEqual({
|
||||
credentials: { client_id: "x" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a top-level key flat so `key in changedValues` still answers", () => {
|
||||
expect("url" in changedValuesFor("url", { url: "https://example.com" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Controller,
|
||||
useFieldArray,
|
||||
useWatch,
|
||||
type Control,
|
||||
type ControllerProps,
|
||||
type RegisterOptions,
|
||||
type UseFormGetValues,
|
||||
type UseFormReturn,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/shared/form/field";
|
||||
|
||||
export type MountedFormValues = Record<string, unknown>;
|
||||
|
||||
export interface MountRegistry {
|
||||
readonly register: (name: string) => () => void;
|
||||
readonly mountedNames: () => readonly string[];
|
||||
readonly subscribe: (listener: () => void) => () => void;
|
||||
readonly version: () => number;
|
||||
}
|
||||
|
||||
export interface MountedFormContextValue {
|
||||
readonly control: Control<MountedFormValues>;
|
||||
readonly registry: MountRegistry;
|
||||
}
|
||||
|
||||
const missingProvider = (): never => {
|
||||
throw new Error("MountedFormField requires a MountedFormProvider ancestor");
|
||||
};
|
||||
|
||||
const MountedFormContext = React.createContext<MountedFormContextValue>({
|
||||
get control(): Control<MountedFormValues> {
|
||||
return missingProvider();
|
||||
},
|
||||
registry: {
|
||||
register: missingProvider,
|
||||
mountedNames: missingProvider,
|
||||
subscribe: missingProvider,
|
||||
version: missingProvider,
|
||||
},
|
||||
});
|
||||
|
||||
export const MountedFormProvider = MountedFormContext.Provider;
|
||||
|
||||
export const useMountedFormContext = (): MountedFormContextValue => React.useContext(MountedFormContext);
|
||||
|
||||
export const useMountRegistry = (): MountRegistry => {
|
||||
const counts = React.useRef<Map<string, number>>(new Map());
|
||||
const listeners = React.useRef<Set<() => void>>(new Set());
|
||||
const version = React.useRef(0);
|
||||
return React.useMemo(() => {
|
||||
const bump = () => {
|
||||
version.current += 1;
|
||||
listeners.current.forEach((listener) => listener());
|
||||
};
|
||||
return {
|
||||
register: (name: string) => {
|
||||
const before = counts.current.get(name) ?? 0;
|
||||
counts.current.set(name, before + 1);
|
||||
if (before === 0) bump();
|
||||
return () => {
|
||||
const remaining = (counts.current.get(name) ?? 0) - 1;
|
||||
if (remaining > 0) {
|
||||
counts.current.set(name, remaining);
|
||||
return;
|
||||
}
|
||||
counts.current.delete(name);
|
||||
bump();
|
||||
};
|
||||
},
|
||||
mountedNames: () => Array.from(counts.current.keys()),
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.current.add(listener);
|
||||
return () => {
|
||||
listeners.current.delete(listener);
|
||||
};
|
||||
},
|
||||
version: () => version.current,
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
const isIndexSegment = (segment: string): boolean => /^\d+$/.test(segment);
|
||||
|
||||
const readPath = (source: unknown, path: readonly string[]): unknown =>
|
||||
path.reduce<unknown>(
|
||||
(value, segment) =>
|
||||
value === null || value === undefined ? undefined : (value as Record<string, unknown>)[segment],
|
||||
source,
|
||||
);
|
||||
|
||||
const cloneContainer = (target: unknown, head: string): Record<string, unknown> | unknown[] => {
|
||||
if (Array.isArray(target)) return [...target];
|
||||
if (target !== null && typeof target === "object") return { ...(target as Record<string, unknown>) };
|
||||
return isIndexSegment(head) ? [] : {};
|
||||
};
|
||||
|
||||
const writePath = (target: unknown, path: readonly string[], value: unknown): unknown => {
|
||||
const [head, ...rest] = path;
|
||||
const container = cloneContainer(target, head);
|
||||
const next = rest.length === 0 ? value : writePath(readPath(container, [head]), rest, value);
|
||||
if (Array.isArray(container)) {
|
||||
const copy = [...container];
|
||||
copy[Number(head)] = next;
|
||||
return copy;
|
||||
}
|
||||
return { ...container, [head]: next };
|
||||
};
|
||||
|
||||
const collectPaths = (store: unknown, paths: readonly string[], seed: unknown): unknown =>
|
||||
paths.reduce<unknown>((acc, path) => {
|
||||
const segments = path.split(".");
|
||||
return writePath(acc, segments, readPath(store, segments));
|
||||
}, seed);
|
||||
|
||||
export const projectMountedValues = (
|
||||
registry: MountRegistry,
|
||||
source: MountedFormValues | UseFormGetValues<MountedFormValues>,
|
||||
): MountedFormValues =>
|
||||
collectPaths(typeof source === "function" ? source() : source, registry.mountedNames(), {}) as MountedFormValues;
|
||||
|
||||
export const changedValuesFor = (name: string, store: MountedFormValues): MountedFormValues =>
|
||||
collectPaths(store, [name], {}) as MountedFormValues;
|
||||
|
||||
const projectSubtree = (mountedNames: readonly string[], name: string, subtree: unknown): unknown => {
|
||||
if (mountedNames.includes(name)) {
|
||||
return subtree;
|
||||
}
|
||||
const prefix = `${name}.`;
|
||||
const relative = mountedNames
|
||||
.filter((mounted) => mounted.startsWith(prefix))
|
||||
.map((mounted) => mounted.slice(prefix.length));
|
||||
return relative.length === 0 ? undefined : collectPaths(subtree, relative, undefined);
|
||||
};
|
||||
|
||||
const useMountedNames = (registry: MountRegistry): readonly string[] => {
|
||||
React.useSyncExternalStore(registry.subscribe, registry.version, registry.version);
|
||||
return registry.mountedNames();
|
||||
};
|
||||
|
||||
export const useMountedWatch = (name: string, context?: MountedFormContextValue): unknown => {
|
||||
const fallback = React.useContext(MountedFormContext);
|
||||
const { control, registry } = context ?? fallback;
|
||||
const mountedNames = useMountedNames(registry);
|
||||
const subtree = useWatch({ control, name });
|
||||
return React.useMemo(() => projectSubtree(mountedNames, name, subtree), [mountedNames, name, subtree]);
|
||||
};
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const mergeValues = (target: unknown, patch: unknown): unknown => {
|
||||
if (!isPlainObject(target) || !isPlainObject(patch)) {
|
||||
return patch;
|
||||
}
|
||||
return Object.entries(patch).reduce<Record<string, unknown>>(
|
||||
(acc, [key, value]) => ({ ...acc, [key]: mergeValues(target[key], value) }),
|
||||
{ ...target },
|
||||
);
|
||||
};
|
||||
|
||||
export const applyFieldValues = (form: UseFormReturn<MountedFormValues>, patch: MountedFormValues): void => {
|
||||
const current = form.getValues();
|
||||
Object.entries(patch).forEach(([key, value]) => {
|
||||
form.setValue(key, mergeValues(current[key], value));
|
||||
});
|
||||
};
|
||||
|
||||
export const resetFieldsToDefaults = (
|
||||
form: UseFormReturn<MountedFormValues>,
|
||||
defaultValues: MountedFormValues,
|
||||
names: readonly string[],
|
||||
): void => {
|
||||
names.forEach((name) => {
|
||||
form.setValue(name, readPath(defaultValues, name.split(".")));
|
||||
form.clearErrors(name);
|
||||
});
|
||||
};
|
||||
|
||||
export type MountedFieldControlProps = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly value: unknown;
|
||||
readonly onChange: (...event: unknown[]) => void;
|
||||
readonly onBlur: () => void;
|
||||
readonly "aria-required": "true" | undefined;
|
||||
readonly "aria-invalid": "true" | undefined;
|
||||
readonly "aria-describedby": string | undefined;
|
||||
};
|
||||
|
||||
export interface MountedFieldArray {
|
||||
readonly fields: readonly { readonly id: string }[];
|
||||
readonly append: (value: MountedFormValues) => void;
|
||||
readonly remove: (index: number) => void;
|
||||
}
|
||||
|
||||
export const useMountedFieldArray = (control: Control<MountedFormValues>, name: string): MountedFieldArray => {
|
||||
const { fields, append, remove } = useFieldArray({ control, name: name as never });
|
||||
return { fields, append: append as (value: MountedFormValues) => void, remove };
|
||||
};
|
||||
|
||||
export const bindControl = <TValue,>(
|
||||
control: MountedFieldControlProps,
|
||||
): Omit<MountedFieldControlProps, "value"> & {
|
||||
value: TValue;
|
||||
} => ({ ...control, value: control.value as TValue });
|
||||
|
||||
export interface MountedFormFieldProps {
|
||||
readonly name: string;
|
||||
readonly label?: React.ReactNode;
|
||||
readonly help?: React.ReactNode;
|
||||
readonly required?: boolean;
|
||||
readonly rules?: Omit<
|
||||
RegisterOptions<MountedFormValues, string>,
|
||||
"valueAsNumber" | "valueAsDate" | "setValueAs" | "disabled"
|
||||
>;
|
||||
readonly defaultValue?: unknown;
|
||||
readonly bare?: boolean;
|
||||
readonly className?: string;
|
||||
readonly children: (control: MountedFieldControlProps) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const MountedFormField: React.FC<MountedFormFieldProps> = ({
|
||||
name,
|
||||
label,
|
||||
help,
|
||||
required,
|
||||
rules,
|
||||
defaultValue,
|
||||
bare,
|
||||
className,
|
||||
children,
|
||||
}) => {
|
||||
const { control, registry } = useMountedFormContext();
|
||||
React.useEffect(() => registry.register(name), [registry, name]);
|
||||
|
||||
const helpId = `${name}_help`;
|
||||
const hasHelp = help !== undefined && help !== null;
|
||||
|
||||
const renderField: ControllerProps<MountedFormValues>["render"] = ({ field, fieldState }) => {
|
||||
const invalid = fieldState.error !== undefined;
|
||||
const controlProps: MountedFieldControlProps = {
|
||||
id: name,
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
onChange: field.onChange,
|
||||
onBlur: field.onBlur,
|
||||
"aria-required": required ? "true" : undefined,
|
||||
"aria-invalid": invalid ? "true" : undefined,
|
||||
"aria-describedby": hasHelp || invalid ? helpId : undefined,
|
||||
};
|
||||
|
||||
if (bare) {
|
||||
return <>{children(controlProps)}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Field data-invalid={invalid || undefined} className={className}>
|
||||
{label !== undefined && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
|
||||
{children(controlProps)}
|
||||
{hasHelp ? (
|
||||
<FieldDescription id={helpId}>{help}</FieldDescription>
|
||||
) : (
|
||||
<FieldError id={helpId} errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
return <Controller control={control} name={name} rules={rules} defaultValue={defaultValue} render={renderField} />;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue