From ad3f324f3dc338862fb38995892eb72826324faf Mon Sep 17 00:00:00 2001
From: Yuneng Jiang
Date: Tue, 18 Aug 2026 22:32:38 -0700
Subject: [PATCH] refactor(ui): port MCP server forms from antd Form to
react-hook-form
The MCP server create and edit screens carry the dashboard's largest antd
`Form` graph: 92 `Form.Item`s across 14 files, against 39 in the next
largest. This replaces `Form` and `Form.Item` with react-hook-form plus
the shared shadcn field wrapper, and leaves every antd widget in place,
so the rendered form is unchanged.
antd submits only the fields that are currently mounted, which the
conditional transport and auth sections rely on. react-hook-form keeps
unmounted values instead, so the port adds an explicit mount registry and
projects the store through it at submit time. `MountedFormField` also
reproduces the antd behaviours the graph depended on: an unmounted watch
reading as undefined, `setFieldsValue` deep-merging objects while
replacing arrays, and `resetFields` restoring values for names that have
no registered field.
Field-level `initialValue` props do not survive the port, so the four
defaults they carried now live in `defaultValues`. The edit form builds
`defaultValues` from named keys rather than spreading the server record,
which keeps read-only columns such as `created_at` and `approval_status`
out of the store as well as out of the payload.
The registry itself is shared with the create key port, so it keeps that
lane's reference counting: a name stays mounted while any field still
binds it, which matters here because 23 of the 58 names are bound in more
than one place. `projectMountedValues` accepts either a store or a
`getValues` function so both call styles keep working.
Removes an auth hint in OpenApiByokFields that sat inside a block already
guarded by the same condition and could never render.
---
ui/litellm-dashboard/eslint-suppressions.json | 28 +-
.../_components/AwsSigV4Fields.tsx | 299 ++--
.../_components/CreateMCPServer.tsx | 741 +++++----
.../_components/DcrBridgeToggle.tsx | 18 +-
.../_components/EnvVarsSection.tsx | 165 +-
.../_components/IdJagFormFields.tsx | 184 ++-
.../MCPPermissionManagement.test.tsx | 66 +-
.../_components/MCPPermissionManagement.tsx | 248 +--
.../_components/OAuthFormFields.test.tsx | 24 +-
.../_components/OAuthFormFields.tsx | 275 ++--
.../_components/OpenAPIFormSection.tsx | 56 +-
.../_components/OpenApiByokFields.tsx | 163 +-
.../PassthroughAuthorizeSection.test.tsx | 16 +-
.../PassthroughAuthorizeSection.tsx | 43 +-
.../_components/StdioConfiguration.tsx | 44 +-
.../TokenEndpointAuthMethodField.tsx | 30 +-
.../_components/TokenExchangeFormFields.tsx | 206 +--
.../_components/mcp_server_edit.tsx | 1377 +++++++++--------
.../mcp-servers/_components/testUtils.ts | 1 +
.../mcp-servers/_components/utils.tsx | 12 +
.../MountedFormField.test.tsx | 232 +++
.../common_components/MountedFormField.tsx | 275 ++++
22 files changed, 2731 insertions(+), 1772 deletions(-)
create mode 100644 ui/litellm-dashboard/src/components/common_components/MountedFormField.test.tsx
create mode 100644 ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx
diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json
index 3f32048cac1..23491515968 100644
--- a/ui/litellm-dashboard/eslint-suppressions.json
+++ b/ui/litellm-dashboard/eslint-suppressions.json
@@ -558,9 +558,6 @@
},
"no-restricted-imports": {
"count": 1
- },
- "react-hooks/set-state-in-effect": {
- "count": 4
}
},
"src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx": {
@@ -583,11 +580,6 @@
"count": 2
}
},
- "src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx": {
"no-restricted-imports": {
"count": 1
@@ -609,11 +601,6 @@
"count": 1
}
},
- "src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": {
"no-nested-ternary": {
"count": 1
@@ -627,7 +614,7 @@
"count": 1
},
"no-restricted-imports": {
- "count": 2
+ "count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": {
@@ -640,11 +627,6 @@
"count": 1
}
},
- "src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx": {
"no-restricted-imports": {
"count": 1
@@ -732,12 +714,6 @@
},
"no-restricted-imports": {
"count": 1
- },
- "react-hooks/immutability": {
- "count": 1
- },
- "react-hooks/set-state-in-effect": {
- "count": 5
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": {
@@ -3014,4 +2990,4 @@
"count": 1
}
}
-}
+}
\ No newline at end of file
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/AwsSigV4Fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/AwsSigV4Fields.tsx
index d4ae537bffa..a4b2408ac9e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/AwsSigV4Fields.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/AwsSigV4Fields.tsx
@@ -1,155 +1,156 @@
import React from "react";
-import { Form, Input, Tooltip } from "antd";
+import { Input, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { useFormContext } from "react-hook-form";
+import { MountedFormField, bindControl, type MountedFormValues } from "@/components/common_components/MountedFormField";
-const AwsSigV4Fields: React.FC = () => (
- <>
-
- For MCP servers hosted on AWS Bedrock AgentCore.{" "}
-
- View docs →
-
-
-
- AWS Region
-
-
-
-
- }
- name={["credentials", "aws_region_name"]}
- rules={[{ required: true, message: "AWS region is required for SigV4 auth" }]}
- >
-
-
-
- AWS Service Name
-
-
-
-
- }
- name={["credentials", "aws_service_name"]}
- >
-
-
-
- AWS Access Key ID
-
-
-
-
- }
- name={["credentials", "aws_access_key_id"]}
- dependencies={[["credentials", "aws_secret_access_key"]]}
- rules={[
- ({ getFieldValue }) => ({
- validator(_, value) {
- const secretKey = getFieldValue(["credentials", "aws_secret_access_key"]);
- if (secretKey && !value) {
- return Promise.reject(new Error("Access Key ID is required when Secret Access Key is provided"));
- }
- return Promise.resolve();
- },
- }),
- ]}
- >
-
-
-
- AWS Secret Access Key
-
-
-
-
- }
- name={["credentials", "aws_secret_access_key"]}
- dependencies={[["credentials", "aws_access_key_id"]]}
- rules={[
- ({ getFieldValue }) => ({
- validator(_, value) {
- const accessKeyId = getFieldValue(["credentials", "aws_access_key_id"]);
- if (accessKeyId && !value) {
- return Promise.reject(new Error("Secret Access Key is required when Access Key ID is provided"));
- }
- return Promise.resolve();
- },
- }),
- ]}
- >
-
-
-
- AWS Session Token
-
-
-
-
- }
- name={["credentials", "aws_session_token"]}
- >
-
-
-
- AWS Role ARN
-
-
-
-
- }
- name={["credentials", "aws_role_name"]}
- >
-
-
-
- AWS Session Name
-
-
-
-
- }
- name={["credentials", "aws_session_name"]}
- >
-
-
- >
+const fieldClassName = "rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500";
+
+const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, tooltip }) => (
+
+ {label}
+
+
+
+
);
+const AwsSigV4Fields: React.FC = () => {
+ const { getValues } = useFormContext();
+
+ return (
+ <>
+
+ For MCP servers hosted on AWS Bedrock AgentCore.{" "}
+
+ View docs →
+
+
+ }
+ name="credentials.aws_region_name"
+ required
+ rules={{ required: "AWS region is required for SigV4 auth" }}
+ >
+ {(field) => (
+ (field)} placeholder="us-east-1" className={fieldClassName} />
+ )}
+
+
+ }
+ name="credentials.aws_service_name"
+ >
+ {(field) => (
+ (field)}
+ placeholder="bedrock-agentcore"
+ className={fieldClassName}
+ />
+ )}
+
+
+ }
+ name="credentials.aws_access_key_id"
+ rules={{
+ deps: ["credentials.aws_secret_access_key"],
+ validate: (value) =>
+ getValues("credentials.aws_secret_access_key") && !value
+ ? "Access Key ID is required when Secret Access Key is provided"
+ : true,
+ }}
+ >
+ {(field) => (
+ (field)}
+ placeholder="AKIA... (optional — uses IAM role if blank)"
+ className={fieldClassName}
+ />
+ )}
+
+
+ }
+ name="credentials.aws_secret_access_key"
+ rules={{
+ deps: ["credentials.aws_access_key_id"],
+ validate: (value) =>
+ getValues("credentials.aws_access_key_id") && !value
+ ? "Secret Access Key is required when Access Key ID is provided"
+ : true,
+ }}
+ >
+ {(field) => (
+ (field)}
+ placeholder="Enter secret key (optional — uses IAM role if blank)"
+ className={fieldClassName}
+ />
+ )}
+
+ }
+ name="credentials.aws_session_token"
+ >
+ {(field) => (
+ (field)}
+ placeholder="Enter session token (optional)"
+ className={fieldClassName}
+ />
+ )}
+
+
+ }
+ name="credentials.aws_role_name"
+ >
+ {(field) => (
+ (field)}
+ placeholder="arn:aws:iam::123456789012:role/MyRole (optional)"
+ className={fieldClassName}
+ />
+ )}
+
+
+ }
+ name="credentials.aws_session_name"
+ >
+ {(field) => (
+ (field)}
+ placeholder="litellm-prod (optional, auto-generated if blank)"
+ className={fieldClassName}
+ />
+ )}
+
+ >
+ );
+};
+
export default AwsSigV4Fields;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx
index ed8d3bddc98..ac7960c1884 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx
@@ -1,6 +1,18 @@
import React, { useState } from "react";
-import { Modal, Tooltip, Form, Select, Input as AntdInput, InputNumber, Collapse } from "antd";
+import { Modal, Tooltip, Select, Input as AntdInput, InputNumber, Collapse } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { FormProvider, useForm } from "react-hook-form";
+import {
+ MountedFormField,
+ MountedFormProvider,
+ applyFieldValues,
+ bindControl,
+ changedValuesFor,
+ projectMountedValues,
+ resetFieldsToDefaults,
+ useMountRegistry,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
@@ -45,7 +57,7 @@ import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection";
import MCPLogoSelector from "./MCPLogoSelector";
import EnvVarsSection from "./EnvVarsSection";
import { isAdminRole } from "@/utils/roles";
-import { validateMCPServerUrl, validateMCPServerName } from "./utils";
+import { antdValidator, validateMCPServerUrl, validateMCPServerName } from "./utils";
import { toast } from "@/lib/toast";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
import { useTestMCPConnection } from "@/hooks/useTestMCPConnection";
@@ -76,6 +88,16 @@ const payloadErrorMessage = (result: Exclude = ({
userID,
userRole,
@@ -87,7 +109,8 @@ const CreateMCPServer: React.FC = ({
prefillData,
onBackToDiscovery,
}) => {
- const [form] = Form.useForm();
+ const form = useForm({ defaultValues: CREATE_DEFAULTS });
+ const registry = useMountRegistry();
const [isLoading, setIsLoading] = useState(false);
const [costConfig, setCostConfig] = useState({});
const [formValues, setFormValues] = useState>({});
@@ -147,7 +170,7 @@ const CreateMCPServer: React.FC = ({
const persistCreateUiState = () => {
writeCreateUiSnapshot({
modalVisible: isModalVisible,
- formValues: form.getFieldsValue(true),
+ formValues: form.getValues() as Record,
transportType,
costConfig,
allowedTools,
@@ -170,11 +193,11 @@ const CreateMCPServer: React.FC = ({
// Merge the ref-held DCR client so a re-authorize reuses the registered client instead of
// re-registering; the form store itself never holds the DCR client (see onTokenReceived).
getCredentials: () => ({
- ...((form.getFieldValue("credentials") as Record | undefined) ?? {}),
+ ...((form.getValues("credentials") as Record | undefined) ?? {}),
...(dcrClientRef.current ?? {}),
}),
getTemporaryPayload: () => {
- const values = form.getFieldsValue(true);
+ const values: Record = form.getValues();
const transport = values.transport || transportType;
// For OpenAPI transport the form has spec_path instead of url.
// We pass the spec_path as url so the temp-session endpoint has something
@@ -218,12 +241,12 @@ const CreateMCPServer: React.FC = ({
return;
}
- if (isClientForwardedTokenMode(form.getFieldValue("auth_type"))) {
+ if (isClientForwardedTokenMode(form.getValues("auth_type") as string | undefined)) {
// Browser-only modes: the token is held in local state (oauthAccessToken) for tool preview
// and committed to sessionStorage on submit; it must never be written into form.credentials,
// which would persist it as server-level credentials on the created server row. Mirrors the
// edit form's onTokenReceived early return.
- setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true)));
+ setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getValues()));
toast.success(
"Token held for this browser session. Tools can now be previewed and configured; the token is not saved to LiteLLM.",
);
@@ -240,7 +263,7 @@ const CreateMCPServer: React.FC = ({
}
: null;
- const current = (form.getFieldValue("credentials") as Record | undefined) ?? {};
+ const current = (form.getValues("credentials") as Record | undefined) ?? {};
const nextCredentials = {
...(preservedAdminCredentials(current) ?? {}),
...(current.scopes !== undefined && { scopes: current.scopes }),
@@ -252,10 +275,10 @@ const CreateMCPServer: React.FC = ({
// Path-replace (not deep-merge) so a re-authorize with fewer token fields does not leave stale
// siblings from the previous token behind; the admin-typed client keys and scopes are carried
// explicitly above.
- form.setFieldValue("credentials", nextCredentials);
+ form.setValue("credentials", nextCredentials);
// Capture the identity AFTER writing the token so the held token is not spuriously invalidated by
// its own credential write.
- setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true)));
+ setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getValues()));
toast.success("OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.");
},
@@ -277,10 +300,12 @@ const CreateMCPServer: React.FC = ({
// Capture the admin-typed app before resetFields destroys it, then re-apply it: the app is
// upstream-scoped config, not minted material, so it survives every invalidation (the token is
// what gets discarded). Token-shaped keys are excluded by the helper's key filter.
- const keptAdminCredentials = preservedAdminCredentials(form.getFieldValue("credentials"));
- form.resetFields([...CLEARED_ON_INVALIDATION]);
+ const keptAdminCredentials = preservedAdminCredentials(
+ form.getValues("credentials") as Record | undefined,
+ );
+ resetFieldsToDefaults(form, CREATE_DEFAULTS, CLEARED_ON_INVALIDATION);
if (keptAdminCredentials) {
- form.setFieldsValue({ credentials: keptAdminCredentials });
+ applyFieldValues(form, { credentials: keptAdminCredentials });
}
// Re-apply the in-flight edit last; rc-field-form deep-merges nested objects, so a changed
// credentials sub-field composes with the preserved sibling instead of replacing the object.
@@ -288,7 +313,7 @@ const CreateMCPServer: React.FC = ({
CLEARED_ON_INVALIDATION.filter((key) => key in changedValues).map((key) => [key, changedValues[key]]),
);
if (Object.keys(preserved).length > 0) {
- form.setFieldsValue(preserved);
+ applyFieldValues(form, preserved);
}
};
@@ -337,7 +362,7 @@ const CreateMCPServer: React.FC = ({
// wait until transportType state catches up so the URL field is mounted
return;
}
- form.setFieldsValue(pendingRestoredValues.values);
+ applyFieldValues(form, pendingRestoredValues.values);
setFormValues(pendingRestoredValues.values);
setPendingRestoredValues(null);
}, [pendingRestoredValues, form, transportType]);
@@ -381,7 +406,7 @@ const CreateMCPServer: React.FC = ({
prefillValues.url = prefillData.url;
}
- form.setFieldsValue(prefillValues);
+ applyFieldValues(form, prefillValues);
setFormValues(prefillValues);
setAliasManuallyEdited(false);
}, [isModalVisible, prefillData, form]);
@@ -446,7 +471,7 @@ const CreateMCPServer: React.FC = ({
description: "Once an admin approves it, the server will appear in your MCP Servers list.",
});
}
- form.resetFields();
+ form.reset(CREATE_DEFAULTS);
setCostConfig({});
clearTools();
setAllowedTools([]);
@@ -466,7 +491,7 @@ const CreateMCPServer: React.FC = ({
// state
const handleCancel = () => {
- form.resetFields();
+ form.reset(CREATE_DEFAULTS);
setCostConfig({});
clearTools();
setAllowedTools([]);
@@ -489,11 +514,11 @@ const CreateMCPServer: React.FC = ({
? { url: undefined, command: undefined, args: undefined, env: undefined }
: { spec_path: undefined, command: undefined, args: undefined, env: undefined };
- form.setFieldsValue(transportValues);
- if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentity)) {
+ applyFieldValues(form, transportValues);
+ if (isHeldOAuthTokenStale(form.getValues(), authorizedIdentity)) {
clearHeldOAuthToken();
}
- setFormValues(form.getFieldsValue(true));
+ setFormValues(form.getValues() as Record);
};
// Generate options with existing groups and potential new group
@@ -532,7 +557,7 @@ const CreateMCPServer: React.FC = ({
React.useEffect(() => {
if (!aliasManuallyEdited && formValues.server_name) {
const normalized = formValues.server_name.replace(/\s+/g, "_");
- form.setFieldsValue({ alias: normalized });
+ applyFieldValues(form, { alias: normalized });
setFormValues((prev) => ({ ...prev, alias: normalized }));
}
}, [formValues.server_name]);
@@ -549,7 +574,7 @@ const CreateMCPServer: React.FC = ({
const wasVisible = wasModalVisibleRef.current;
wasModalVisibleRef.current = isModalVisible;
if (!isModalVisible && wasVisible) {
- form.resetFields();
+ form.reset(CREATE_DEFAULTS);
setFormValues({});
setOauthAccessToken(null);
clearTools();
@@ -582,19 +607,38 @@ const CreateMCPServer: React.FC = ({
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), authorizedIdentity)) {
+ if (isHeldOAuthTokenStale(form.getValues(), authorizedIdentity)) {
clearHeldOAuthToken(changedValues);
- setFormValues(form.getFieldsValue(true));
+ setFormValues(form.getValues() as Record);
return;
}
setFormValues(allValues);
};
+ const valuesChangeRef = React.useRef(handleFormValuesChange);
+ React.useEffect(() => {
+ valuesChangeRef.current = handleFormValuesChange;
+ });
+ React.useEffect(() => {
+ const subscription = form.watch((values, { name, type }) => {
+ if (type !== "change" || !name) {
+ return;
+ }
+ valuesChangeRef.current(
+ changedValuesFor(name, values as MountedFormValues),
+ projectMountedValues(registry, values as MountedFormValues),
+ );
+ });
+ return () => subscription.unsubscribe();
+ }, [form, registry]);
+
// rendering
return (
= ({
}}
>
-
+ {/* Cost Configuration Section */}
+
+ allowedTools.includes(tool.name))}
+ disabled={false}
+ />
+
+
+
+
+ Cancel
+
+
+ {isLoading && }
+ {isLoading ? "Creating..." : "Add MCP Server"}
+
+
+
+
+
);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx
index 49c182aa6be..ad78aa2c3ff 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx
@@ -1,16 +1,17 @@
import React from "react";
-import { Form, Switch, Tooltip } from "antd";
+import { Switch, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { isClientForwardedTokenMode } from "@/components/mcp_tools/types";
+import { MountedFormField } from "@/components/common_components/MountedFormField";
/**
* DCR-bridge toggle for the client-forwarded token modes (true_passthrough /
* oauth_delegate); self-gates to those two auth types and renders nothing
* otherwise. When on, OAuth-only clients like Claude Desktop can register and
* sign in through the gateway; when off, the gateway relays the upstream
- * server's own OAuth metadata instead. `initialChecked` seeds the antd
- * Form.Item `initialValue` (not the Switch's DOM defaultChecked): the create
- * form defaults it on, the edit form seeds it from the stored value.
+ * server's own OAuth metadata instead. `initialChecked` seeds the field's
+ * default value (not the Switch's DOM defaultChecked): the create form defaults
+ * it on, the edit form seeds it from the stored value.
*/
export default function DcrBridgeToggle({
authType,
@@ -21,7 +22,7 @@ export default function DcrBridgeToggle({
}) {
if (!isClientForwardedTokenMode(authType)) return null;
return (
-
Gateway-hosted sign-in (DCR bridge)
@@ -31,10 +32,9 @@ export default function DcrBridgeToggle({
}
name="dcr_bridge"
- valuePropName="checked"
- initialValue={initialChecked}
+ defaultValue={initialChecked}
>
-
-
+ {(field) => }
+
);
}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.tsx
index fbaacc40263..19b8f9a89b5 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.tsx
@@ -1,6 +1,13 @@
import React from "react";
-import { Form, Input, Select, Button, Tooltip, Typography } from "antd";
+import { Input, Select, Button, Tooltip, Typography } from "antd";
import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
+import {
+ MountedFormField,
+ bindControl,
+ useMountedFieldArray,
+ useMountedFormContext,
+ useMountedWatch,
+} from "@/components/common_components/MountedFormField";
const { Text } = Typography;
@@ -20,6 +27,9 @@ const SCOPE_OPTIONS = [
* The parent form reads the ``env_vars`` field from the form values.
*/
const EnvVarsSection: React.FC = () => {
+ const { control } = useMountedFormContext();
+ const { fields, append, remove } = useMountedFieldArray(control, "env_vars");
+
return (
@@ -48,60 +58,59 @@ const EnvVarsSection: React.FC = () => {
-
- {(fields, { add, remove }) => (
-
- {fields.length > 0 && (
-
-
Variable Name
-
Value / Description
-
Scope
-
-
- )}
- {fields.map(({ key, name, ...restField }) => (
-
-
-
-
-
-
-
-
-
-
-
- remove(name)}
- className="text-gray-500 hover:text-red-500 cursor-pointer"
- />
-
-
- ))}
-
add({ scope: "global" })} icon={ } block>
- Add Variable
-
+
+ {fields.length > 0 && (
+
+
Variable Name
+
Value / Description
+
Scope
+
)}
-
+ {fields.map((row, index) => (
+
+
+
+ {(field) => (
+ (field)}
+ placeholder="e.g. DB_PROTOCOL"
+ className="rounded-md font-mono"
+ />
+ )}
+
+
+
+
+
+
+
+ {(field) => (field)} options={SCOPE_OPTIONS} />}
+
+
+
+ remove(index)}
+ className="text-gray-500 hover:text-red-500 cursor-pointer"
+ />
+
+
+ ))}
+
append({ scope: "global" })} icon={ } block>
+ Add Variable
+
+
);
};
@@ -109,33 +118,39 @@ const EnvVarsSection: React.FC = () => {
// For instance-scoped vars this column holds the admin value. For per-user
// vars the value comes from each user later, so the column instead captures an
// optional description that the per-user fill-in modal shows as a hint.
-const ScopedValueOrDescription: React.FC<{
- name: number;
- restField: object;
-}> = ({ name, restField }) => {
- const isPerUser = Form.useWatch(["env_vars", name, "scope"]) === "user";
+const ScopedValueOrDescription: React.FC<{ index: number }> = ({ index }) => {
+ const isPerUser = useMountedWatch(`env_vars.${index}.scope`) === "user";
if (isPerUser) {
return (
-
-
-
-
- Hint
-
-
- }
- placeholder="e.g. Your DB username"
- styles={{ input: { color: "#9ca3af" } }}
- />
-
+
+ {(field) => (
+ (field)}
+ addonBefore={
+
+
+
+ Hint
+
+
+ }
+ placeholder="e.g. Your DB username"
+ styles={{ input: { color: "#9ca3af" } }}
+ />
+ )}
+
);
}
return (
-
-
-
+
+ {(field) => (
+ (field)}
+ placeholder="e.g. postgresql"
+ className="rounded-md font-mono"
+ />
+ )}
+
);
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx
index e8730a5b974..565db208366 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.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 { useFormContext } from "react-hook-form";
+import { MountedFormField, bindControl, type MountedFormValues } from "@/components/common_components/MountedFormField";
interface IdJagFormFieldsProps {
isEditing?: boolean;
@@ -19,10 +21,11 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
const IdJagFormFields: React.FC = ({ isEditing = false }) => {
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
+ const { getValues } = useFormContext();
return (
<>
- = ({ isEditing = false })
/>
}
name="token_exchange_endpoint"
- rules={[{ required: !isEditing, message: "The org token endpoint is required for ID-JAG" }]}
+ required={!isEditing}
+ rules={isEditing ? {} : { required: "The org token endpoint is required for ID-JAG" }}
>
-
-
- (
+ (field)}
+ placeholder="https://your-org.okta.com/oauth2/v1/token"
+ className={fieldClassName}
+ />
+ )}
+
+
}
- name={["credentials", "id_jag_resource_token_endpoint"]}
- rules={[{ required: !isEditing, message: "The resource token endpoint is required for ID-JAG" }]}
+ name="credentials.id_jag_resource_token_endpoint"
+ required={!isEditing}
+ rules={isEditing ? {} : { required: "The resource token endpoint is required for ID-JAG" }}
>
-
-
- (
+ (field)}
+ placeholder="https://upstream.example.com/oauth2/token"
+ className={fieldClassName}
+ />
+ )}
+
+ }
- name={["credentials", "client_id"]}
- rules={[{ required: !isEditing, message: "Client ID is required for ID-JAG" }]}
+ name="credentials.client_id"
+ required={!isEditing}
+ rules={isEditing ? {} : { required: "Client ID is required for ID-JAG" }}
>
-
-
- (
+ (field)}
+ placeholder={`Enter OAuth client ID${placeholderSuffix}`}
+ className={fieldClassName}
+ />
+ )}
+
+
}
- name={["credentials", "client_secret"]}
- dependencies={[["credentials", "client_private_key"]]}
- rules={[
- ({ getFieldValue }) => ({
- validator: (_, value) => {
- if (isEditing || value || getFieldValue(["credentials", "client_private_key"])) {
- return Promise.resolve();
- }
- return Promise.reject(new Error("Provide either a client secret or a client private key"));
- },
- }),
- ]}
+ name="credentials.client_secret"
+ rules={{
+ deps: ["credentials.client_private_key"],
+ validate: (value) =>
+ isEditing || value || getValues("credentials.client_private_key")
+ ? true
+ : "Provide either a client secret or a client private key",
+ }}
>
-
-
- (
+ (field)}
+ placeholder={`Enter OAuth client secret${placeholderSuffix}`}
+ className={fieldClassName}
+ />
+ )}
+
+
}
- name={["credentials", "client_private_key"]}
+ name="credentials.client_private_key"
>
-
-
- (
+ (field)}
+ rows={3}
+ placeholder={`-----BEGIN PRIVATE KEY-----${placeholderSuffix}`}
+ className={fieldClassName}
+ />
+ )}
+
+
}
- name={["credentials", "client_private_key_id"]}
+ name="credentials.client_private_key_id"
>
-
-
- (
+ (field)}
+ placeholder="my-signing-key-1"
+ className={fieldClassName}
+ />
+ )}
+
+
}
- name={["credentials", "client_assertion_signing_alg"]}
+ name="credentials.client_assertion_signing_alg"
>
-
-
- (
+ (field)} placeholder="RS256" className={fieldClassName} />
+ )}
+
+ = ({ isEditing = false })
}
name="audience"
>
-
-
- (
+ (field)}
+ placeholder="https://upstream.example.com"
+ className={fieldClassName}
+ />
+ )}
+
+
}
- name={["credentials", "id_jag_resource"]}
+ name="credentials.id_jag_resource"
>
-
-
- (
+ (field)}
+ placeholder="https://upstream.example.com/mcp"
+ className={fieldClassName}
+ />
+ )}
+
+ = ({ isEditing = false })
}
name="subject_token_type"
>
-
-
- (
+ (field)}
+ placeholder="urn:ietf:params:oauth:token-type:id_token"
+ className={fieldClassName}
+ />
+ )}
+
+ }
- name={["credentials", "scopes"]}
+ name="credentials.scopes"
>
-
-
+ {(field) => (
+ (field)}
+ mode="tags"
+ tokenSeparators={[","]}
+ placeholder="Add scopes"
+ className="rounded-lg"
+ size="large"
+ />
+ )}
+
>
);
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx
index ee6aee86a4d..4e14f7e5ba4 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx
@@ -2,10 +2,37 @@ import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
-import { Form } from "antd";
+import { FormProvider, useForm } from "react-hook-form";
+import {
+ MountedFormField,
+ MountedFormProvider,
+ useMountRegistry,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
import MCPPermissionManagement from "./MCPPermissionManagement";
+const Wrapper: React.FC<{ children: React.ReactNode; defaultValues: MountedFormValues; withAuthType?: boolean }> = ({
+ children,
+ defaultValues,
+ withAuthType = false,
+}) => {
+ const form = useForm({ defaultValues });
+ const registry = useMountRegistry();
+ return (
+
+
+ {withAuthType && (
+
+ {(field) => }
+
+ )}
+ {children}
+
+
+ );
+};
+
const defaultProps = {
availableAccessGroups: [],
mcpServer: null,
@@ -24,22 +51,12 @@ describe("MCPPermissionManagement", () => {
return user;
};
- const renderWithForm = (props = {}) => {
- const Wrapper: React.FC = ({ children }) => {
- const [form] = Form.useForm();
- return (
-
- {children}
-
- );
- };
-
- return render(
-
+ const renderWithForm = (props = {}) =>
+ render(
+
,
);
- };
it("should default allow_all_keys switch to unchecked for new servers", async () => {
renderWithForm();
@@ -51,27 +68,12 @@ describe("MCPPermissionManagement", () => {
expect(toggle).not.toBeChecked();
});
- const renderWithInitialValues = (initialValues: Record, props = {}) => {
- const Wrapper: React.FC = ({ children }) => {
- const [form] = Form.useForm();
- return (
-
- {/* In the real app auth_type is registered by the parent form; the
- component only watches it. Register a hidden field here so
- Form.useWatch("auth_type") resolves the initial value. */}
-
-
-
- {children}
-
- );
- };
- return render(
-
+ const renderWithInitialValues = (initialValues: Record, props = {}) =>
+ render(
+
,
);
- };
it("shows only the oauth2 PKCE-delegation toggle for oauth2 servers", async () => {
renderWithInitialValues({ allow_all_keys: false, auth_type: "oauth2" });
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx
index aae13d4b467..79e4b8af9c1 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx
@@ -1,7 +1,16 @@
import React, { useEffect } from "react";
-import { Alert, Form, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd";
+import { Alert, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd";
import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
+import { useFormContext } from "react-hook-form";
import { MCPServer, AUTH_TYPE } from "@/components/mcp_tools/types";
+import {
+ MountedFormField,
+ bindControl,
+ useMountedFieldArray,
+ useMountedFormContext,
+ useMountedWatch,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
const { Panel } = Collapse;
interface MCPPermissionManagementProps {
@@ -22,11 +31,13 @@ const MCPPermissionManagement: React.FC = ({
setSearchValue,
getAccessGroupOptions,
}) => {
- const form = Form.useFormInstance();
- const watchedAuthType = Form.useWatch("auth_type", form);
+ const form = useFormContext();
+ const { control } = useMountedFormContext();
+ const staticHeaders = useMountedFieldArray(control, "static_headers");
+ const watchedAuthType = useMountedWatch("auth_type");
const isOAuth2 = watchedAuthType === AUTH_TYPE.OAUTH2;
const isNoneAuth = watchedAuthType === AUTH_TYPE.NONE || watchedAuthType == null;
- const watchedExtraHeaders = Form.useWatch("extra_headers", form);
+ const watchedExtraHeaders = useMountedWatch("extra_headers");
const hasAuthorizationHeader =
Array.isArray(watchedExtraHeaders) &&
watchedExtraHeaders.some((h) => typeof h === "string" && h.toLowerCase() === "authorization");
@@ -39,22 +50,22 @@ const MCPPermissionManagement: React.FC = ({
// Kept as separate flags so neither silently implies the other and existing
// oauth2 servers can't regress into pass-through behavior.
const canEnableOAuthPassthrough = isNoneAuth && hasAuthorizationHeader;
- const watchedDelegateAuth = Form.useWatch("delegate_auth_to_upstream", form);
- const watchedPublicInternet = Form.useWatch("available_on_public_internet", form);
+ const watchedDelegateAuth = useMountedWatch("delegate_auth_to_upstream");
+ const watchedPublicInternet = useMountedWatch("available_on_public_internet");
const showInternalDelegatePkceWarning = isOAuth2 && watchedDelegateAuth === true && watchedPublicInternet === false;
// Set initial values when mcpServer changes
useEffect(() => {
if (mcpServer) {
if (mcpServer.static_headers) {
- const staticHeaders = Object.entries(mcpServer.static_headers).map(([header, value]) => ({
+ const headerRows = Object.entries(mcpServer.static_headers).map(([header, value]) => ({
header,
value: value != null ? String(value) : "",
}));
- form.setFieldValue("static_headers", staticHeaders);
+ form.setValue("static_headers", headerRows);
}
if (Array.isArray(mcpServer.env_vars) && mcpServer.env_vars.length > 0) {
- form.setFieldValue(
+ form.setValue(
"env_vars",
mcpServer.env_vars.map((entry) => ({
name: entry.name,
@@ -65,22 +76,22 @@ const MCPPermissionManagement: React.FC = ({
);
}
if (typeof mcpServer.allow_all_keys === "boolean") {
- form.setFieldValue("allow_all_keys", mcpServer.allow_all_keys);
+ form.setValue("allow_all_keys", mcpServer.allow_all_keys);
}
if (typeof mcpServer.available_on_public_internet === "boolean") {
- form.setFieldValue("available_on_public_internet", mcpServer.available_on_public_internet);
+ form.setValue("available_on_public_internet", mcpServer.available_on_public_internet);
}
if (typeof mcpServer.delegate_auth_to_upstream === "boolean") {
- form.setFieldValue("delegate_auth_to_upstream", mcpServer.delegate_auth_to_upstream);
+ form.setValue("delegate_auth_to_upstream", mcpServer.delegate_auth_to_upstream);
}
if (typeof mcpServer.oauth_passthrough === "boolean") {
- form.setFieldValue("oauth_passthrough", mcpServer.oauth_passthrough);
+ form.setValue("oauth_passthrough", mcpServer.oauth_passthrough);
}
} else {
- form.setFieldValue("allow_all_keys", false);
- form.setFieldValue("available_on_public_internet", true);
- form.setFieldValue("delegate_auth_to_upstream", false);
- form.setFieldValue("oauth_passthrough", false);
+ form.setValue("allow_all_keys", false);
+ form.setValue("available_on_public_internet", true);
+ form.setValue("delegate_auth_to_upstream", false);
+ form.setValue("oauth_passthrough", false);
}
}, [mcpServer, form]);
@@ -89,7 +100,7 @@ const MCPPermissionManagement: React.FC = ({
// stale toggle value doesn't get persisted unexpectedly.
useEffect(() => {
if (!isOAuth2) {
- form.setFieldValue("delegate_auth_to_upstream", false);
+ form.setValue("delegate_auth_to_upstream", false);
}
}, [isOAuth2, form]);
@@ -97,7 +108,7 @@ const MCPPermissionManagement: React.FC = ({
// Authorization upstream. Force it back to false otherwise.
useEffect(() => {
if (!canEnableOAuthPassthrough) {
- form.setFieldValue("oauth_passthrough", false);
+ form.setValue("oauth_passthrough", false);
}
}, [canEnableOAuthPassthrough, form]);
@@ -130,14 +141,9 @@ const MCPPermissionManagement: React.FC = ({
Enable if this server should be "public" to all keys.
-
-
-
+
+ {(field) => }
+
@@ -152,16 +158,11 @@ const MCPPermissionManagement: React.FC = ({
Turn on to restrict access to callers within your internal network only.
- ({ checked: !value })}
- getValueFromEvent={(checked: boolean) => !checked}
- initialValue={true}
- className="mb-0"
- >
-
-
+
+ {(field) => (
+ field.onChange(!checked)} />
+ )}
+
{isOAuth2 && (
@@ -177,14 +178,13 @@ const MCPPermissionManagement: React.FC = ({
Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server.
-
-
-
+ {(field) => }
+
)}
@@ -202,14 +202,13 @@ const MCPPermissionManagement: React.FC = ({
upstream MCP server.
-
-
-
+ {(field) => }
+
)}
@@ -223,7 +222,7 @@ const MCPPermissionManagement: React.FC = ({
/>
)}
-
MCP Access Groups
@@ -235,21 +234,24 @@ const MCPPermissionManagement: React.FC = ({
name="mcp_access_groups"
className="mb-4"
>
- (option?.value ?? "").toLowerCase().includes(input.toLowerCase())}
- onSearch={(value) => setSearchValue(value)}
- tokenSeparators={[","]}
- options={getAccessGroupOptions()}
- maxTagCount="responsive"
- allowClear
- />
-
+ {(field) => (
+ (field)}
+ mode="tags"
+ showSearch
+ placeholder="Select existing groups or type to create new ones"
+ optionFilterProp="value"
+ filterOption={(input, option) => (option?.value ?? "").toLowerCase().includes(input.toLowerCase())}
+ onSearch={(value) => setSearchValue(value)}
+ tokenSeparators={[","]}
+ options={getAccessGroupOptions()}
+ maxTagCount="responsive"
+ allowClear
+ />
+ )}
+
-
Extra Headers
@@ -265,70 +267,78 @@ const MCPPermissionManagement: React.FC = ({
}
name="extra_headers"
>
- 0
- ? `Currently: ${mcpServer.extra_headers.join(", ")}`
- : "Enter header names (e.g., Authorization, X-Custom-Header)"
- }
- className="rounded-lg"
- size="large"
- tokenSeparators={[","]}
- allowClear
- />
-
+ {(field) => (
+ (field)}
+ mode="tags"
+ placeholder={
+ mcpServer?.extra_headers && mcpServer.extra_headers.length > 0
+ ? `Currently: ${mcpServer.extra_headers.join(", ")}`
+ : "Enter header names (e.g., Authorization, X-Custom-Header)"
+ }
+ className="rounded-lg"
+ size="large"
+ tokenSeparators={[","]}
+ allowClear
+ />
+ )}
+
-
- Static Headers
-
-
-
-
- }
- required={false}
- >
-
- {(fields, { add, remove }) => (
-
- {fields.map(({ key, name, ...restField }) => (
-
-
+
+
+ Static Headers
+
+
+
+
+
+ {staticHeaders.fields.map((row, index) => (
+
+
- )}
-
-
+ )}
+
+
+
+
+ {(field) => (
+ (field)}
+ size="large"
+ allowClear
+ className="rounded-lg"
+ placeholder="Header value"
+ />
+ )}
+
+
+
staticHeaders.remove(index)}
+ className="text-gray-500 hover:text-red-500 cursor-pointer"
+ />
+
+ ))}
+ staticHeaders.append({})} icon={ } block>
+ Add Static Header
+
+
+
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx
index 48964490339..99158fcf5b3 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx
@@ -1,22 +1,32 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
-import { Form } from "antd";
+import { FormProvider, useForm } from "react-hook-form";
+import {
+ MountedFormProvider,
+ projectMountedValues,
+ useMountRegistry,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
import OAuthFormFields from "./OAuthFormFields";
// ── helpers ──────────────────────────────────────────────────────────────────
-/** Minimal Ant Form wrapper so Form.Item registers correctly. */
const WithForm: React.FC<{ children: React.ReactNode; onFinish?: (values: any) => void }> = ({
children,
onFinish,
}) => {
- const [form] = Form.useForm();
+ const form = useForm({ defaultValues: {} });
+ const registry = useMountRegistry();
return (
-
- {children}
- Submit
-
+
+
+ onFinish?.(projectMountedValues(registry, store)))}>
+ {children}
+ Submit
+
+
+
);
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx
index cbe6ac18d22..125f33c6a0e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx
@@ -1,9 +1,10 @@
import React from "react";
-import { Form, Input as AntdInput, InputNumber, Select, Tooltip } from "antd";
+import { Input as AntdInput, InputNumber, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { OAUTH_FLOW } from "@/components/mcp_tools/types";
+import { MountedFormField, bindControl } from "@/components/common_components/MountedFormField";
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
interface OAuthFlowStatus {
@@ -41,12 +42,19 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
);
const UpstreamResourceField: React.FC = () => (
- }
- name={["credentials", "upstream_resource"]}
+ name="credentials.upstream_resource"
>
-
-
+ {(field) => (
+ (field)}
+ value={(field.value as string | undefined) ?? ""}
+ placeholder="auto, or https://mcp.example.com/mcp"
+ className={fieldClassName}
+ />
+ )}
+
);
const OAuthFormFields: React.FC = ({
@@ -57,11 +65,11 @@ const OAuthFormFields: React.FC = ({
docsUrl,
}) => {
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
- const requiredWhenCreating = (message: string) => (isEditing ? [] : [{ required: true, message }]);
+ const requiredWhenCreating = (message: string) => (isEditing ? {} : { required: message });
return (
<>
- = ({
/>
}
name="oauth_flow_type"
- {...(initialFlowType ? { initialValue: initialFlowType } : {})}
+ {...(initialFlowType ? { defaultValue: initialFlowType } : {})}
>
-
-
-
- Machine-to-Machine (M2M)
- server-to-server, no user interaction
-
-
-
-
- Interactive (PKCE)
- browser-based user authorization
-
-
-
-
+ {(field) => (
+ (field)}
+ placeholder="Select OAuth flow"
+ className="rounded-lg"
+ size="large"
+ >
+
+
+ Machine-to-Machine (M2M)
+ server-to-server, no user interaction
+
+
+
+
+ Interactive (PKCE)
+ browser-based user authorization
+
+
+
+ )}
+
{isM2M ? (
<>
- }
- name={["credentials", "client_id"]}
+ name="credentials.client_id"
+ required={!isEditing}
rules={requiredWhenCreating("Client ID is required for M2M OAuth")}
>
-
-
- (
+ (field)}
+ placeholder={`Enter OAuth client ID${placeholderSuffix}`}
+ className={fieldClassName}
+ />
+ )}
+
+
}
- name={["credentials", "client_secret"]}
+ name="credentials.client_secret"
+ required={!isEditing}
rules={requiredWhenCreating("Client Secret is required for M2M OAuth")}
>
-
-
- (
+ (field)}
+ placeholder={`Enter OAuth client secret${placeholderSuffix}`}
+ className={fieldClassName}
+ />
+ )}
+
+ }
name="token_url"
+ required={!isEditing}
rules={requiredWhenCreating("Token URL is required for M2M OAuth")}
>
-
-
+ {(field) => (
+ (field)}
+ value={(field.value as string | undefined) ?? ""}
+ placeholder="https://auth.example.com/oauth/token"
+ className={fieldClassName}
+ />
+ )}
+
-
}
- name={["credentials", "scopes"]}
+ name="credentials.scopes"
>
-
-
+ {(field) => (
+ (field)}
+ mode="tags"
+ tokenSeparators={[","]}
+ placeholder="Add scopes"
+ className="rounded-lg"
+ size="large"
+ />
+ )}
+
>
) : (
<>
-
= ({
)}
}
- name={["credentials", "client_id"]}
+ name="credentials.client_id"
>
-
-
- (
+ (field)}
+ placeholder={`Enter client ID${placeholderSuffix}`}
+ className={fieldClassName}
+ />
+ )}
+
+
}
- name={["credentials", "client_secret"]}
+ name="credentials.client_secret"
>
-
-
- (
+ (field)}
+ placeholder={`Enter client secret${placeholderSuffix}`}
+ className={fieldClassName}
+ />
+ )}
+
+
}
- name={["credentials", "scopes"]}
+ name="credentials.scopes"
>
-
-
+ {(field) => (
+ (field)}
+ mode="tags"
+ tokenSeparators={[","]}
+ placeholder="Add scopes"
+ className="rounded-lg"
+ size="large"
+ />
+ )}
+
- = ({
}
name="issuer"
>
-
-
- (
+ (field)}
+ value={(field.value as string | undefined) ?? ""}
+ placeholder="https://issuer.example.com"
+ className={fieldClassName}
+ />
+ )}
+
+ = ({
}
name="authorization_url"
>
-
-
- (
+ (field)}
+ value={(field.value as string | undefined) ?? ""}
+ placeholder="https://example.com/oauth/authorize"
+ className={fieldClassName}
+ />
+ )}
+
+ }
name="token_url"
>
-
-
+ {(field) => (
+ (field)}
+ value={(field.value as string | undefined) ?? ""}
+ placeholder="https://example.com/oauth/token"
+ className={fieldClassName}
+ />
+ )}
+
- = ({
}
name="registration_url"
>
-
-
- (
+ (field)}
+ value={(field.value as string | undefined) ?? ""}
+ placeholder="https://example.com/oauth/register"
+ className={fieldClassName}
+ />
+ )}
+
+ = ({
/>
}
name="token_validation_json"
- rules={[
- {
- validator: (_: any, value: string) => {
- if (!value || value.trim() === "") return Promise.resolve();
- try {
- JSON.parse(value);
- return Promise.resolve();
- } catch {
- return Promise.reject(new Error("Must be valid JSON"));
- }
- },
+ rules={{
+ validate: (value) => {
+ if (typeof value !== "string" || value.trim() === "") return true;
+ try {
+ JSON.parse(value);
+ return true;
+ } catch {
+ return "Must be valid JSON";
+ }
},
- ]}
+ }}
>
-
-
- (
+ (field)}
+ placeholder={'{\n "organization": "my-org",\n "team.id": "123"\n}'}
+ rows={4}
+ className="font-mono text-sm rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+ = ({
}
name="token_storage_ttl_seconds"
>
-
-
+ {(field) => (
+ (field)}
+ min={1}
+ placeholder="e.g. 3600"
+ className="w-full rounded-lg"
+ style={{ width: "100%" }}
+ />
+ )}
+
{oauthFlow && (
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 {children} ;
+ 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 && (
-
- )}
-
- {!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 ;
+};