diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx
index 073780b359f..ab6cceb9073 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx
@@ -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;
+ defaultValues: MountedFormValues;
accessToken: string | null;
/** Called when a preset is selected so the parent can sync its formValues state. */
onValuesChange: (updates: Record) => void;
@@ -25,6 +33,7 @@ interface OpenAPIFormSectionProps {
*/
const OpenAPIFormSection: React.FC = ({
form,
+ defaultValues,
accessToken,
onValuesChange,
onKeyToolsChange,
@@ -47,13 +56,11 @@ const OpenAPIFormSection: React.FC = ({
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 = ({
<>
-
OpenAPI Spec URL
@@ -73,20 +80,25 @@ const OpenAPIFormSection: React.FC = ({
}
name="spec_path"
- rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]}
+ required
+ rules={{ required: "Please enter an OpenAPI spec URL" }}
>
- {
- // 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) => (
+ (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);
+ }}
+ />
+ )}
+
>
);
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx
index 2ac4279e20a..a7e81b6bd3d 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx
@@ -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 = () => (
- <>
-
- BYOK (Bring Your Own Key)
-
-
-
-
- }
- name="is_byok"
- valuePropName="checked"
- >
-
-
+const OpenApiByokFields: React.FC = () => {
+ const { control } = useMountedFormContext();
+ const isByok = useWatch({ control, name: "is_byok" });
+ const authType = useWatch({ control, name: "auth_type" }) as string | undefined;
- 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" && (
-
-
-
- User keys will be sent as:{" "}
-
- {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}"}
-
- {!getFieldValue("auth_type") && "Set Authentication Type below to specify the format."}
-
-
- )}
- {!getFieldValue("auth_type") && (
-
-
-
- Set the Authentication Type below to specify how user keys are sent (e.g., Bearer
- Token, API Key header).
-
-
- )}
-
- Access Description
-
-
-
-
- }
- name="byok_description"
- >
+ return (
+ <>
+
+ BYOK (Bring Your Own Key)
+
+
+
+
+ }
+ name="is_byok"
+ >
+ {(field) => }
+
+
+ {isByok ? (
+ <>
+ {/* Auth format hint */}
+ {authType && authType !== "none" && (
+
+
+
+ User keys will be sent as:{" "}
+
+ {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}"}
+
+
+
+ )}
+ {!authType && (
+
+
+
+ Set the Authentication Type below to specify how user keys are sent (e.g., Bearer
+ Token, API Key header).
+
+
+ )}
+
+ Access Description
+
+
+
+
+ }
+ name="byok_description"
+ >
+ {(field) => (
(field)}
mode="tags"
placeholder="Add access description items (press Enter after each)"
className="w-full"
tokenSeparators={[","]}
/>
-
+ )}
+
-
- API Key Help URL
-
-
-
-
- }
- name="byok_api_key_help_url"
- >
-
-
- >
- ) : null
- }
-
- >
-);
+
+ API Key Help URL
+
+
+
+
+ }
+ name="byok_api_key_help_url"
+ >
+ {(field) => (
+ (field)} placeholder="https://docs.example.com/api-keys" />
+ )}
+
+ >
+ ) : null}
+ >
+ );
+};
export default OpenApiByokFields;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx
index 0a09ef3f856..38111840103 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx
@@ -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 ;
+ const form = useForm({ defaultValues: {} });
+ const registry = useMountRegistry();
+ return (
+
+ {children}
+
+ );
};
const noopFlow = { startOAuthFlow: () => {}, status: "idle", error: null, tokenResponse: null };
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx
index dc10f0f1392..9adf609eb2a 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx
@@ -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.
)}
-
OAuth Client ID (optional)}
- name={["credentials", "client_id"]}
- extra={clientIdExtra}
+ name="credentials.client_id"
+ help={clientIdExtra}
>
-
-
-
(
+ (field)}
+ placeholder={clientIdPlaceholder}
+ disabled={removeStoredApp}
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+ OAuth Client Secret (optional)}
- name={["credentials", "client_secret"]}
+ name="credentials.client_secret"
>
-
-
+ {(field) => (
+
(field)}
+ placeholder={clientSecretPlaceholder}
+ disabled={removeStoredApp}
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
{isEditing && onRemoveStoredAppChange && (
onRemoveStoredAppChange(e.target.checked)}>
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx
index 476a5b61683..47913b86e64 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx
@@ -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 = ({ isVisible, requ
if (!isVisible) return null;
return (
-
Stdio Configuration (JSON)
@@ -25,23 +26,23 @@ const StdioConfiguration: React.FC = ({ isVisible, requ
}
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";
+ }
},
- ]}
+ }}
>
- (
+ (field)}
+ placeholder={`{
"mcpServers": {
"circleci-mcp-server": {
"command": "npx",
@@ -53,10 +54,11 @@ const StdioConfiguration: React.FC = ({ isVisible, requ
}
}
}`}
- rows={12}
- className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm"
- />
-
+ rows={12}
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm"
+ />
+ )}
+
);
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx
index c97ce96bfb1..74cc1ce7d34 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx
@@ -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 = ({ isEditing = false }) => (
-
Token Endpoint Auth Method (optional)
@@ -21,18 +22,21 @@ const TokenEndpointAuthMethodField: React.FC
}
- name={["credentials", "token_endpoint_auth_method"]}
+ name="credentials.token_endpoint_auth_method"
>
-
-
+ {(field) => (
+ (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}
+ />
+ )}
+
);
export default TokenEndpointAuthMethodField;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx
index 9e1e1a85743..cc8eeeb3bd8 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx
@@ -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 = ({ isEditing = false }) => {
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
+ const { control } = useMountedFormContext();
+ const isEntraObo = useWatch({ control, name: "token_exchange_profile" }) === "entra_obo";
return (
<>
- = ({ isEdi
/>
}
name="token_exchange_profile"
- {...(isEditing ? {} : { initialValue: "rfc8693" })}
+ {...(isEditing ? {} : { defaultValue: "rfc8693" })}
>
-
-
- RFC 8693 (standard)
-
-
- Microsoft Entra OBO
-
-
-
- (
+ (field)} className="rounded-lg" size="large">
+
+ RFC 8693 (standard)
+
+
+ Microsoft Entra OBO
+
+
+ )}
+
+ = ({ isEdi
}
name="token_exchange_endpoint"
>
-
-
- (
+ (field)}
+ placeholder="https://idp.example.com/oauth2/token"
+ className={fieldClassName}
+ />
+ )}
+
+
}
- 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" }}
>
-
-
- (
+ (field)}
+ placeholder={`Enter OAuth client ID${placeholderSuffix}`}
+ className={fieldClassName}
+ />
+ )}
+
+
}
- 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" }}
>
-
-
- prev.token_exchange_profile !== cur.token_exchange_profile}>
- {({ getFieldValue }) => {
- const isEntraObo = getFieldValue("token_exchange_profile") === "entra_obo";
- return (
- <>
- {!isEntraObo && (
- <>
-
- }
- name="audience"
- >
-
-
-
- }
- name="subject_token_type"
- >
-
-
- >
- )}
- /.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:///.default",
- },
- ]
- : []
- }
- >
- /.default" : "Add scopes"}
- className="rounded-lg"
- size="large"
- />
-
- >
- );
- }}
-
+ {(field) => (
+ (field)}
+ placeholder={`Enter OAuth client secret${placeholderSuffix}`}
+ className={fieldClassName}
+ />
+ )}
+
+ {!isEntraObo && (
+ <>
+
+ }
+ name="audience"
+ >
+ {(field) => (
+ (field)}
+ placeholder="https://upstream.example.com"
+ className={fieldClassName}
+ />
+ )}
+
+
+ }
+ name="subject_token_type"
+ >
+ {(field) => (
+ (field)}
+ placeholder="urn:ietf:params:oauth:token-type:access_token"
+ className={fieldClassName}
+ />
+ )}
+
+ >
+ )}
+ /.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:///.default" } : {}}
+ >
+ {(field) => (
+ (field)}
+ mode="tags"
+ tokenSeparators={[","]}
+ placeholder={isEntraObo ? "api:///.default" : "Add scopes"}
+ className="rounded-lg"
+ size="large"
+ />
+ )}
+
>
);
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx
index f8eff41c76a..ed708cde26c 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx
@@ -1,6 +1,19 @@
import React, { useState, useEffect } from "react";
-import { Form, Select, Button as AntdButton, Tooltip, Input, InputNumber, Alert } from "antd";
+import { Select, Button as AntdButton, Tooltip, Input, InputNumber, Alert } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { FormProvider, useForm } from "react-hook-form";
+import {
+ MountedFormField,
+ MountedFormProvider,
+ applyFieldValues,
+ bindControl,
+ changedValuesFor,
+ projectMountedValues,
+ resetFieldsToDefaults,
+ useMountRegistry,
+ useMountedWatch,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
@@ -40,7 +53,7 @@ import IdJagFormFields from "./IdJagFormFields";
import OAuthFormFields from "./OAuthFormFields";
import MCPLogoSelector from "./MCPLogoSelector";
import EnvVarsSection from "./EnvVarsSection";
-import { validateMCPServerUrl, validateMCPServerName, normalizeToolOverrideMap } from "./utils";
+import { antdValidator, validateMCPServerUrl, validateMCPServerName, normalizeToolOverrideMap } from "./utils";
import { buildEditServerPayload, editPayloadErrorMessage } from "./editServerPayload";
import { toast } from "@/lib/toast";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
@@ -66,173 +79,7 @@ const MCPServerEdit: React.FC = ({
onSuccess,
availableAccessGroups,
}) => {
- const [form] = Form.useForm();
- const [costConfig, setCostConfig] = useState({});
- const [tools, setTools] = useState([]);
- const [isLoadingTools, setIsLoadingTools] = useState(false);
- const [toolsError, setToolsError] = useState(null);
- const [searchValue, setSearchValue] = useState("");
- const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false);
- const [removeStoredApp, setRemoveStoredApp] = useState(false);
- // Set when the upstream identity (url/endpoints) changed while a declared app is present, so the
- // section warns that the saved app may not match the new upstream (the app is kept, not wiped).
- const [appMayNotMatchUpstream, setAppMayNotMatchUpstream] = useState(false);
- const [allowedTools, setAllowedTools] = useState([]);
- const [hasToolAllowlistInteraction, setHasToolAllowlistInteraction] = useState(false);
- const [toolNameToDisplayName, setToolNameToDisplayName] = useState>({});
- const [toolNameToDescription, setToolNameToDescription] = useState>({});
- const [pendingRestoredValues, setPendingRestoredValues] = useState | null>(null);
- const [logoUrl, setLogoUrl] = useState(mcpServer.mcp_info?.logo_url || undefined);
- const authType = Form.useWatch("auth_type", form) as string | undefined;
- const transportType = Form.useWatch("transport", form) as string | undefined;
- const isStdioTransport = transportType === "stdio";
- const isOpenAPITransport = transportType === TRANSPORT.OPENAPI;
- const isMCPTransport = !isStdioTransport && !isOpenAPITransport;
- const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
- const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
- const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE;
- const isIdJagAuthType = authType === AUTH_TYPE.OAUTH2_ID_JAG;
- const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
- const oauthFlowTypeValue = Form.useWatch("oauth_flow_type", form) as string | undefined;
- const isM2MFlow = isOAuthAuthType && oauthFlowTypeValue === OAUTH_FLOW.M2M;
- // Watch reflects a live toggle when the delegate switch is mounted; fall back to
- // the stored value otherwise (useWatch returns undefined for an unmounted field,
- // the same trap the oauth_flow_type field originally hit).
- const delegateAuthWatched = Form.useWatch("delegate_auth_to_upstream", form) as boolean | undefined;
- const isDelegateAuth = delegateAuthWatched ?? Boolean(mcpServer.delegate_auth_to_upstream);
-
- // Watch form fields that affect tool fetching
- const currentUrl = Form.useWatch("url", form);
- const currentSpecPath = Form.useWatch("spec_path", form);
- const currentServerName = Form.useWatch("server_name", form);
- const currentAuthType = Form.useWatch("auth_type", form);
- const currentStaticHeaders = Form.useWatch("static_headers", form);
- const currentCredentials = Form.useWatch("credentials", form);
- const currentIssuer = Form.useWatch("issuer", form);
- const currentAuthorizationUrl = Form.useWatch("authorization_url", form);
- const currentTokenUrl = Form.useWatch("token_url", form);
- const currentRegistrationUrl = Form.useWatch("registration_url", form);
- const hasExistingToolAllowlist =
- Boolean(mcpServer.mcp_info?.tool_allowlist_enforced) || (mcpServer.allowed_tools?.length ?? 0) > 0;
- const existingAllowedTools = hasExistingToolAllowlist ? mcpServer.allowed_tools ?? [] : null;
-
- const persistEditUiState = () => {
- if (typeof window === "undefined") {
- return;
- }
- try {
- const values = form.getFieldsValue(true);
- setSecureItem(
- EDIT_OAUTH_UI_STATE_KEY,
- JSON.stringify({
- serverId: mcpServer.server_id,
- formValues: values,
- costConfig,
- allowedTools,
- hasToolAllowlistInteraction,
- searchValue,
- aliasManuallyEdited,
- }),
- );
- } catch (err) {
- console.warn("Failed to persist MCP edit state", err);
- }
- };
-
- // The auth mode every decision must key off: the admin's in-flight form selection wins over the
- // saved record, so authorizing, loading tools, and saving all agree with what the form shows. Paths
- // that read only mcpServer.auth_type go stale the moment the admin switches modes in the form.
- const getEffectiveAuthType = () => form.getFieldValue("auth_type") ?? mcpServer.auth_type;
-
- // The OAuth authorization identity (see getOAuthAuthorizationIdentity) captured when a token is fetched
- // in this edit session; undefined when none is held. If a mint-relevant field later diverges from it,
- // the held token (hook response + sessionStorage) is discarded so the admin must re-authorize.
- const authorizedIdentityRef = React.useRef(undefined);
-
- const {
- startOAuthFlow,
- status: oauthStatus,
- error: oauthError,
- tokenResponse: oauthTokenResponse,
- reset: resetOAuthFlow,
- } = useMcpOAuthFlow({
- accessToken,
- getCredentials: () => form.getFieldValue("credentials"),
- getTemporaryPayload: () => {
- const values = form.getFieldsValue(true);
- const url = values.url || mcpServer.url;
- const transport = values.transport || mcpServer.transport;
- if (!url || !transport) {
- return null;
- }
- const staticHeaders = Array.isArray(values.static_headers)
- ? values.static_headers.reduce((acc: Record, entry: Record) => {
- const header = entry?.header?.trim();
- if (!header) {
- return acc;
- }
- acc[header] = (entry?.value ?? "").trim();
- return acc;
- }, {})
- : ({} as Record);
-
- return {
- server_id: mcpServer.server_id,
- server_name: values.server_name || mcpServer.server_name || mcpServer.alias,
- alias: values.alias || mcpServer.alias,
- description: values.description || mcpServer.description,
- url,
- transport,
- auth_type: isClientForwardedTokenMode(values.auth_type) ? values.auth_type : AUTH_TYPE.OAUTH2,
- credentials: isClientForwardedTokenMode(values.auth_type)
- ? preservedAdminCredentials(values.credentials)
- : values.credentials,
- mcp_access_groups: values.mcp_access_groups || mcpServer.mcp_access_groups,
- static_headers: staticHeaders,
- command: values.command,
- args: values.args,
- env: values.env,
- };
- },
- onTokenReceived: (token) => {
- if (!token?.access_token) {
- return;
- }
-
- authorizedIdentityRef.current = getOAuthAuthorizationIdentity(form.getFieldsValue(true));
- if (isClientForwardedTokenMode(getEffectiveAuthType())) {
- const browserHeldToken = {
- access_token: token.access_token,
- expires_in: token.expires_in,
- token_type: token.token_type,
- };
- setToken(mcpServer.server_id, browserHeldToken, userID);
- toast.success(
- "Token held for this browser session. Tools can now be loaded and configured; the token is not saved to LiteLLM.",
- );
- return;
- }
-
- const current = (form.getFieldValue("credentials") as Record | undefined) ?? {};
- const nextCredentials = {
- ...(preservedAdminCredentials(current) ?? {}),
- ...(current.scopes !== undefined && { scopes: current.scopes }),
- access_token: token.access_token,
- ...(token.refresh_token && { refresh_token: token.refresh_token }),
- ...(token.expires_in && { expires_in: token.expires_in }),
- ...(token.scope && { scope: token.scope }),
- };
- // Path-replace (not deep-merge) so a re-authorize with fewer token fields does not leave stale
- // siblings behind; the admin-typed client keys and scopes are carried explicitly above.
- form.setFieldValue("credentials", nextCredentials);
- // Re-capture after writing credentials so the token is not invalidated by its own credential write.
- authorizedIdentityRef.current = getOAuthAuthorizationIdentity(form.getFieldsValue(true));
-
- toast.success("OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.");
- },
- onBeforeRedirect: persistEditUiState,
- flowSource: "edit",
- });
+ const registry = useMountRegistry();
const initialStaticHeaders = React.useMemo(() => {
if (!mcpServer.static_headers) {
@@ -276,10 +123,39 @@ const MCPServerEdit: React.FC = ({
return mcpServer.transport;
}, [mcpServer]);
- const initialValues = React.useMemo(
+ const defaultValues: MountedFormValues = React.useMemo(
() => ({
- ...mcpServer,
+ server_name: mcpServer.server_name,
+ alias: mcpServer.alias,
+ description: mcpServer.description,
+ source_url: mcpServer.source_url,
transport: effectiveTransport,
+ url: mcpServer.url,
+ spec_path: mcpServer.spec_path,
+ max_concurrent_requests: mcpServer.max_concurrent_requests,
+ auth_type: mcpServer.auth_type,
+ credentials: mcpServer.credentials,
+ command: mcpServer.command,
+ args: mcpServer.args,
+ stdio_config: undefined,
+ env_json: undefined,
+ issuer: mcpServer.issuer,
+ authorization_url: mcpServer.authorization_url,
+ token_url: mcpServer.token_url,
+ registration_url: mcpServer.registration_url,
+ token_exchange_endpoint: mcpServer.token_exchange_endpoint,
+ token_exchange_profile: mcpServer.token_exchange_profile,
+ audience: mcpServer.audience,
+ subject_token_type: mcpServer.subject_token_type,
+ token_storage_ttl_seconds: mcpServer.token_storage_ttl_seconds,
+ is_byok: mcpServer.is_byok,
+ byok_description: mcpServer.byok_description,
+ byok_api_key_help_url: mcpServer.byok_api_key_help_url,
+ mcp_access_groups: mcpServer.mcp_access_groups,
+ allow_all_keys: mcpServer.allow_all_keys ?? false,
+ available_on_public_internet: mcpServer.available_on_public_internet ?? true,
+ delegate_auth_to_upstream: mcpServer.delegate_auth_to_upstream ?? false,
+ oauth_passthrough: mcpServer.oauth_passthrough ?? false,
static_headers: initialStaticHeaders,
env_vars: initialEnvVars,
extra_headers: mcpServer.extra_headers || [],
@@ -292,24 +168,187 @@ const MCPServerEdit: React.FC = ({
[mcpServer, effectiveTransport, initialStaticHeaders, initialEnvVars, initialEnvJson],
);
- // antd applies `initialValues` only at first mount. When the server loads after
- // mount (e.g. returning from the OAuth redirect lands on Overview and the form
- // mounts before the server data is ready), the form would stay blank. Re-sync it
- // from the loaded server once per server_id so it always reflects the saved config;
- // the OAuth-restore effect below then overlays any in-progress edits on top.
+ const form = useForm({ defaultValues });
+ const mountedContext = React.useMemo(() => ({ control: form.control, registry }), [form.control, registry]);
+
+ const [costConfig, setCostConfig] = useState({});
+ const [tools, setTools] = useState([]);
+ const [isLoadingTools, setIsLoadingTools] = useState(false);
+ const [toolsError, setToolsError] = useState(null);
+ const [searchValue, setSearchValue] = useState("");
+ const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false);
+ const [removeStoredApp, setRemoveStoredApp] = useState(false);
+ // Set when the upstream identity (url/endpoints) changed while a declared app is present, so the
+ // section warns that the saved app may not match the new upstream (the app is kept, not wiped).
+ const [appMayNotMatchUpstream, setAppMayNotMatchUpstream] = useState(false);
+ const [allowedTools, setAllowedTools] = useState([]);
+ const [hasToolAllowlistInteraction, setHasToolAllowlistInteraction] = useState(false);
+ const [toolNameToDisplayName, setToolNameToDisplayName] = useState>({});
+ const [toolNameToDescription, setToolNameToDescription] = useState>({});
+ const [pendingRestoredValues, setPendingRestoredValues] = useState | null>(null);
+ const [logoUrl, setLogoUrl] = useState(mcpServer.mcp_info?.logo_url || undefined);
+ const authType = useMountedWatch("auth_type", mountedContext) as string | undefined;
+ const transportType = useMountedWatch("transport", mountedContext) as string | undefined;
+ const isStdioTransport = transportType === "stdio";
+ const isOpenAPITransport = transportType === TRANSPORT.OPENAPI;
+ const isMCPTransport = !isStdioTransport && !isOpenAPITransport;
+ const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
+ const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
+ const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE;
+ const isIdJagAuthType = authType === AUTH_TYPE.OAUTH2_ID_JAG;
+ const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
+ const oauthFlowTypeValue = useMountedWatch("oauth_flow_type", mountedContext) as string | undefined;
+ const isM2MFlow = isOAuthAuthType && oauthFlowTypeValue === OAUTH_FLOW.M2M;
+ const delegateAuthWatched = useMountedWatch("delegate_auth_to_upstream", mountedContext) as boolean | undefined;
+ const isDelegateAuth = delegateAuthWatched ?? Boolean(mcpServer.delegate_auth_to_upstream);
+
+ // Watch form fields that affect tool fetching
+ const currentUrl = useMountedWatch("url", mountedContext);
+ const currentSpecPath = useMountedWatch("spec_path", mountedContext);
+ const currentServerName = useMountedWatch("server_name", mountedContext);
+ const currentAuthType = useMountedWatch("auth_type", mountedContext);
+ const currentStaticHeaders = useMountedWatch("static_headers", mountedContext);
+ const currentCredentials = useMountedWatch("credentials", mountedContext);
+ const currentIssuer = useMountedWatch("issuer", mountedContext);
+ const currentAuthorizationUrl = useMountedWatch("authorization_url", mountedContext);
+ const currentTokenUrl = useMountedWatch("token_url", mountedContext);
+ const currentRegistrationUrl = useMountedWatch("registration_url", mountedContext);
+ const hasExistingToolAllowlist =
+ Boolean(mcpServer.mcp_info?.tool_allowlist_enforced) || (mcpServer.allowed_tools?.length ?? 0) > 0;
+ const existingAllowedTools = hasExistingToolAllowlist ? mcpServer.allowed_tools ?? [] : null;
+
+ const persistEditUiState = () => {
+ if (typeof window === "undefined") {
+ return;
+ }
+ try {
+ const values: Record = form.getValues();
+ setSecureItem(
+ EDIT_OAUTH_UI_STATE_KEY,
+ JSON.stringify({
+ serverId: mcpServer.server_id,
+ formValues: values,
+ costConfig,
+ allowedTools,
+ hasToolAllowlistInteraction,
+ searchValue,
+ aliasManuallyEdited,
+ }),
+ );
+ } catch (err) {
+ console.warn("Failed to persist MCP edit state", err);
+ }
+ };
+
+ // The auth mode every decision must key off: the admin's in-flight form selection wins over the
+ // saved record, so authorizing, loading tools, and saving all agree with what the form shows. Paths
+ // that read only mcpServer.auth_type go stale the moment the admin switches modes in the form.
+ const getEffectiveAuthType = (): string | null | undefined =>
+ (form.getValues("auth_type") as string | undefined) ?? mcpServer.auth_type;
+
+ // The OAuth authorization identity (see getOAuthAuthorizationIdentity) captured when a token is fetched
+ // in this edit session; undefined when none is held. If a mint-relevant field later diverges from it,
+ // the held token (hook response + sessionStorage) is discarded so the admin must re-authorize.
+ const authorizedIdentityRef = React.useRef(undefined);
+
+ const {
+ startOAuthFlow,
+ status: oauthStatus,
+ error: oauthError,
+ tokenResponse: oauthTokenResponse,
+ reset: resetOAuthFlow,
+ } = useMcpOAuthFlow({
+ accessToken,
+ getCredentials: () => form.getValues("credentials") as Record | undefined,
+ getTemporaryPayload: () => {
+ const values: Record = form.getValues();
+ const url = values.url || mcpServer.url;
+ const transport = values.transport || mcpServer.transport;
+ if (!url || !transport) {
+ return null;
+ }
+ const staticHeaders = Array.isArray(values.static_headers)
+ ? values.static_headers.reduce((acc: Record, entry: Record) => {
+ const header = entry?.header?.trim();
+ if (!header) {
+ return acc;
+ }
+ acc[header] = (entry?.value ?? "").trim();
+ return acc;
+ }, {})
+ : ({} as Record);
+
+ return {
+ server_id: mcpServer.server_id,
+ server_name: values.server_name || mcpServer.server_name || mcpServer.alias,
+ alias: values.alias || mcpServer.alias,
+ description: values.description || mcpServer.description,
+ url,
+ transport,
+ auth_type: isClientForwardedTokenMode(values.auth_type) ? values.auth_type : AUTH_TYPE.OAUTH2,
+ credentials: isClientForwardedTokenMode(values.auth_type)
+ ? preservedAdminCredentials(values.credentials)
+ : values.credentials,
+ mcp_access_groups: values.mcp_access_groups || mcpServer.mcp_access_groups,
+ static_headers: staticHeaders,
+ command: values.command,
+ args: values.args,
+ env: values.env,
+ };
+ },
+ onTokenReceived: (token) => {
+ if (!token?.access_token) {
+ return;
+ }
+
+ authorizedIdentityRef.current = getOAuthAuthorizationIdentity(form.getValues());
+ if (isClientForwardedTokenMode(getEffectiveAuthType())) {
+ const browserHeldToken = {
+ access_token: token.access_token,
+ expires_in: token.expires_in,
+ token_type: token.token_type,
+ };
+ setToken(mcpServer.server_id, browserHeldToken, userID);
+ toast.success(
+ "Token held for this browser session. Tools can now be loaded and configured; the token is not saved to LiteLLM.",
+ );
+ return;
+ }
+
+ const current = (form.getValues("credentials") as Record | undefined) ?? {};
+ const nextCredentials = {
+ ...(preservedAdminCredentials(current) ?? {}),
+ ...(current.scopes !== undefined && { scopes: current.scopes }),
+ access_token: token.access_token,
+ ...(token.refresh_token && { refresh_token: token.refresh_token }),
+ ...(token.expires_in && { expires_in: token.expires_in }),
+ ...(token.scope && { scope: token.scope }),
+ };
+ // Path-replace (not deep-merge) so a re-authorize with fewer token fields does not leave stale
+ // siblings behind; the admin-typed client keys and scopes are carried explicitly above.
+ form.setValue("credentials", nextCredentials);
+ // Re-capture after writing credentials so the token is not invalidated by its own credential write.
+ authorizedIdentityRef.current = getOAuthAuthorizationIdentity(form.getValues());
+
+ toast.success("OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.");
+ },
+ onBeforeRedirect: persistEditUiState,
+ flowSource: "edit",
+ });
+
const syncedServerIdRef = React.useRef(null);
useEffect(() => {
if (!mcpServer.server_id || syncedServerIdRef.current === mcpServer.server_id) {
return;
}
syncedServerIdRef.current = mcpServer.server_id;
- form.setFieldsValue(initialValues);
+ applyFieldValues(form, defaultValues);
// Reset per-server OAuth UI state so it never carries across a server switch without an unmount: a
// stale removeStoredApp would send an explicit-null credential write that deletes the new server's
// stored app, and a stale warning would show on a server whose upstream did not change.
setAppMayNotMatchUpstream(false);
setRemoveStoredApp(false);
- }, [mcpServer.server_id, initialValues, form]);
+ }, [mcpServer.server_id, defaultValues, form]);
// Initialize cost config from existing server data
useEffect(() => {
@@ -394,11 +433,11 @@ const MCPServerEdit: React.FC = ({
// on the re-run triggered by the transportType watch (without it the effect's
// deps never change and the second pass never runs, leaving fields blank).
const transport = pendingRestoredValues.transport || mcpServer.transport;
- if (transport && transport !== form.getFieldValue("transport")) {
- form.setFieldsValue({ transport });
+ if (transport && transport !== form.getValues("transport")) {
+ applyFieldValues(form, { transport });
return;
}
- form.setFieldsValue(pendingRestoredValues);
+ applyFieldValues(form, pendingRestoredValues);
setPendingRestoredValues(null);
}, [pendingRestoredValues, form, mcpServer.transport, transportType]);
@@ -407,7 +446,7 @@ const MCPServerEdit: React.FC = ({
if (mcpServer.mcp_access_groups) {
// If access groups are objects, extract the name property; if strings, use as is
const groupNames = mcpServer.mcp_access_groups.map((g: any) => (typeof g === "string" ? g : g.name || String(g)));
- form.setFieldValue("mcp_access_groups", groupNames);
+ form.setValue("mcp_access_groups", groupNames);
}
}, [mcpServer]);
@@ -439,16 +478,18 @@ const MCPServerEdit: React.FC = ({
resetOAuthFlow();
// The admin-typed app is upstream-scoped config, not minted material, so it survives every
// invalidation; only the held token is discarded. Token-shaped keys are excluded by the filter.
- const keptAdminCredentials = preservedAdminCredentials(form.getFieldValue("credentials"));
- form.resetFields([...CLEARED_ON_INVALIDATION]);
+ const keptAdminCredentials = preservedAdminCredentials(
+ form.getValues("credentials") as Record | undefined,
+ );
+ resetFieldsToDefaults(form, defaultValues, CLEARED_ON_INVALIDATION);
if (keptAdminCredentials) {
- form.setFieldsValue({ credentials: keptAdminCredentials });
+ applyFieldValues(form, { credentials: keptAdminCredentials });
}
const preserved = Object.fromEntries(
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);
}
};
@@ -463,12 +504,14 @@ const MCPServerEdit: React.FC = ({
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 | undefined) !==
+ undefined;
if (upstreamChanged && hasDeclaredApp) {
setAppMayNotMatchUpstream(true);
}
}
- if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentityRef.current)) {
+ if (isHeldOAuthTokenStale(form.getValues(), authorizedIdentityRef.current)) {
clearHeldOAuthToken(changedValues);
}
};
@@ -492,7 +535,7 @@ const MCPServerEdit: React.FC = ({
setIsLoadingTools(true);
setToolsError(null);
try {
- const values = form.getFieldsValue(true);
+ const values: Record = form.getValues();
const rawTransport = values.transport || mcpServer.transport;
// oauth2_flow must be explicit: the preview endpoint infers client_credentials from the
// inherited client_id/client_secret/token_url (common once DCR or discovery filled them) and
@@ -631,7 +674,7 @@ const MCPServerEdit: React.FC = ({
token_url: undefined,
registration_url: undefined,
};
- form.setFieldsValue(clearedForStdio);
+ applyFieldValues(form, clearedForStdio);
} else if (value === TRANSPORT.OPENAPI) {
const clearedForOpenapi = {
url: undefined,
@@ -640,9 +683,9 @@ const MCPServerEdit: React.FC = ({
env_json: undefined,
stdio_config: undefined,
};
- form.setFieldsValue(clearedForOpenapi);
+ applyFieldValues(form, clearedForOpenapi);
} else {
- form.setFieldsValue({
+ applyFieldValues(form, {
spec_path: undefined,
command: undefined,
args: undefined,
@@ -650,7 +693,7 @@ const MCPServerEdit: React.FC = ({
stdio_config: undefined,
});
}
- if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentityRef.current)) {
+ if (isHeldOAuthTokenStale(form.getValues(), authorizedIdentityRef.current)) {
clearHeldOAuthToken();
}
};
@@ -720,6 +763,22 @@ const MCPServerEdit: React.FC = ({
}
};
+ const submitFromCostTab = form.handleSubmit((store) => handleSave(projectMountedValues(registry, store)));
+
+ 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));
+ });
+ return () => subscription.unsubscribe();
+ }, [form, registry]);
+
return (
@@ -732,452 +791,506 @@ const MCPServerEdit: React.FC = ({
- validateMCPServerName(value),
- },
- ]}
- >
-
-
- validateMCPServerName(value),
- },
- ]}
- >
- setAliasManuallyEdited(true)}
- className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
- />
-
-
-
-
-
-
-
- Streamable HTTP (Recommended)
- Server-Sent Events (SSE)
- Standard Input/Output (stdio)
- OpenAPI Spec
-
-
-
- {/* URL field - only for HTTP/SSE */}
- {isMCPTransport && (
- validateMCPServerUrl(value) },
- ]}
- >
-
-
- )}
-
- {/* OpenAPI Spec URL - only for OpenAPI transport */}
- {isOpenAPITransport && (
-
- OpenAPI Spec URL
-
-
-
-
- }
- name="spec_path"
- rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]}
- >
-
-
- )}
-
-
- Max Concurrent Requests (optional)
-
-
-
-
- }
- name="max_concurrent_requests"
- >
-
-
-
- {/* Authentication - for HTTP, SSE, and OpenAPI */}
- {!isStdioTransport && (
- <>
-
-
- None
- API Key
- Bearer Token
- Token
- Basic Auth
- OAuth
- OAuth Token Exchange (OBO)
- ID-JAG (Okta Cross App Access)
- AWS SigV4 (Bedrock AgentCore MCPs)
- True Passthrough (no LiteLLM auth)
-
- OAuth Delegate (client-supplied upstream token)
-
-
-
-
-
- >
- )}
-
- {isStdioTransport && (
-
-
- Configure the stdio transport used to launch the MCP server process. You can either fill in the fields
- below or paste a JSON configuration.
-
-
-
+
+
-
-
-
-
-
-
{
- if (!value) return Promise.resolve();
- try {
- const parsed = JSON.parse(value);
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
- return Promise.resolve();
- }
- return Promise.reject(new Error("Env must be a JSON object"));
- } catch {
- return Promise.reject(new Error("Please enter valid JSON"));
- }
- },
- },
- ]}
+ {(field) => (
+ (field)}
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+ antdValidator(validateMCPServerName, value) }}
>
-
-
+ {(field) => (
+
(field)}
+ onChange={(event) => {
+ setAliasManuallyEdited(true);
+ field.onChange(event);
+ }}
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+
+ {(field) => (
+ (field)}
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+
+
+ {(field) => (
+ (field)}
+ onChange={(value) => {
+ field.onChange(value);
+ handleTransportChange(value);
+ }}
+ >
+ Streamable HTTP (Recommended)
+ Server-Sent Events (SSE)
+ Standard Input/Output (stdio)
+ OpenAPI Spec
+
+ )}
+
- {/* Optional JSON config (if provided, it overrides command/args/env on save) */}
-
-
- )}
-
- {!isStdioTransport && shouldShowAuthValueField && (
-
- Authentication Value
-
-
-
-
- }
- name={["credentials", "auth_value"]}
- rules={[
- {
- validator: (_, value) =>
- value && typeof value === "string" && value.trim() === ""
- ? Promise.reject(new Error("Authentication value cannot be empty"))
- : Promise.resolve(),
- },
- ]}
- >
-
-
- )}
-
- {!isStdioTransport && isOAuthAuthType && (
- <>
- {!oauthFlowTypeValue && !isDelegateAuth && (
-
- )}
-
- >
- )}
-
- {!isStdioTransport && isTokenExchangeAuthType && }
-
- {!isStdioTransport && isIdJagAuthType && }
-
- {!isStdioTransport && isAwsSigV4AuthType && (
- <>
-
- For MCP servers hosted on AWS Bedrock AgentCore.{" "}
- antdValidator(validateMCPServerUrl, value),
+ }}
>
- View docs →
-
-
-
- AWS Region
-
-
-
-
- }
- name={["credentials", "aws_region_name"]}
- rules={[]}
- >
-
-
-
- AWS Service Name
-
-
-
-
- }
- name={["credentials", "aws_service_name"]}
- >
-
-
-
- AWS Access Key ID
-
-
-
-
- }
- name={["credentials", "aws_access_key_id"]}
- rules={[]}
- >
-
-
-
- AWS Secret Access Key
-
-
-
-
- }
- name={["credentials", "aws_secret_access_key"]}
- rules={[]}
- >
-
-
-
- AWS Session Token
-
-
-
-
- }
- name={["credentials", "aws_session_token"]}
- >
-
-
-
- AWS Role ARN
-
-
-
-
- }
- name={["credentials", "aws_role_name"]}
- >
-
-
-
- AWS Session Name
-
-
-
-
- }
- name={["credentials", "aws_session_name"]}
- >
-
-
- >
- )}
+ {(field) => (
+ (field)}
+ placeholder="https://your-mcp-server.com"
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+ )}
- {/* Environment Variables Section */}
-
-
-
+ {/* OpenAPI Spec URL - only for OpenAPI transport */}
+ {isOpenAPITransport && (
+
+ OpenAPI Spec URL
+
+
+
+
+ }
+ name="spec_path"
+ required
+ rules={{ required: "Please enter an OpenAPI spec URL" }}
+ >
+ {(field) => (
+ (field)}
+ placeholder="https://petstore3.swagger.io/api/v3/openapi.json"
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+ )}
- {/* Permission Management / Access Control Section */}
-
-
-
+
+ Max Concurrent Requests (optional)
+
+
+
+
+ }
+ name="max_concurrent_requests"
+ >
+ {(field) => (
+ (field)}
+ min={1}
+ precision={0}
+ placeholder="e.g. 10"
+ style={{ width: "100%" }}
+ className="rounded-lg"
+ />
+ )}
+
- {/* Tool Configuration Section */}
-
- setHasToolAllowlistInteraction(true)}
- toolNameToDisplayName={toolNameToDisplayName}
- toolNameToDescription={toolNameToDescription}
- onToolNameToDisplayNameChange={setToolNameToDisplayName}
- onToolNameToDescriptionChange={setToolNameToDescription}
- externalTools={tools}
- externalIsLoading={isLoadingTools}
- externalError={toolsError}
- externalCanFetch={true}
- />
-
+ {/* Authentication - for HTTP, SSE, and OpenAPI */}
+ {!isStdioTransport && (
+ <>
+
+ {(field) => (
+ (field)} virtual={false}>
+ None
+ API Key
+ Bearer Token
+ Token
+ Basic Auth
+ OAuth
+ OAuth Token Exchange (OBO)
+ ID-JAG (Okta Cross App Access)
+ AWS SigV4 (Bedrock AgentCore MCPs)
+ True Passthrough (no LiteLLM auth)
+
+ OAuth Delegate (client-supplied upstream token)
+
+
+ )}
+
+
+
+ >
+ )}
-
-
Cancel
-
Save Changes
-
-
+ {isStdioTransport && (
+
+
+ Configure the stdio transport used to launch the MCP server process. You can either fill in the
+ fields below or paste a JSON configuration.
+
+
+
+ {(field) => (
+ (field)}
+ placeholder="e.g., npx"
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+
+
+ {(field) => (
+ (field)}
+ mode="tags"
+ size="large"
+ tokenSeparators={[","]}
+ placeholder="Add args (press enter or comma)"
+ className="rounded-lg"
+ />
+ )}
+
+
+
{
+ if (!value) return true;
+ try {
+ const parsed = JSON.parse(String(value));
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ return true;
+ }
+ return "Env must be a JSON object";
+ } catch {
+ return "Please enter valid JSON";
+ }
+ },
+ }}
+ >
+ {(field) => (
+ (field)}
+ rows={6}
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm"
+ placeholder={`{\n \"KEY\": \"value\"\n}`}
+ />
+ )}
+
+
+ {/* Optional JSON config (if provided, it overrides command/args/env on save) */}
+
+
+ )}
+
+ {!isStdioTransport && shouldShowAuthValueField && (
+
+ Authentication Value
+
+
+
+
+ }
+ name="credentials.auth_value"
+ rules={{
+ validate: (value) =>
+ value && typeof value === "string" && value.trim() === ""
+ ? "Authentication value cannot be empty"
+ : true,
+ }}
+ >
+ {(field) => (
+ (field)}
+ placeholder="Enter token or secret (leave blank to keep existing)"
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+ )}
+
+ {!isStdioTransport && isOAuthAuthType && (
+ <>
+ {!oauthFlowTypeValue && !isDelegateAuth && (
+
+ )}
+
+ >
+ )}
+
+ {!isStdioTransport && isTokenExchangeAuthType && }
+
+ {!isStdioTransport && isIdJagAuthType && }
+
+ {!isStdioTransport && isAwsSigV4AuthType && (
+ <>
+
+ For MCP servers hosted on AWS Bedrock AgentCore.{" "}
+
+ View docs →
+
+
+
+ AWS Region
+
+
+
+
+ }
+ name="credentials.aws_region_name"
+ >
+ {(field) => (
+ (field)}
+ placeholder="us-east-1 (leave blank to keep existing)"
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+
+ AWS Service Name
+
+
+
+
+ }
+ name="credentials.aws_service_name"
+ >
+ {(field) => (
+ (field)}
+ placeholder="bedrock-agentcore (leave blank to keep existing)"
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+
+ AWS Access Key ID
+
+
+
+
+ }
+ name="credentials.aws_access_key_id"
+ >
+ {(field) => (
+ (field)}
+ placeholder="Leave blank to keep existing"
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+
+ AWS Secret Access Key
+
+
+
+
+ }
+ name="credentials.aws_secret_access_key"
+ >
+ {(field) => (
+ (field)}
+ placeholder="Leave blank to keep existing"
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+
+ AWS Session Token
+
+
+
+
+ }
+ name="credentials.aws_session_token"
+ >
+ {(field) => (
+ (field)}
+ placeholder="Leave blank to keep existing"
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+
+ AWS Role ARN
+
+
+
+
+ }
+ name="credentials.aws_role_name"
+ >
+ {(field) => (
+ (field)}
+ placeholder="Leave blank to keep existing"
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+
+ AWS Session Name
+
+
+
+
+ }
+ name="credentials.aws_session_name"
+ >
+ {(field) => (
+ (field)}
+ placeholder="Leave blank to keep existing"
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+ >
+ )}
+
+ {/* Environment Variables Section */}
+
+
+
+
+ {/* Permission Management / Access Control Section */}
+
+
+
+
+ {/* Tool Configuration Section */}
+
+ setHasToolAllowlistInteraction(true)}
+ toolNameToDisplayName={toolNameToDisplayName}
+ toolNameToDescription={toolNameToDescription}
+ onToolNameToDisplayNameChange={setToolNameToDisplayName}
+ onToolNameToDescriptionChange={setToolNameToDescription}
+ externalTools={tools}
+ externalIsLoading={isLoadingTools}
+ externalError={toolsError}
+ externalCanFetch={true}
+ />
+
+
+
+
Cancel
+
Save Changes
+
+
+
+
@@ -1186,7 +1299,7 @@ const MCPServerEdit: React.FC = ({
Cancel
-
form.submit()}>Save Changes
+
submitFromCostTab()}>Save Changes
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts
index 7911c3eb800..4c137609579 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts
@@ -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") ??
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/utils.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/utils.tsx
index 4738e1e8fba..98bd7c6821f 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/utils.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/utils.tsx
@@ -54,6 +54,18 @@ export const validateMCPServerName = (value: string) => {
: Promise.resolve();
};
+export const antdValidator = async (
+ validate: (value: string) => Promise,
+ value: unknown,
+): Promise => {
+ 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) => {
diff --git a/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.tsx b/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.tsx
new file mode 100644
index 00000000000..4171e9306b2
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.tsx
@@ -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;
+ 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 (
+ <>
+ {JSON.stringify(gated) ?? "undefined"}
+ {JSON.stringify(credentials) ?? "undefined"}
+ {JSON.stringify(rows) ?? "undefined"}
+ >
+ );
+};
+
+const Harness: React.FC = ({
+ defaultValues,
+ showGated = true,
+ showNested = true,
+ showRows = 0,
+ duplicateGated = false,
+}) => {
+ const form = useForm({ defaultValues });
+ const registry = useMountRegistry();
+ React.useEffect(() => {
+ harness.form = form;
+ harness.registry = registry;
+ }, [form, registry]);
+ return (
+
+
+
+ {(field) => (
+
+ )}
+
+ {showGated && (
+
+ {(field) => (
+
+ )}
+
+ )}
+ {duplicateGated && (
+
+ {(field) => (
+
+ )}
+
+ )}
+ {showNested && (
+
+ {(field) => (
+
+ )}
+
+ )}
+ {Array.from({ length: showRows }, (_, index) => (
+
+ {(field) => (
+
+ )}
+
+ ))}
+
+ );
+};
+
+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( );
+ 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( );
+ 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( );
+ 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( );
+ rerender( );
+ expect(projectMountedValues(harness.registry!, harness.form!.getValues())).toHaveProperty("gated", "seeded");
+ });
+
+ it("accepts a getValues function as well as a plain store", () => {
+ render( );
+ 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( );
+ expect(projectMountedValues(harness.registry!, harness.form!.getValues())).toHaveProperty("gated");
+ rerender( );
+ 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( );
+ expect(screen.getByTestId("watch-gated")).toHaveTextContent("undefined");
+ });
+
+ it("reports the live value while the field is mounted", async () => {
+ render( );
+ 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( );
+ await userEvent.clear(screen.getByLabelText("gated"));
+ await userEvent.type(screen.getByLabelText("gated"), "typed");
+ rerender( );
+ expect(screen.getByTestId("watch-gated")).toHaveTextContent("undefined");
+ expect(harness.form!.getValues("gated")).toBe("typed");
+ });
+
+ it("narrows a container to its mounted descendants", () => {
+ render( );
+ expect(screen.getByTestId("watch-credentials")).toHaveTextContent('{"client_id":"cid"}');
+ });
+
+ it("is undefined for a container whose descendants all unmounted", () => {
+ render( );
+ expect(screen.getByTestId("watch-credentials")).toHaveTextContent("undefined");
+ });
+});
+
+describe("applyFieldValues", () => {
+ it("deep-merges a partial object instead of replacing it", () => {
+ render( );
+ 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( );
+ 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( );
+ 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( );
+ 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( );
+ 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);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx
new file mode 100644
index 00000000000..a2998b2c6ed
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx
@@ -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;
+
+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;
+ readonly registry: MountRegistry;
+}
+
+const missingProvider = (): never => {
+ throw new Error("MountedFormField requires a MountedFormProvider ancestor");
+};
+
+const MountedFormContext = React.createContext({
+ get control(): Control {
+ 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>(new Map());
+ const listeners = React.useRef 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(
+ (value, segment) =>
+ value === null || value === undefined ? undefined : (value as Record)[segment],
+ source,
+ );
+
+const cloneContainer = (target: unknown, head: string): Record | unknown[] => {
+ if (Array.isArray(target)) return [...target];
+ if (target !== null && typeof target === "object") return { ...(target as Record) };
+ 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((acc, path) => {
+ const segments = path.split(".");
+ return writePath(acc, segments, readPath(store, segments));
+ }, seed);
+
+export const projectMountedValues = (
+ registry: MountRegistry,
+ source: MountedFormValues | UseFormGetValues,
+): 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 =>
+ 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>(
+ (acc, [key, value]) => ({ ...acc, [key]: mergeValues(target[key], value) }),
+ { ...target },
+ );
+};
+
+export const applyFieldValues = (form: UseFormReturn, 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,
+ 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, 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 = (
+ control: MountedFieldControlProps,
+): Omit & {
+ 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,
+ "valueAsNumber" | "valueAsDate" | "setValueAs" | "disabled"
+ >;
+ readonly defaultValue?: unknown;
+ readonly bare?: boolean;
+ readonly className?: string;
+ readonly children: (control: MountedFieldControlProps) => React.ReactNode;
+}
+
+export const MountedFormField: React.FC = ({
+ 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["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 (
+
+ {label !== undefined && {label} }
+ {children(controlProps)}
+ {hasHelp ? (
+ {help}
+ ) : (
+
+ )}
+
+ );
+ };
+
+ return ;
+};