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..78c8bbe73a9 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,15 @@
import React, { useState } from "react";
-import { Form, Input, Tooltip } from "antd";
+import { Input, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
-import { FormInstance } from "antd/es/form";
import { AUTH_TYPE, OAUTH_FLOW } from "@/components/mcp_tools/types";
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { antdRequired } from "@/components/common_components/antdFormRules";
import OpenAPIQuickPicker, { OpenAPIRegistryEntry, OpenAPIKeyTool } from "./OpenAPIQuickPicker";
+import { McpForm, resetFields, setFieldsValue } from "./mcpFormStore";
+import { textControl } from "./mcpFieldRules";
interface OpenAPIFormSectionProps {
- form: FormInstance;
+ form: McpForm;
accessToken: string | null;
/** Called when a preset is selected so the parent can sync its formValues state. */
onValuesChange: (updates: Record) => void;
@@ -47,13 +50,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);
+ setFieldsValue(form, updates);
onOAuthDocsUrlChange?.(entry.oauth.docs_url ?? null);
} else {
- // resetFields is required to visually clear Ant Design form fields —
- // setFieldsValue with undefined silently skips undefined keys.
- form.resetFields(["auth_type", "authorization_url", "token_url"]);
- form.setFieldsValue(updates);
+ resetFields(form, ["auth_type", "authorization_url", "token_url"]);
+ setFieldsValue(form, updates);
onOAuthDocsUrlChange?.(null);
}
onValuesChange(updates);
@@ -63,7 +64,7 @@ const OpenAPIFormSection: React.FC = ({
<>
-
OpenAPI Spec URL
@@ -73,20 +74,25 @@ const OpenAPIFormSection: React.FC = ({
}
name="spec_path"
- rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]}
+ required
+ rules={{ validate: { required: antdRequired("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);
- }}
- />
-
+ {(control) => (
+ {
+ control.onChange(event);
+ // Clear the preset selection when the user manually edits the spec URL
+ // so stale suggested tools from a previous preset don't persist.
+ setSelectedPreset(null);
+ onKeyToolsChange?.([]);
+ onOAuthDocsUrlChange?.(null);
+ }}
+ />
+ )}
+
>
);
};
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..83c841439f0 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,101 @@
import React from "react";
-import { Form, Input, Select, Switch, Tooltip } from "antd";
+import { Input, Select, Switch, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { useWatch } from "react-hook-form";
-const OpenApiByokFields: React.FC = () => (
- <>
-
- BYOK (Bring Your Own Key)
-
-
-
-
- }
- name="is_byok"
- valuePropName="checked"
- >
-
-
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { selectControl, switchControl, textControl } from "./mcpFieldRules";
- 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"
- >
+const AUTH_HEADER_FORMATS: Readonly> = {
+ bearer_token: "Authorization: Bearer {key}",
+ token: "Authorization: token {key}",
+ api_key: "x-api-key: {key}",
+ basic: "Authorization: Basic {key}",
+ authorization: "Authorization: {key}",
+};
+
+const OpenApiByokFields: React.FC = () => {
+ const isByok = Boolean(useWatch({ name: "is_byok" }));
+ const authType = useWatch({ name: "auth_type" }) as string | undefined;
+ const hasAuthType = Boolean(authType) && authType !== "none";
+
+ return (
+ <>
+
+ BYOK (Bring Your Own Key)
+
+
+
+
+ }
+ name="is_byok"
+ >
+ {(control) => }
+
+
+ {isByok && (
+ <>
+ {hasAuthType && (
+
+
+
+ User keys will be sent as:{" "}
+
+ {authType === undefined ? "" : AUTH_HEADER_FORMATS[authType]}
+
+
+
+ )}
+ {!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"
+ >
+ {(control) => (
-
+ )}
+
-
- API Key Help URL
-
-
-
-
- }
- name="byok_api_key_help_url"
- >
-
-
- >
- ) : null
- }
-
- >
-);
+
+ API Key Help URL
+
+
+
+
+ }
+ name="byok_api_key_help_url"
+ >
+ {(control) => }
+
+ >
+ )}
+ >
+ );
+};
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..a7577ccb781 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,13 +1,10 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
-import { Form } from "antd";
import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
+import { McpFormHarness } from "./McpFormTestHarness";
-const WithForm: React.FC<{ children: React.ReactNode }> = ({ children }) => {
- const [form] = Form.useForm();
- return ;
-};
+const WithForm = McpFormHarness;
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..375e1dfe515 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,6 +1,8 @@
import React from "react";
-import { Button, Checkbox, Form, Input } from "antd";
+import { Button, Checkbox, Input } from "antd";
import DcrBridgeToggle from "./DcrBridgeToggle";
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { textControl } from "./mcpFieldRules";
import { credentialAuthClass, isClientForwardedTokenMode } from "@/components/mcp_tools/types";
interface PassthroughOAuthFlow {
@@ -81,27 +83,33 @@ export default function PassthroughAuthorizeSection({
and may not be valid. Update the client ID, or clear it to use dynamic client registration.
)}
-
OAuth Client ID (optional)}
name={["credentials", "client_id"]}
- extra={clientIdExtra}
+ help={clientIdExtra}
>
-
-
-
(
+
+ )}
+
+ OAuth Client Secret (optional)}
name={["credentials", "client_secret"]}
>
-
-
+ {(control) => (
+
+ )}
+
{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..bb410aa19dc 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,7 +1,11 @@
import React from "react";
-import { Form, Input, Tooltip } from "antd";
+import { Input, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { antdRequired } from "@/components/common_components/antdFormRules";
+import { parsesAsJson, textControl } from "./mcpFieldRules";
+
interface StdioConfigurationProps {
isVisible: boolean;
/**
@@ -11,37 +15,7 @@ interface StdioConfigurationProps {
required?: boolean;
}
-const StdioConfiguration: React.FC = ({ isVisible, required = true }) => {
- if (!isVisible) return null;
-
- return (
-
- Stdio Configuration (JSON)
-
-
-
-
- }
- 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");
- }
- },
- },
- ]}
- >
- = ({ isVisible, requ
}
}
}
-}`}
- rows={12}
- className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm"
- />
-
+}`;
+
+const StdioConfiguration: React.FC = ({ isVisible, required = true }) => {
+ if (!isVisible) return null;
+
+ return (
+
+ Stdio Configuration (JSON)
+
+
+
+
+ }
+ name="stdio_config"
+ required={required}
+ rules={{
+ validate: {
+ ...(required ? { required: antdRequired("Please enter stdio configuration") } : {}),
+ json: parsesAsJson("Please enter valid JSON"),
+ },
+ }}
+ >
+ {(control) => (
+
+ )}
+
);
};
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..38fa3573079 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,7 +1,10 @@
import React from "react";
-import { Form, Select, Tooltip } from "antd";
+import { Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { selectControl } from "./mcpFieldRules";
+
const TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS = [
{ value: "client_secret_basic", label: "Client Secret Basic" },
{ value: "client_secret_post", label: "Client Secret Post" },
@@ -12,7 +15,7 @@ interface TokenEndpointAuthMethodFieldProps {
}
const TokenEndpointAuthMethodField: React.FC = ({ isEditing = false }) => (
-
Token Endpoint Auth Method (optional)
@@ -23,16 +26,19 @@ const TokenEndpointAuthMethodField: React.FC
}
name={["credentials", "token_endpoint_auth_method"]}
>
-
-
+ {(control) => (
+
+ )}
+
);
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..ba213b20655 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,11 @@
import React from "react";
-import { Form, Input, Select, Tooltip } from "antd";
+import { Input, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { useWatch } from "react-hook-form";
+
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { antdRequired } from "@/components/common_components/antdFormRules";
+import { selectControl, textControl } from "./mcpFieldRules";
interface TokenExchangeFormFieldsProps {
isEditing?: boolean;
@@ -19,10 +24,13 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
const TokenExchangeFormFields: React.FC = ({ isEditing = false }) => {
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
+ const isEntraObo = useWatch({ name: "token_exchange_profile" }) === "entra_obo";
+ const requiredWhenCreating = (message: string) =>
+ isEditing ? undefined : { validate: { required: antdRequired(message) } };
return (
<>
- = ({ isEdi
/>
}
name="token_exchange_profile"
- {...(isEditing ? {} : { initialValue: "rfc8693" })}
+ {...(isEditing ? {} : { defaultValue: "rfc8693" })}
>
-
-
- RFC 8693 (standard)
-
-
- Microsoft Entra OBO
-
-
-
- (
+
+
+ RFC 8693 (standard)
+
+
+ Microsoft Entra OBO
+
+
+ )}
+
+ = ({ isEdi
}
name="token_exchange_endpoint"
>
-
-
- (
+
+ )}
+
+ = ({ isEdi
/>
}
name={["credentials", "client_id"]}
- rules={[{ required: !isEditing, message: "Client ID is required for token exchange" }]}
+ required={!isEditing}
+ rules={requiredWhenCreating("Client ID is required for token exchange")}
>
-
-
- (
+
+ )}
+
+ = ({ isEdi
/>
}
name={["credentials", "client_secret"]}
- rules={[{ required: !isEditing, message: "Client Secret is required for token exchange" }]}
+ required={!isEditing}
+ rules={requiredWhenCreating("Client Secret is required for token exchange")}
>
-
-
- 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"
- />
-
- >
- );
- }}
-
+ {(control) => (
+
+ )}
+
+ {!isEntraObo && (
+ <>
+
+ }
+ name="audience"
+ >
+ {(control) => (
+
+ )}
+
+
+ }
+ name="subject_token_type"
+ >
+ {(control) => (
+
+ )}
+
+ >
+ )}
+ /.default)."
+ : "Optional scopes to request during the token exchange."
+ }
+ />
+ }
+ name={["credentials", "scopes"]}
+ required={isEntraObo}
+ rules={
+ isEntraObo
+ ? {
+ validate: {
+ required: antdRequired("Microsoft Entra OBO requires a scope, e.g. api:///.default"),
+ },
+ }
+ : undefined
+ }
+ >
+ {(control) => (
+ /.default" : "Add scopes"}
+ className="rounded-lg"
+ size="large"
+ />
+ )}
+
>
);
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts
index 05e64468f4f..f9cab181e71 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts
@@ -236,7 +236,7 @@ const legacyBuild = (values: Record, ui: EditServerUiState) => {
allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys),
available_on_public_internet: Boolean(availableOnPublicInternetRaw ?? mcpServer.available_on_public_internet),
// ``delegate_auth_to_upstream`` is only honored server-side for
- // ``auth_type=oauth2`` (PKCE passthrough). The Form.Item is
+ // ``auth_type=oauth2`` (PKCE passthrough). The field is
// conditionally rendered so the value drops out of the form on
// auth_type change; force false for any other configuration to avoid
// persisting a stale ``true`` that would silently re-activate if the
@@ -258,7 +258,7 @@ const legacyBuild = (values: Record, ui: EditServerUiState) => {
return isNoneAuth && hasAuthorizationHeader ? Boolean(oauthPassthroughRaw ?? mcpServer.oauth_passthrough) : false;
})(),
// ``dcr_bridge`` is only meaningful for the client-forwarded token
- // modes (true_passthrough / oauth_delegate). The Form.Item is
+ // modes (true_passthrough / oauth_delegate). The field is
// conditionally rendered so the value drops out of the form on
// auth_type change; force false for any other configuration to avoid
// persisting a stale ``true`` that would silently re-activate if the
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFieldRules.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFieldRules.ts
new file mode 100644
index 00000000000..76ff74eac52
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFieldRules.ts
@@ -0,0 +1,98 @@
+import type { Validate } from "react-hook-form";
+
+import type { MountedFieldControlProps, MountedFormValues } from "@/components/common_components/MountedFormField";
+
+type McpValidate = Validate;
+
+const ariaOf = (control: MountedFieldControlProps) => ({
+ id: control.id,
+ onBlur: control.onBlur,
+ "aria-required": control["aria-required"],
+ "aria-invalid": control["aria-invalid"],
+ "aria-describedby": control["aria-describedby"],
+});
+
+export const textControl = (control: MountedFieldControlProps) => ({
+ ...ariaOf(control),
+ name: control.name,
+ value: control.value === null || control.value === undefined ? "" : String(control.value),
+ onChange: control.onChange,
+});
+
+export const selectControl = (control: MountedFieldControlProps) => ({
+ ...ariaOf(control),
+ value: control.value as TValue,
+ onChange: control.onChange,
+});
+
+export const numberControl = (control: MountedFieldControlProps) => ({
+ ...ariaOf(control),
+ value: control.value as number | null | undefined,
+ onChange: control.onChange,
+});
+
+export const switchControl = (control: MountedFieldControlProps) => ({
+ ...ariaOf(control),
+ checked: control.value === true,
+ onChange: control.onChange,
+});
+
+export const invertedSwitchControl = (control: MountedFieldControlProps) => ({
+ ...ariaOf(control),
+ checked: control.value !== true,
+ onChange: (checked: boolean) => control.onChange(!checked),
+});
+
+export const valueAt = (values: MountedFormValues, path: readonly string[]): unknown =>
+ path.reduce(
+ (node, segment) => (node === null || node === undefined ? undefined : (node as Record)[segment]),
+ values,
+ );
+
+export const parsesAsJson =
+ (message: string): McpValidate =>
+ (value) => {
+ if (typeof value !== "string" || value.trim() === "") {
+ return true;
+ }
+ try {
+ JSON.parse(value);
+ return true;
+ } catch {
+ return message;
+ }
+ };
+
+export const parsesAsJsonObject =
+ (message: string, notObjectMessage: string): McpValidate =>
+ (value) => {
+ if (typeof value !== "string" || value === "") {
+ return true;
+ }
+ try {
+ const parsed: unknown = JSON.parse(value);
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? true : notObjectMessage;
+ } catch {
+ return message;
+ }
+ };
+
+export const matchesPattern =
+ (pattern: RegExp, message: string): McpValidate =>
+ (value) =>
+ typeof value === "string" && value !== "" && !pattern.test(value) ? message : true;
+
+export const notOnlyWhitespace =
+ (message: string): McpValidate =>
+ (value) =>
+ typeof value === "string" && value !== "" && value.trim() === "" ? message : true;
+
+export const requiredWhenSiblingSet =
+ (siblingPath: readonly string[], message: string): McpValidate =>
+ (value, values) =>
+ valueAt(values, siblingPath) && !value ? message : true;
+
+export const requiredUnlessSiblingSet =
+ (siblingPath: readonly string[], message: string): McpValidate =>
+ (value, values) =>
+ value || valueAt(values, siblingPath) ? true : message;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.test.tsx
new file mode 100644
index 00000000000..25eba43335e
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.test.tsx
@@ -0,0 +1,132 @@
+import React from "react";
+import { describe, expect, it } from "vitest";
+import { render } from "@testing-library/react";
+import { useForm } from "react-hook-form";
+
+import type { MountedFormValues } from "@/components/common_components/MountedFormField";
+import { allFieldsValue, deepMergedFieldsValue, resetFields, setFieldsValue, singleBranchChange } from "./mcpFormStore";
+
+const withForm = (
+ defaultValues: MountedFormValues,
+ act: (form: ReturnType>) => void,
+) => {
+ let store: MountedFormValues = {};
+ const Probe: React.FC = () => {
+ const form = useForm({ defaultValues });
+ React.useEffect(() => {
+ act(form);
+ store = allFieldsValue(form);
+ }, [form]);
+ return null;
+ };
+ render( );
+ return store;
+};
+
+describe("deepMergedFieldsValue", () => {
+ it("keeps a sibling key when a nested object is written, which is what preserves a declared app", () => {
+ expect(
+ deepMergedFieldsValue(
+ { credentials: { client_id: "kept", access_token: "tok" } },
+ { credentials: { client_id: "typed" } },
+ ),
+ ).toStrictEqual({ credentials: { client_id: "typed", access_token: "tok" } });
+ });
+
+ it("replaces an array rather than merging it index by index", () => {
+ expect(deepMergedFieldsValue({ extra_headers: ["a", "b", "c"] }, { extra_headers: ["z"] })).toStrictEqual({
+ extra_headers: ["z"],
+ });
+ });
+
+ it("writes an explicit undefined instead of skipping the key, which is how a transport switch clears a field", () => {
+ const merged = deepMergedFieldsValue({ url: "https://old", auth_type: "api_key" }, { url: undefined });
+ expect(merged).toStrictEqual({ url: undefined, auth_type: "api_key" });
+ expect("url" in merged).toBe(true);
+ });
+
+ it("writes an explicit null rather than treating it as a merge target", () => {
+ expect(deepMergedFieldsValue({ credentials: { client_id: "x" } }, { credentials: null })).toStrictEqual({
+ credentials: null,
+ });
+ });
+
+ it("replaces a primitive with an object when the incoming value is an object", () => {
+ expect(deepMergedFieldsValue({ credentials: "not-an-object" }, { credentials: { client_id: "x" } })).toStrictEqual({
+ credentials: { client_id: "x" },
+ });
+ });
+
+ it("does not mutate the store it was handed", () => {
+ const store = { credentials: { client_id: "kept" } };
+ deepMergedFieldsValue(store, { credentials: { client_secret: "added" } });
+ expect(store).toStrictEqual({ credentials: { client_id: "kept" } });
+ });
+
+ it("treats a missing store as empty rather than throwing", () => {
+ expect(deepMergedFieldsValue(undefined, { alias: "a" })).toStrictEqual({ alias: "a" });
+ });
+});
+
+describe("singleBranchChange", () => {
+ it("carries only the changed leaf, so re-applying it cannot resurrect a sibling token key", () => {
+ expect(
+ singleBranchChange("credentials.client_id", { credentials: { client_id: "typed", access_token: "stale" } }),
+ ).toStrictEqual({ credentials: { client_id: "typed" } });
+ });
+
+ it("exposes the changed top-level key so an upstream-field check can test membership", () => {
+ const changed = singleBranchChange("url", { url: "https://new", alias: "a" });
+ expect("url" in changed).toBe(true);
+ expect("alias" in changed).toBe(false);
+ });
+
+ it("builds an array for a numeric segment so a list row does not become an object keyed by index", () => {
+ expect(
+ singleBranchChange("static_headers.1.value", { static_headers: [{ value: "a" }, { value: "b" }] }),
+ ).toStrictEqual({ static_headers: [undefined, { value: "b" }] });
+ });
+
+ it("yields an undefined leaf rather than throwing when the path is not in the store", () => {
+ expect(singleBranchChange("credentials.client_secret", {})).toStrictEqual({
+ credentials: { client_secret: undefined },
+ });
+ });
+});
+
+describe("resetFields", () => {
+ it("restores the seeded value rather than clearing the key, so an edit reset keeps the saved server's credentials", () => {
+ const store = withForm({ credentials: { client_id: "saved", access_token: "tok" } }, (form) => {
+ form.setValue("credentials", { client_id: "typed" });
+ resetFields(form, ["credentials"], { credentials: { client_id: "saved", access_token: "tok" } });
+ });
+
+ expect(store.credentials).toStrictEqual({ client_id: "saved", access_token: "tok" });
+ });
+
+ it("clears the key when no seed is supplied, which is what the create form's blank store means", () => {
+ const store = withForm({ credentials: { client_id: "typed" } }, (form) => {
+ resetFields(form, ["credentials"]);
+ });
+
+ expect(store).toHaveProperty("credentials", undefined);
+ });
+});
+
+describe("setFieldsValue", () => {
+ it("writes an undefined leaf into the live store, so a transport switch really clears the field", () => {
+ const store = withForm({ url: "https://example.com", command: "npx" }, (form) => {
+ setFieldsValue(form, { url: undefined });
+ });
+
+ expect(store).toStrictEqual({ url: undefined, command: "npx" });
+ });
+
+ it("merges a nested write into the live store instead of replacing the whole object", () => {
+ const store = withForm({ credentials: { client_id: "kept", scopes: ["a"] } }, (form) => {
+ setFieldsValue(form, { credentials: { client_secret: "new" } });
+ });
+
+ expect(store.credentials).toStrictEqual({ client_id: "kept", scopes: ["a"], client_secret: "new" });
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.ts
new file mode 100644
index 00000000000..20df3e32273
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.ts
@@ -0,0 +1,107 @@
+import { useWatch } from "react-hook-form";
+import type { Control, UseFormReturn } from "react-hook-form";
+
+import {
+ projectMountedValues,
+ type MountRegistry,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
+
+export type McpForm = UseFormReturn;
+
+const isPlainObject = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype;
+
+export const deepMergedFieldsValue = (store: unknown, values: Record): Record =>
+ Object.entries(values).reduce>(
+ (merged, [key, value]) => ({
+ ...merged,
+ [key]: isPlainObject(value) ? deepMergedFieldsValue(merged[key], value) : value,
+ }),
+ isPlainObject(store) ? { ...store } : {},
+ );
+
+export const setFieldsValue = (form: McpForm, values: Record): void => {
+ const merged = deepMergedFieldsValue(form.getValues(), values);
+ Object.keys(values).forEach((key) => form.setValue(key, merged[key]));
+};
+
+export const resetFields = (form: McpForm, names: readonly string[], defaults: MountedFormValues = {}): void => {
+ names.forEach((name) => {
+ form.setValue(name, defaults[name]);
+ form.clearErrors(name);
+ });
+};
+
+const branchAt = (segments: readonly string[], leaf: unknown): unknown => {
+ const [head, ...rest] = segments;
+ if (head === undefined) {
+ return leaf;
+ }
+ const child = branchAt(rest, leaf);
+ if (!/^\d+$/.test(head)) {
+ return { [head]: child };
+ }
+ const index = Number(head);
+ return Array.from({ length: index + 1 }, (_, position) => (position === index ? child : undefined));
+};
+
+export const singleBranchChange = (path: string, values: MountedFormValues): Record => {
+ const segments = path.split(".");
+ const leaf = segments.reduce(
+ (node, segment) => (node === null || node === undefined ? undefined : (node as Record)[segment]),
+ values,
+ );
+ return branchAt(segments, leaf) as Record;
+};
+
+export interface McpStaticHeaderRow {
+ header?: string;
+ value?: string;
+}
+
+export interface McpEnvVarRow {
+ name?: string;
+ value?: string;
+ scope?: string;
+ description?: string;
+}
+
+export interface McpListValues {
+ static_headers: McpStaticHeaderRow[];
+ env_vars: McpEnvVarRow[];
+}
+
+export interface McpFormSnapshot extends MountedFormValues {
+ readonly server_name?: string;
+ readonly alias?: string;
+ readonly description?: string;
+ readonly url?: string;
+ readonly spec_path?: string;
+ readonly transport?: string;
+ readonly auth_type?: string;
+ readonly oauth_flow_type?: string;
+ readonly credentials?: Record;
+ readonly issuer?: string;
+ readonly authorization_url?: string;
+ readonly token_url?: string;
+ readonly registration_url?: string;
+ readonly mcp_access_groups?: string[];
+ readonly static_headers?: readonly McpStaticHeaderRow[];
+ readonly command?: string;
+ readonly args?: string[];
+ readonly env?: Record;
+}
+
+export const allFieldsValue = (form: McpForm): McpFormSnapshot => form.getValues() as McpFormSnapshot;
+
+export const listControl = (control: Control): Control =>
+ control as unknown as Control;
+
+export const mountedPaths = (registry: MountRegistry): readonly string[] =>
+ registry.mountedNames().map((name) => (Array.isArray(name) ? name.join(".") : (name as string)));
+
+export const useMountedValues = (form: McpForm, registry: MountRegistry): MountedFormValues => {
+ useWatch({ control: form.control });
+ return projectMountedValues(registry, form.getValues);
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx
index 4e85aa41cfc..14e26fa5393 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx
@@ -781,7 +781,7 @@ describe("MCPServerEdit (interactive OAuth)", () => {
});
// Note: The M2M flow hiding logic is tested via OAuthFormFields.test.tsx (isM2M prop directly),
- // since Form.useWatch doesn't synchronously reflect initialValues in jsdom.
+ // since a mounted-values read doesn't synchronously reflect the seeded defaults in jsdom.
it("pre-populates token_validation_json from existing server token_validation", async () => {
const tokenValidation = { organization: "my-org", "team.id": "123" };
@@ -1003,7 +1003,7 @@ describe("MCPServerEdit (interactive OAuth)", () => {
fireEvent.click(saveButtons[0]);
});
- // The Form.Item inline validator intercepts invalid JSON before handleSave runs,
+ // The field's inline validator intercepts invalid JSON before handleSave runs,
// so the inline error message appears and updateMCPServer is never called.
await waitFor(() => {
expect(screen.getByText("Must be valid JSON")).toBeInTheDocument();
@@ -2190,7 +2190,7 @@ describe("MCPServerEdit (dcr_bridge toggle)", () => {
});
expect(getDcrToggle()).toHaveAttribute("aria-checked", "true");
- // The Form.Item stays mounted across the two client-forwarded modes, so the live toggle value is
+ // The field stays mounted across the two client-forwarded modes, so the live toggle value is
// preserved rather than forced false by the switch.
await selectAntOption("Authentication", "OAuth Delegate (client-supplied upstream token)");
await waitFor(() => {
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 c6ebde13bb8..3892b9d37b7 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,5 +1,6 @@
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 { FormProvider, useForm } from "react-hook-form";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -44,6 +45,23 @@ import { validateMCPServerUrl, validateMCPServerName, normalizeToolOverrideMap }
import { EditServerFormValues, buildEditServerPayload, editPayloadErrorMessage } from "./editServerPayload";
import { toast } from "@/lib/toast";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
+import {
+ MountedFormField,
+ MountedFormProvider,
+ projectMountedValues,
+ useMountRegistry,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
+import { antdRequired, antdRules } from "@/components/common_components/antdFormRules";
+import {
+ allFieldsValue,
+ mountedPaths,
+ resetFields,
+ setFieldsValue,
+ singleBranchChange,
+ useMountedValues,
+} from "./mcpFormStore";
+import { numberControl, notOnlyWhitespace, parsesAsJsonObject, selectControl, textControl } from "./mcpFieldRules";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
interface MCPServerEditProps {
@@ -66,174 +84,6 @@ 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 initialStaticHeaders = React.useMemo(() => {
if (!mcpServer.static_headers) {
return [];
@@ -292,6 +142,176 @@ const MCPServerEdit: React.FC = ({
[mcpServer, effectiveTransport, initialStaticHeaders, initialEnvVars, initialEnvJson],
);
+ const form = useForm({ mode: "onChange", defaultValues: initialValues });
+ const registry = useMountRegistry();
+ const mountedValues = useMountedValues(form, 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 = mountedValues.auth_type as string | undefined;
+ const transportType = mountedValues.transport 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 = mountedValues.oauth_flow_type 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 = mountedValues.delegate_auth_to_upstream as boolean | undefined;
+ const isDelegateAuth = delegateAuthWatched ?? Boolean(mcpServer.delegate_auth_to_upstream);
+
+ // Watch form fields that affect tool fetching
+ const currentUrl = mountedValues.url;
+ const currentSpecPath = mountedValues.spec_path;
+ const currentServerName = mountedValues.server_name;
+ const currentAuthType = mountedValues.auth_type;
+ const currentStaticHeaders = mountedValues.static_headers;
+ const currentCredentials = mountedValues.credentials;
+ const currentIssuer = mountedValues.issuer;
+ const currentAuthorizationUrl = mountedValues.authorization_url;
+ const currentTokenUrl = mountedValues.token_url;
+ const currentRegistrationUrl = mountedValues.registration_url;
+ 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 = allFieldsValue(form);
+ 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 = () => allFieldsValue(form).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: () => allFieldsValue(form).credentials,
+ getTemporaryPayload: () => {
+ const values = allFieldsValue(form);
+ 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(allFieldsValue(form));
+ 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 = (allFieldsValue(form).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(allFieldsValue(form));
+
+ toast.success("OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.");
+ },
+ onBeforeRedirect: persistEditUiState,
+ flowSource: "edit",
+ });
+
// 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
@@ -303,7 +323,7 @@ const MCPServerEdit: React.FC = ({
return;
}
syncedServerIdRef.current = mcpServer.server_id;
- form.setFieldsValue(initialValues);
+ setFieldsValue(form, initialValues);
// 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.
@@ -394,11 +414,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 !== allFieldsValue(form).transport) {
+ setFieldsValue(form, { transport });
return;
}
- form.setFieldsValue(pendingRestoredValues);
+ setFieldsValue(form, pendingRestoredValues);
setPendingRestoredValues(null);
}, [pendingRestoredValues, form, mcpServer.transport, transportType]);
@@ -407,7 +427,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 +459,16 @@ 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(allFieldsValue(form).credentials);
+ resetFields(form, [...CLEARED_ON_INVALIDATION], initialValues as MountedFormValues);
if (keptAdminCredentials) {
- form.setFieldsValue({ credentials: keptAdminCredentials });
+ setFieldsValue(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);
+ setFieldsValue(form, preserved);
}
};
@@ -463,12 +483,12 @@ 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(allFieldsValue(form).credentials) !== undefined;
if (upstreamChanged && hasDeclaredApp) {
setAppMayNotMatchUpstream(true);
}
}
- if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentityRef.current)) {
+ if (isHeldOAuthTokenStale(allFieldsValue(form), authorizedIdentityRef.current)) {
clearHeldOAuthToken(changedValues);
}
};
@@ -492,7 +512,7 @@ const MCPServerEdit: React.FC = ({
setIsLoadingTools(true);
setToolsError(null);
try {
- const values = form.getFieldsValue(true);
+ const values = allFieldsValue(form);
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 +651,7 @@ const MCPServerEdit: React.FC = ({
token_url: undefined,
registration_url: undefined,
};
- form.setFieldsValue(clearedForStdio);
+ setFieldsValue(form, clearedForStdio);
} else if (value === TRANSPORT.OPENAPI) {
const clearedForOpenapi = {
url: undefined,
@@ -640,9 +660,9 @@ const MCPServerEdit: React.FC = ({
env_json: undefined,
stdio_config: undefined,
};
- form.setFieldsValue(clearedForOpenapi);
+ setFieldsValue(form, clearedForOpenapi);
} else {
- form.setFieldsValue({
+ setFieldsValue(form, {
spec_path: undefined,
command: undefined,
args: undefined,
@@ -650,11 +670,32 @@ const MCPServerEdit: React.FC = ({
stdio_config: undefined,
});
}
- if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentityRef.current)) {
+ if (isHeldOAuthTokenStale(allFieldsValue(form), authorizedIdentityRef.current)) {
clearHeldOAuthToken();
}
};
+ const valuesChangeRef = React.useRef(handleFormValuesChange);
+ valuesChangeRef.current = handleFormValuesChange;
+
+ React.useEffect(() => {
+ const subscription = form.watch((values, { name, type }) => {
+ if (type !== "change" || name === undefined) {
+ return;
+ }
+ valuesChangeRef.current(singleBranchChange(name, values as MountedFormValues));
+ });
+ return () => subscription.unsubscribe();
+ }, [form]);
+
+ const submitForm = async () => {
+ const isValid = await form.trigger(mountedPaths(registry) as string[]);
+ if (!isValid) {
+ return;
+ }
+ await handleSave(projectMountedValues(registry, form.getValues) as unknown as EditServerFormValues);
+ };
+
const handleSave = async (values: EditServerFormValues) => {
if (!accessToken) return;
try {
@@ -732,452 +773,510 @@ 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"));
- }
- },
- },
- ]}
- >
-
-
-
- {/* 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.{" "}
-
- 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"]}
- >
-
-
- >
- )}
-
- {/* Environment Variables Section */}
-
-
-
-
- {/* Permission Management / Access Control Section */}
-
-
-
-
- {/* Tool Configuration Section */}
-
-
+
+
+ >
+ validateMCPServerName(value) }) }}
+ >
+ {(control) => (
+
+ )}
+
+ validateMCPServerName(value) }) }}
+ >
+ {(control) => (
+ {
+ control.onChange(event);
+ setAliasManuallyEdited(true);
+ }}
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+
+ {(control) => (
+
+ )}
+
+
+
+ {(control) => (
+ (control)}
+ onChange={(value: string) => {
+ control.onChange(value);
+ handleTransportChange(value);
+ }}
+ >
+ Streamable HTTP (Recommended)
+ Server-Sent Events (SSE)
+ Standard Input/Output (stdio)
+ OpenAPI Spec
+
+ )}
+
-
-
Cancel
-
Save Changes
-
-
+ {/* URL field - only for HTTP/SSE */}
+ {isMCPTransport && (
+ validateMCPServerUrl(value) }),
+ },
+ }}
+ >
+ {(control) => (
+
+ )}
+
+ )}
+
+ {/* OpenAPI Spec URL - only for OpenAPI transport */}
+ {isOpenAPITransport && (
+
+ OpenAPI Spec URL
+
+
+
+
+ }
+ name="spec_path"
+ required
+ rules={{ validate: { required: antdRequired("Please enter an OpenAPI spec URL") } }}
+ >
+ {(control) => (
+
+ )}
+
+ )}
+
+
+ Max Concurrent Requests (optional)
+
+
+
+
+ }
+ name="max_concurrent_requests"
+ >
+ {(control) => (
+
+ )}
+
+
+ {/* Authentication - for HTTP, SSE, and OpenAPI */}
+ {!isStdioTransport && (
+ <>
+
+ {(control) => (
+ (control)} 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)
+
+
+ )}
+
+
+
+ >
+ )}
+
+ {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.
+
+
+
+ {(control) => (
+
+ )}
+
+
+
+ {(control) => (
+ (control)}
+ mode="tags"
+ size="large"
+ tokenSeparators={[","]}
+ placeholder="Add args (press enter or comma)"
+ className="rounded-lg"
+ />
+ )}
+
+
+
+ {(control) => (
+
+ )}
+
+
+ {/* Optional JSON config (if provided, it overrides command/args/env on save) */}
+
+
+ )}
+
+ {!isStdioTransport && shouldShowAuthValueField && (
+
+ Authentication Value
+
+
+
+
+ }
+ name={["credentials", "auth_value"]}
+ rules={{ validate: { notWhitespace: notOnlyWhitespace("Authentication value cannot be empty") } }}
+ >
+ {(control) => (
+
+ )}
+
+ )}
+
+ {!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"]}
+ >
+ {(control) => (
+
+ )}
+
+
+ AWS Service Name
+
+
+
+
+ }
+ name={["credentials", "aws_service_name"]}
+ >
+ {(control) => (
+
+ )}
+
+
+ AWS Access Key ID
+
+
+
+
+ }
+ name={["credentials", "aws_access_key_id"]}
+ >
+ {(control) => (
+
+ )}
+
+
+ AWS Secret Access Key
+
+
+
+
+ }
+ name={["credentials", "aws_secret_access_key"]}
+ >
+ {(control) => (
+
+ )}
+
+
+ AWS Session Token
+
+
+
+
+ }
+ name={["credentials", "aws_session_token"]}
+ >
+ {(control) => (
+
+ )}
+
+
+ AWS Role ARN
+
+
+
+
+ }
+ name={["credentials", "aws_role_name"]}
+ >
+ {(control) => (
+
+ )}
+
+
+ AWS Session Name
+
+
+
+
+ }
+ name={["credentials", "aws_session_name"]}
+ >
+ {(control) => (
+
+ )}
+
+ >
+ )}
+
+ {/* 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 +1285,7 @@ const MCPServerEdit: React.FC = ({
Cancel
-
form.submit()}>Save Changes
+
void submitForm()}>Save Changes
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts
index 5f06d03d595..dd9c8db6d30 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts
@@ -482,7 +482,7 @@ describe("projection shape", () => {
expect(projected.command).toBe("npx");
});
- it("passes Form.List rows through whole, since antd does not project a row to its mounted sub-fields", () => {
+ it("passes list rows through whole, since a list field is projected as one key and not per mounted sub-field", () => {
const row = { name: "N", value: "V", scope: "user", description: "D" };
const projected = projectMountedEditValues({ ...HTTP_NONE, env_vars: [row] });
expect(projected.env_vars).toStrictEqual([row]);
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..c1692f436d7 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/components/common_components/MountedFormField.test.ts b/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.ts
deleted file mode 100644
index b619ec5748a..00000000000
--- a/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { UseFormGetValues } from "react-hook-form";
-
-import {
- projectMountedValues,
- type MountedFieldName,
- type MountedFormValues,
- type MountRegistry,
-} from "./MountedFormField";
-
-const registryOf = (names: readonly MountedFieldName[]): MountRegistry => ({
- register: () => () => undefined,
- mountedNames: () => names,
-});
-
-const getValuesOf = (store: Readonly>): UseFormGetValues =>
- ((names: readonly string[]) => names.map((name) => store[name])) as unknown as UseFormGetValues;
-
-const project = (store: Readonly>) =>
- projectMountedValues(registryOf(Object.keys(store)), getValuesOf(store));
-
-const projectPaths = (entries: readonly (readonly [MountedFieldName, unknown])[]) => {
- const store = Object.fromEntries(
- entries.map(([name, value]) => [Array.isArray(name) ? name.join(".") : (name as string), value]),
- );
- return projectMountedValues(registryOf(entries.map(([name]) => name)), getValuesOf(store));
-};
-
-describe("projectMountedValues", () => {
- it("keeps a flat name flat", () => {
- expect(project({ server_name: "s1", transport: "http" })).toStrictEqual({ server_name: "s1", transport: "http" });
- });
-
- it("nests an ARRAY name into a credentials object", () => {
- expect(
- projectPaths([
- [["credentials", "aws_region_name"], "us-east-1"],
- [["credentials", "aws_access_key_id"], "AKIA"],
- ]),
- ).toStrictEqual({ credentials: { aws_region_name: "us-east-1", aws_access_key_id: "AKIA" } });
- });
-
- it("keeps a literal dotted STRING name flat, matching antd getNamePath toArray", () => {
- expect(projectPaths([["a.b", 1]])).toStrictEqual({ "a.b": 1 });
- expect(projectPaths([["schema.property.with.dots", "v"]])).toStrictEqual({ "schema.property.with.dots": "v" });
- });
-
- it("rebuilds Form.List rows as an array, not an object keyed by digits", () => {
- const projected = projectPaths([
- [["env_vars", "0", "name"], "API_KEY"],
- [["env_vars", "0", "description"], "the key"],
- [["env_vars", "1", "name"], "REGION"],
- ]);
- expect(projected).toStrictEqual({
- env_vars: [{ name: "API_KEY", description: "the key" }, { name: "REGION" }],
- });
- expect(Array.isArray(projected.env_vars)).toBe(true);
- });
-
- it("rebuilds static_headers rows, the second Form.List site", () => {
- expect(
- projectPaths([
- [["static_headers", "0", "key"], "X-Tenant"],
- [["static_headers", "0", "value"], "acme"],
- ]),
- ).toStrictEqual({
- static_headers: [{ key: "X-Tenant", value: "acme" }],
- });
- });
-
- it("emits a mounted-but-unset field as a key holding undefined, matching antd onFinish", () => {
- const projected = project({ alias: undefined });
- expect(Object.keys(projected)).toStrictEqual(["alias"]);
- expect(projected.alias).toBeUndefined();
- });
-
- it("leaves a sparse row index as a hole rather than shifting later rows down", () => {
- const projected = projectPaths([[["env_vars", "2", "name"], "THIRD"]]) as { env_vars: readonly unknown[] };
- expect(projected.env_vars).toHaveLength(3);
- expect(projected.env_vars[2]).toStrictEqual({ name: "THIRD" });
- });
-
- it("mixes flat, nested and list names in one projection", () => {
- expect(
- projectPaths([
- ["transport", "http"],
- [["credentials", "client_id"], "cid"],
- [["env_vars", "0", "name"], "K"],
- ]),
- ).toStrictEqual({
- transport: "http",
- credentials: { client_id: "cid" },
- env_vars: [{ name: "K" }],
- });
- });
-});
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..62cdd935131
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.tsx
@@ -0,0 +1,187 @@
+import React from "react";
+import { render, renderHook, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { useForm } from "react-hook-form";
+import type { UseFormGetValues } from "react-hook-form";
+
+import {
+ MountedFormField,
+ MountedFormProvider,
+ projectMountedValues,
+ useMountRegistry,
+ type MountedFieldName,
+ type MountedFormValues,
+ type MountRegistry,
+} from "./MountedFormField";
+
+const registryOf = (names: readonly MountedFieldName[]): MountRegistry => ({
+ register: () => () => undefined,
+ mountedNames: () => names,
+});
+
+const getValuesOf = (store: Readonly>): UseFormGetValues =>
+ ((names: readonly string[]) => names.map((name) => store[name])) as unknown as UseFormGetValues;
+
+const project = (store: Readonly>) =>
+ projectMountedValues(registryOf(Object.keys(store)), getValuesOf(store));
+
+const projectPaths = (entries: readonly (readonly [MountedFieldName, unknown])[]) => {
+ const store = Object.fromEntries(
+ entries.map(([name, value]) => [Array.isArray(name) ? name.join(".") : (name as string), value]),
+ );
+ return projectMountedValues(registryOf(entries.map(([name]) => name)), getValuesOf(store));
+};
+
+describe("projectMountedValues", () => {
+ it("keeps a flat name flat", () => {
+ expect(project({ server_name: "s1", transport: "http" })).toStrictEqual({ server_name: "s1", transport: "http" });
+ });
+
+ it("nests an ARRAY name into a credentials object", () => {
+ expect(
+ projectPaths([
+ [["credentials", "aws_region_name"], "us-east-1"],
+ [["credentials", "aws_access_key_id"], "AKIA"],
+ ]),
+ ).toStrictEqual({ credentials: { aws_region_name: "us-east-1", aws_access_key_id: "AKIA" } });
+ });
+
+ it("keeps a literal dotted STRING name flat, matching antd getNamePath toArray", () => {
+ expect(projectPaths([["a.b", 1]])).toStrictEqual({ "a.b": 1 });
+ expect(projectPaths([["schema.property.with.dots", "v"]])).toStrictEqual({ "schema.property.with.dots": "v" });
+ });
+
+ it("rebuilds Form.List rows as an array, not an object keyed by digits", () => {
+ const projected = projectPaths([
+ [["env_vars", "0", "name"], "API_KEY"],
+ [["env_vars", "0", "description"], "the key"],
+ [["env_vars", "1", "name"], "REGION"],
+ ]);
+ expect(projected).toStrictEqual({
+ env_vars: [{ name: "API_KEY", description: "the key" }, { name: "REGION" }],
+ });
+ expect(Array.isArray(projected.env_vars)).toBe(true);
+ });
+
+ it("rebuilds static_headers rows, the second Form.List site", () => {
+ expect(
+ projectPaths([
+ [["static_headers", "0", "key"], "X-Tenant"],
+ [["static_headers", "0", "value"], "acme"],
+ ]),
+ ).toStrictEqual({
+ static_headers: [{ key: "X-Tenant", value: "acme" }],
+ });
+ });
+
+ it("emits a mounted-but-unset field as a key holding undefined, matching antd onFinish", () => {
+ const projected = project({ alias: undefined });
+ expect(Object.keys(projected)).toStrictEqual(["alias"]);
+ expect(projected.alias).toBeUndefined();
+ });
+
+ it("leaves a sparse row index as a hole rather than shifting later rows down", () => {
+ const projected = projectPaths([[["env_vars", "2", "name"], "THIRD"]]) as { env_vars: readonly unknown[] };
+ expect(projected.env_vars).toHaveLength(3);
+ expect(projected.env_vars[2]).toStrictEqual({ name: "THIRD" });
+ });
+
+ it("mixes flat, nested and list names in one projection", () => {
+ expect(
+ projectPaths([
+ ["transport", "http"],
+ [["credentials", "client_id"], "cid"],
+ [["env_vars", "0", "name"], "K"],
+ ]),
+ ).toStrictEqual({
+ transport: "http",
+ credentials: { client_id: "cid" },
+ env_vars: [{ name: "K" }],
+ });
+ });
+});
+
+describe("useMountRegistry lifecycle", () => {
+ const GatedForm: React.FC<{
+ showOptional: boolean;
+ showRequired: boolean;
+ onFinish: (v: MountedFormValues) => void;
+ }> = ({ showOptional, showRequired, onFinish }) => {
+ const form = useForm({ mode: "onChange", defaultValues: { server_name: "keep" } });
+ const registry = useMountRegistry();
+ return (
+
+
+
+ );
+ };
+
+ it("drops a field's key from the submitted payload once its gate unmounts it", async () => {
+ const onFinish = vi.fn();
+ const { rerender } = render( );
+
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
+ await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
+ expect(Object.keys(onFinish.mock.calls[0][0] as object)).toContain("alias");
+
+ rerender( );
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
+ await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(2));
+ expect(Object.keys(onFinish.mock.calls[1][0] as object)).not.toContain("alias");
+ });
+
+ it("submits after a required field is unmounted, rather than validating a field the user can no longer see", async () => {
+ const onFinish = vi.fn();
+ const { rerender } = render( );
+
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
+ expect(await screen.findByText("Token URL is required")).toBeInTheDocument();
+ expect(onFinish).not.toHaveBeenCalled();
+
+ rerender( );
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
+ await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
+ expect(Object.keys(onFinish.mock.calls[0][0] as object)).not.toContain("token_url");
+ });
+
+ it("keeps a name mounted while a second field still holds a registration on it", () => {
+ const registry = renderHook(() => useMountRegistry()).result.current;
+ const releaseFirst = registry.register("credentials.scopes");
+ registry.register("credentials.scopes");
+
+ releaseFirst();
+
+ expect(registry.mountedNames()).toStrictEqual(["credentials.scopes"]);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx
index 0ae58e102e7..f0efa885d92 100644
--- a/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx
+++ b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx
@@ -94,6 +94,11 @@ export const projectMountedValues = (
);
};
+export const useMountedName = (name: MountedFieldName): void => {
+ const { registry } = React.useContext(MountedFormContext);
+ React.useEffect(() => registry.register(name), [registry, name]);
+};
+
export type MountedFieldControlProps = {
readonly id: string;
readonly name: string;
@@ -131,9 +136,9 @@ export const MountedFormField: React.FC = ({
className,
children,
}) => {
- const { control, registry } = React.useContext(MountedFormContext);
+ const { control } = React.useContext(MountedFormContext);
const path = fieldKey(name);
- React.useEffect(() => registry.register(name), [registry, name]);
+ useMountedName(name);
const helpId = `${path}_help`;
const hasHelp = help !== undefined && help !== null;