From 481c08de4e8a4adb2c942da71d5f0c6ccde32445 Mon Sep 17 00:00:00 2001
From: yuneng-jiang
Date: Wed, 19 Aug 2026 10:26:20 -0700
Subject: [PATCH] refactor(ui): port the MCP server forms off antd Form onto
react-hook-form (#37483)
* refactor(ui): port the MCP server forms off antd Form onto react-hook-form
The MCP create and edit forms were the last antd `Form` graph in the
dashboard. antd's `onFinish` hands back only the fields mounted at submit
time, while react-hook-form with `shouldUnregister: false` hands back the
whole store, so a direct port would quietly widen every create and update
request.
All 14 files now bind through `MountedFormField`, whose mount registry
reproduces antd's mounted-only submit: both roots build their payload from
`projectMountedValues` instead of `getValues`. `mcpFormStore` carries the
rest of the FormInstance surface the two roots relied on, each piece
matched to what rc-field-form actually does rather than to what the API
name suggests: `setFieldsValue` deep-merges plain objects and writes an
explicit `undefined`, `resetFields` restores the seeded values rather than
clearing the key, and `onValuesChange` is rebuilt from a `watch`
subscription filtered to user input, carrying a single changed branch.
Two watches needed the mount gate moved rather than translated.
`MCPPermissionManagement` renders outside the transport gate that mounts
`auth_type`, so antd's watch read `undefined` there and mounted the
pass-through toggle; the effective auth type now arrives as a prop that
each root computes with exactly that gate. The edit root reads every watch
off the mounted projection for the same reason, which also stops the token
material in a saved server's credentials from reaching the tool preview.
`Form.List` becomes `useFieldArray` plus a `useMountedName` registration
for the list key itself, because antd registers a list as one field: a
per-user variable row keeps the `value` its scope hides, and an empty list
still submits `env_vars: []` instead of dropping the key.
* test(ui): cover the mount registry's unregister path on the real primitive
The existing MountedFormField suite drove a hand-written registry whose
register returned a no-op, so nothing exercised useMountRegistry's
ref-counting or the cleanup that React wires from useMountedName's effect
return value. A reviewer read that gap as a missing unregister.
These three cases drive the real hook through a gated tree: a key leaves
the submitted payload when its gate unmounts the field, a required field
that unmounts stops blocking submission, and a name held by two fields
survives one of them releasing it.
Verified by mutation: rewriting the effect body to discard the cleanup
turns the first two red, the second reporting the reviewer's exact
symptom, "expected [ 'server_name', 'token_url' ] to not include
'token_url'".
* test(ui): prove the permission panel's booleans reach the create payload
CreateMCPServer.integration.test.tsx mocks MCPPermissionManagement, so the
four booleans createServerPayload writes were invisible to every existing
create-side test. vi.mock is file-scoped, so rendering the real panel needs
its own file.
Four cases, each killed by a different mutation:
unbind allow_all_keys -> "sends allow_all_keys true"
unbind available_on_public_internet -> "sends the panel's defaults"
invertedSwitchControl -> switchControl -> "sends ... false when the
operator restricts"
isOAuth2 gate forced open -> "omits delegate_auth_to_upstream"
A payload assertion expecting false cannot detect an unbound field, since
Boolean(undefined) is false too, so the two cases carrying unbinding
detection are the ones asserting true. The other two are pinned by the
switch-inversion and mount-gate mutations instead.
---
.../_components/AwsSigV4Fields.tsx | 224 ++-
.../CreateMCPServer.integration.test.tsx | 4 +-
...MCPServer.permissions.integration.test.tsx | 160 ++
.../_components/CreateMCPServer.tsx | 742 +++++----
.../_components/DcrBridgeToggle.tsx | 20 +-
.../_components/EnvVarsSection.test.tsx | 86 ++
.../_components/EnvVarsSection.tsx | 158 +-
.../_components/IdJagFormFields.tsx | 163 +-
.../MCPPermissionManagement.test.tsx | 51 +-
.../_components/MCPPermissionManagement.tsx | 268 ++--
.../_components/McpFormTestHarness.tsx | 37 +
.../_components/OAuthFormFields.test.tsx | 18 +-
.../_components/OAuthFormFields.tsx | 237 ++-
.../_components/OpenAPIFormSection.tsx | 50 +-
.../_components/OpenApiByokFields.tsx | 166 +-
.../PassthroughAuthorizeSection.test.tsx | 7 +-
.../PassthroughAuthorizeSection.tsx | 40 +-
.../_components/StdioConfiguration.tsx | 76 +-
.../TokenEndpointAuthMethodField.tsx | 30 +-
.../_components/TokenExchangeFormFields.tsx | 210 +--
.../editServerPayload.differential.test.ts | 4 +-
.../mcp-servers/_components/mcpFieldRules.ts | 98 ++
.../_components/mcpFormStore.test.tsx | 132 ++
.../mcp-servers/_components/mcpFormStore.ts | 107 ++
.../_components/mcp_server_edit.test.tsx | 6 +-
.../_components/mcp_server_edit.tsx | 1359 +++++++++--------
.../_components/mountedServerFields.test.ts | 2 +-
.../mcp-servers/_components/testUtils.ts | 1 +
.../MountedFormField.test.ts | 96 --
.../MountedFormField.test.tsx | 187 +++
.../common_components/MountedFormField.tsx | 9 +-
31 files changed, 2915 insertions(+), 1833 deletions(-)
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.permissions.integration.test.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.test.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/McpFormTestHarness.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFieldRules.ts
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.test.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.ts
delete mode 100644 ui/litellm-dashboard/src/components/common_components/MountedFormField.test.ts
create mode 100644 ui/litellm-dashboard/src/components/common_components/MountedFormField.test.tsx
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..09016bda266 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,7 +1,25 @@
import React from "react";
-import { Form, Input, Tooltip } from "antd";
+import { Input, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { antdRequired } from "@/components/common_components/antdFormRules";
+import { requiredWhenSiblingSet, textControl } from "./mcpFieldRules";
+
+const fieldClassName = "rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500";
+
+const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, tooltip }) => (
+
+ {label}
+
+
+
+
+);
+
+const ACCESS_KEY_PATH = ["credentials", "aws_access_key_id"] as const;
+const SECRET_KEY_PATH = ["credentials", "aws_secret_access_key"] as const;
+
const AwsSigV4Fields: React.FC = () => (
<>
@@ -15,140 +33,120 @@ const AwsSigV4Fields: React.FC = () => (
View docs →
-
- AWS Region
-
-
-
-
- }
+ }
name={["credentials", "aws_region_name"]}
- rules={[{ required: true, message: "AWS region is required for SigV4 auth" }]}
+ required
+ rules={{ validate: { required: antdRequired("AWS region is required for SigV4 auth") } }}
>
-
-
- }
+
+
- 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();
- },
- }),
- ]}
+ name={ACCESS_KEY_PATH}
+ rules={{
+ deps: ["credentials.aws_secret_access_key"],
+ validate: {
+ pairedWithSecret: requiredWhenSiblingSet(
+ SECRET_KEY_PATH,
+ "Access Key ID is required when Secret Access Key is provided",
+ ),
+ },
+ }}
>
-
-
- (
+
+ )}
+
+
- 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();
- },
- }),
- ]}
+ name={SECRET_KEY_PATH}
+ rules={{
+ deps: ["credentials.aws_access_key_id"],
+ validate: {
+ pairedWithAccessKey: requiredWhenSiblingSet(
+ ACCESS_KEY_PATH,
+ "Secret Access Key is required when Access Key ID is provided",
+ ),
+ },
+ }}
>
-
-
-
- AWS Session Token
-
-
-
-
- }
+ {(control) => (
+
+ )}
+
+ }
name={["credentials", "aws_session_token"]}
>
-
-
- (
+
+ )}
+
+
- AWS Role ARN
-
-
-
-
+
}
name={["credentials", "aws_role_name"]}
>
-
-
- (
+
+ )}
+
+
- AWS Session Name
-
-
-
-
+
}
name={["credentials", "aws_session_name"]}
>
-
-
+ {(control) => (
+
+ )}
+
>
);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.integration.test.tsx
index f08aacb5522..78d89b7e19e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.integration.test.tsx
@@ -2147,7 +2147,7 @@ describe("CreateMCPServer dcr_bridge toggle", () => {
});
// Forcing dcr_bridge false for every non-client-forwarded auth type is covered in
- // createServerPayload.test.ts. The two form-state cases below stay: they prove the Form.Item
+ // createServerPayload.test.ts. The two form-state cases below stay: they prove the field
// unmounts on a switch away, and that the live value survives a client-forwarded swap.
it("forces dcr_bridge: false when the auth type is switched away after toggling", async () => {
@@ -2179,7 +2179,7 @@ describe("CreateMCPServer dcr_bridge toggle", () => {
});
expect(getDcrToggle()).toHaveAttribute("aria-checked", "true");
- // The Form.Item is mounted in both client-forwarded modes, so switching between them keeps the
+ // The field is mounted in both client-forwarded modes, so switching between them keeps the
// live toggle value rather than forcing it back to the default or to false.
await selectAntOption("Authentication", "OAuth Delegate (client-supplied upstream token)");
await waitFor(() => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.permissions.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.permissions.integration.test.tsx
new file mode 100644
index 00000000000..788be106d8a
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.permissions.integration.test.tsx
@@ -0,0 +1,160 @@
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import * as networking from "@/components/networking";
+import CreateMCPServer from "./CreateMCPServer";
+import { selectAntOption } from "./testUtils";
+
+vi.mock("@/components/networking", () => ({
+ createMCPServer: vi.fn(),
+ fetchOpenAPIRegistry: vi.fn().mockResolvedValue({ apis: [] }),
+ registerMCPServer: vi.fn(),
+ storeMCPOAuthUserCredential: vi.fn().mockResolvedValue({}),
+ testMCPToolsListRequest: vi.fn().mockResolvedValue({ tools: [], error: null }),
+}));
+
+vi.mock("@/utils/mcpTokenStore", () => ({
+ setToken: vi.fn(),
+}));
+
+vi.mock("./OpenAPIQuickPicker", () => ({
+ default: () => null,
+}));
+
+vi.mock("@/hooks/useMcpOAuthFlow", () => ({
+ useMcpOAuthFlow: () => ({
+ startOAuthFlow: vi.fn(),
+ status: "idle",
+ error: null,
+ tokenResponse: null,
+ reset: vi.fn(),
+ }),
+}));
+
+vi.mock("./mcp_server_cost_config", () => ({
+ default: () =>
,
+}));
+
+vi.mock("./mcp_tool_configuration", () => ({
+ default: () =>
,
+}));
+
+vi.mock("./mcp_connection_status", () => ({
+ default: () =>
,
+}));
+
+vi.mock("./StdioConfiguration", () => ({
+ default: () =>
,
+}));
+
+const defaultProps = {
+ userRole: "Admin",
+ accessToken: "test-token",
+ onCreateSuccess: vi.fn(),
+ isModalVisible: true,
+ setModalVisible: vi.fn(),
+ availableAccessGroups: ["group-a", "group-b"],
+};
+
+const getServerNameInput = () => document.getElementById("server_name") as HTMLInputElement;
+
+const switchFor = (labelText: string): HTMLElement => {
+ const label = screen.getByText(labelText);
+ const row = label.closest(".flex.items-start.justify-between");
+ const control = row?.querySelector("button[role='switch']");
+ if (control === null || control === undefined) {
+ throw new Error(`no switch found for "${labelText}"`);
+ }
+ return control as HTMLElement;
+};
+
+const fillMinimalHttpServer = async (name: string) => {
+ await selectAntOption("Transport Type", "Streamable HTTP");
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument();
+ });
+ const user = userEvent.setup({ delay: null });
+ await user.type(getServerNameInput(), name);
+ await user.type(screen.getByPlaceholderText("https://your-mcp-server.com"), "https://example.com/mcp");
+ await selectAntOption("Authentication", "None");
+};
+
+const submitAndReadPayload = async () => {
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "Add MCP Server" }));
+ });
+ await waitFor(() => expect(networking.createMCPServer).toHaveBeenCalledTimes(1));
+ return vi.mocked(networking.createMCPServer).mock.calls[0][1];
+};
+
+const createdServer = {
+ server_id: "new-server-1",
+ server_name: "Perm_Server",
+ alias: "Perm_Server",
+ url: "https://example.com/mcp",
+ transport: "http",
+ auth_type: "none",
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user-1",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user-1",
+};
+
+describe("CreateMCPServer permission toggles reaching the payload", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(networking.createMCPServer).mockResolvedValue(createdServer);
+ });
+
+ it("sends the panel's untouched defaults rather than dropping the keys the panel owns", async () => {
+ render( );
+ await fillMinimalHttpServer("Perm_Server");
+
+ const payload = await submitAndReadPayload();
+
+ expect(payload.allow_all_keys).toBe(false);
+ expect(payload.available_on_public_internet).toBe(true);
+ });
+
+ it("sends allow_all_keys true once the operator turns the public-to-all-keys switch on", async () => {
+ render( );
+ await fillMinimalHttpServer("Perm_Server");
+
+ await act(async () => {
+ fireEvent.click(switchFor("Allow All LiteLLM Keys"));
+ });
+
+ const payload = await submitAndReadPayload();
+
+ expect(payload.allow_all_keys).toBe(true);
+ });
+
+ it("sends available_on_public_internet false when the operator restricts the server to the internal network", async () => {
+ render( );
+ await fillMinimalHttpServer("Perm_Server");
+
+ const internalOnly = switchFor("Internal network only");
+ expect(internalOnly).toHaveAttribute("aria-checked", "false");
+
+ await act(async () => {
+ fireEvent.click(internalOnly);
+ });
+ expect(internalOnly).toHaveAttribute("aria-checked", "true");
+
+ const payload = await submitAndReadPayload();
+
+ expect(payload.available_on_public_internet).toBe(false);
+ });
+
+ it("omits delegate_auth_to_upstream's true value on a none-auth server, whose gate never mounts that switch", async () => {
+ render( );
+ await fillMinimalHttpServer("Perm_Server");
+
+ expect(screen.getByText("Allow All LiteLLM Keys")).toBeInTheDocument();
+ expect(screen.queryByText("Delegate auth to upstream (PKCE passthrough)")).not.toBeInTheDocument();
+
+ const payload = await submitAndReadPayload();
+
+ expect(payload.delegate_auth_to_upstream).toBe(false);
+ });
+});
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..92bdd14754c 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,5 +1,6 @@
import React, { useState } from "react";
-import { Modal, Tooltip, Form, Select, Input as AntdInput, InputNumber, Collapse } from "antd";
+import { Modal, Tooltip, Select, Input as AntdInput, InputNumber, Collapse } from "antd";
+import { FormProvider, useForm, useWatch } from "react-hook-form";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -49,6 +50,16 @@ import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import { toast } from "@/lib/toast";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
import { useTestMCPConnection } from "@/hooks/useTestMCPConnection";
+import {
+ MountedFormField,
+ MountedFormProvider,
+ projectMountedValues,
+ useMountRegistry,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
+import { antdRequired, antdRules } from "@/components/common_components/antdFormRules";
+import { allFieldsValue, mountedPaths, resetFields, setFieldsValue, singleBranchChange } from "./mcpFormStore";
+import { numberControl, notOnlyWhitespace, selectControl, textControl } from "./mcpFieldRules";
import mcpLogo from "../../../../../public/assets/logos/mcp_logo.png";
export const mcpLogoImg = mcpLogo.src;
@@ -76,6 +87,13 @@ const payloadErrorMessage = (result: Exclude = ({
userID,
userRole,
@@ -87,7 +105,8 @@ const CreateMCPServer: React.FC = ({
prefillData,
onBackToDiscovery,
}) => {
- const [form] = Form.useForm();
+ const form = useForm({ mode: "onChange", defaultValues: CREATE_DEFAULTS });
+ const registry = useMountRegistry();
const [isLoading, setIsLoading] = useState(false);
const [costConfig, setCostConfig] = useState({});
const [formValues, setFormValues] = useState>({});
@@ -136,6 +155,8 @@ const CreateMCPServer: React.FC = ({
enabled: true,
});
+ const authSectionMounted = transportType !== "stdio" && transportType !== "";
+ const watchedAuthType = useWatch({ control: form.control, name: "auth_type" }) as string | undefined;
const authType = formValues.auth_type as string | undefined;
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
@@ -147,7 +168,7 @@ const CreateMCPServer: React.FC = ({
const persistCreateUiState = () => {
writeCreateUiSnapshot({
modalVisible: isModalVisible,
- formValues: form.getFieldsValue(true),
+ formValues: allFieldsValue(form),
transportType,
costConfig,
allowedTools,
@@ -170,11 +191,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) ?? {}),
+ ...((allFieldsValue(form).credentials as Record | undefined) ?? {}),
...(dcrClientRef.current ?? {}),
}),
getTemporaryPayload: () => {
- const values = form.getFieldsValue(true);
+ const values = allFieldsValue(form);
const transport = values.transport || transportType;
// For OpenAPI transport the form has spec_path instead of url.
// We pass the spec_path as url so the temp-session endpoint has something
@@ -218,12 +239,12 @@ const CreateMCPServer: React.FC = ({
return;
}
- if (isClientForwardedTokenMode(form.getFieldValue("auth_type"))) {
+ if (isClientForwardedTokenMode(allFieldsValue(form).auth_type)) {
// Browser-only modes: the token is held in local state (oauthAccessToken) for tool preview
// and committed to sessionStorage on submit; it must never be written into form.credentials,
// which would persist it as server-level credentials on the created server row. Mirrors the
// edit form's onTokenReceived early return.
- setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true)));
+ setAuthorizedIdentity(getOAuthAuthorizationIdentity(allFieldsValue(form)));
toast.success(
"Token held for this browser session. Tools can now be previewed and configured; the token is not saved to LiteLLM.",
);
@@ -240,7 +261,7 @@ const CreateMCPServer: React.FC = ({
}
: null;
- const current = (form.getFieldValue("credentials") as Record | undefined) ?? {};
+ const current = (allFieldsValue(form).credentials as Record | undefined) ?? {};
const nextCredentials = {
...(preservedAdminCredentials(current) ?? {}),
...(current.scopes !== undefined && { scopes: current.scopes }),
@@ -252,10 +273,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(allFieldsValue(form)));
toast.success("OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.");
},
@@ -277,10 +298,10 @@ 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(allFieldsValue(form).credentials);
+ resetFields(form, [...CLEARED_ON_INVALIDATION]);
if (keptAdminCredentials) {
- form.setFieldsValue({ credentials: keptAdminCredentials });
+ setFieldsValue(form, { credentials: keptAdminCredentials });
}
// Re-apply the in-flight edit last; rc-field-form deep-merges nested objects, so a changed
// credentials sub-field composes with the preserved sibling instead of replacing the object.
@@ -288,7 +309,7 @@ const CreateMCPServer: React.FC = ({
CLEARED_ON_INVALIDATION.filter((key) => key in changedValues).map((key) => [key, changedValues[key]]),
);
if (Object.keys(preserved).length > 0) {
- form.setFieldsValue(preserved);
+ setFieldsValue(form, preserved);
}
};
@@ -337,7 +358,7 @@ const CreateMCPServer: React.FC = ({
// wait until transportType state catches up so the URL field is mounted
return;
}
- form.setFieldsValue(pendingRestoredValues.values);
+ setFieldsValue(form, pendingRestoredValues.values);
setFormValues(pendingRestoredValues.values);
setPendingRestoredValues(null);
}, [pendingRestoredValues, form, transportType]);
@@ -381,11 +402,20 @@ const CreateMCPServer: React.FC = ({
prefillValues.url = prefillData.url;
}
- form.setFieldsValue(prefillValues);
+ setFieldsValue(form, prefillValues);
setFormValues(prefillValues);
setAliasManuallyEdited(false);
}, [isModalVisible, prefillData, form]);
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ const isValid = await form.trigger(mountedPaths(registry) as string[]);
+ if (!isValid) {
+ return;
+ }
+ await handleCreate(projectMountedValues(registry, form.getValues));
+ };
+
const handleCreate = async (values: Record) => {
const built = buildCreateServerPayload(values, {
transportType,
@@ -446,7 +476,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 +496,7 @@ const CreateMCPServer: React.FC = ({
// state
const handleCancel = () => {
- form.resetFields();
+ form.reset(CREATE_DEFAULTS);
setCostConfig({});
clearTools();
setAllowedTools([]);
@@ -489,11 +519,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)) {
+ setFieldsValue(form, transportValues);
+ if (isHeldOAuthTokenStale(allFieldsValue(form), authorizedIdentity)) {
clearHeldOAuthToken();
}
- setFormValues(form.getFieldsValue(true));
+ setFormValues(allFieldsValue(form));
};
// Generate options with existing groups and potential new group
@@ -532,7 +562,7 @@ const CreateMCPServer: React.FC = ({
React.useEffect(() => {
if (!aliasManuallyEdited && formValues.server_name) {
const normalized = formValues.server_name.replace(/\s+/g, "_");
- form.setFieldsValue({ alias: normalized });
+ setFieldsValue(form, { alias: normalized });
setFormValues((prev) => ({ ...prev, alias: normalized }));
}
}, [formValues.server_name]);
@@ -549,7 +579,7 @@ const CreateMCPServer: React.FC = ({
const wasVisible = wasModalVisibleRef.current;
wasModalVisibleRef.current = isModalVisible;
if (!isModalVisible && wasVisible) {
- form.resetFields();
+ form.reset(CREATE_DEFAULTS);
setFormValues({});
setOauthAccessToken(null);
clearTools();
@@ -582,19 +612,35 @@ const CreateMCPServer: React.FC = ({
const upstreamChanged = ["url", "spec_path", "issuer", "authorization_url", "token_url", "registration_url"].some(
(key) => key in changedValues,
);
- const hasDeclaredApp = preservedDeclaredAppCredentials(form.getFieldValue("credentials")) !== undefined;
+ const hasDeclaredApp = preservedDeclaredAppCredentials(allFieldsValue(form).credentials) !== undefined;
if (upstreamChanged && hasDeclaredApp) {
setAppMayNotMatchUpstream(true);
}
}
- if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentity)) {
+ if (isHeldOAuthTokenStale(allFieldsValue(form), authorizedIdentity)) {
clearHeldOAuthToken(changedValues);
- setFormValues(form.getFieldsValue(true));
+ setFormValues(allFieldsValue(form));
return;
}
setFormValues(allValues);
};
+ const valuesChangeRef = React.useRef(handleFormValuesChange);
+ valuesChangeRef.current = handleFormValuesChange;
+
+ React.useEffect(() => {
+ const subscription = form.watch((values, { name, type }) => {
+ if (type !== "change" || name === undefined) {
+ return;
+ }
+ valuesChangeRef.current(
+ singleBranchChange(name, values as MountedFormValues),
+ projectMountedValues(registry, form.getValues),
+ );
+ });
+ return () => subscription.unsubscribe();
+ }, [form, registry]);
+
// rendering
return (
= ({
}}
>
-
+ {/* 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..35b23f9873c 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,19 @@
import React from "react";
-import { Form, Switch, Tooltip } from "antd";
+import { Switch, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+
+import { MountedFormField } from "@/components/common_components/MountedFormField";
import { isClientForwardedTokenMode } from "@/components/mcp_tools/types";
+import { switchControl } from "./mcpFieldRules";
/**
* DCR-bridge toggle for the client-forwarded token modes (true_passthrough /
* oauth_delegate); self-gates to those two auth types and renders nothing
* otherwise. When on, OAuth-only clients like Claude Desktop can register and
* sign in through the gateway; when off, the gateway relays the upstream
- * server's own OAuth metadata instead. `initialChecked` seeds the antd
- * Form.Item `initialValue` (not the Switch's DOM defaultChecked): the create
- * form defaults it on, the edit form seeds it from the stored value.
+ * server's own OAuth metadata instead. `initialChecked` seeds the field's
+ * default value (not the Switch's DOM defaultChecked): the create form defaults
+ * it on, the edit form seeds it from the stored value.
*/
export default function DcrBridgeToggle({
authType,
@@ -21,7 +24,7 @@ export default function DcrBridgeToggle({
}) {
if (!isClientForwardedTokenMode(authType)) return null;
return (
-
Gateway-hosted sign-in (DCR bridge)
@@ -31,10 +34,9 @@ export default function DcrBridgeToggle({
}
name="dcr_bridge"
- valuePropName="checked"
- initialValue={initialChecked}
+ defaultValue={initialChecked}
>
-
-
+ {(control) => }
+
);
}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.test.tsx
new file mode 100644
index 00000000000..a57bde7eb9d
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.test.tsx
@@ -0,0 +1,86 @@
+import React from "react";
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { FormProvider, useForm } from "react-hook-form";
+
+import {
+ MountedFormProvider,
+ projectMountedValues,
+ useMountRegistry,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
+import EnvVarsSection from "./EnvVarsSection";
+
+const renderSection = (defaultValues: MountedFormValues) => {
+ const onFinish = vi.fn();
+ const Harness: React.FC = () => {
+ const form = useForm({ mode: "onChange", defaultValues });
+ const registry = useMountRegistry();
+ return (
+
+
+ {
+ event.preventDefault();
+ onFinish(projectMountedValues(registry, form.getValues));
+ }}
+ >
+
+ Submit
+
+
+
+ );
+ };
+ render( );
+ return onFinish;
+};
+
+describe("EnvVarsSection", () => {
+ it("submits a per-user row whole, keeping the value key whose input the scope hides", async () => {
+ const onFinish = renderSection({
+ env_vars: [{ name: "DB_USER", value: "admin", scope: "user", description: "Your DB username" }],
+ });
+
+ expect(screen.queryByPlaceholderText("e.g. postgresql")).not.toBeInTheDocument();
+ await userEvent.click(screen.getByText("Submit"));
+
+ expect(onFinish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ env_vars: [{ name: "DB_USER", value: "admin", scope: "user", description: "Your DB username" }],
+ }),
+ );
+ });
+
+ it("submits an empty env_vars key when the list has no rows, rather than dropping the key", async () => {
+ const onFinish = renderSection({ env_vars: [] });
+
+ await userEvent.click(screen.getByText("Submit"));
+
+ expect(onFinish.mock.calls[0][0]).toHaveProperty("env_vars", []);
+ });
+
+ it("carries a row added after mount into the submitted list, scoped global without the user picking one", async () => {
+ const onFinish = renderSection({ env_vars: [] });
+
+ await userEvent.click(screen.getByText("Add Variable"));
+ await userEvent.type(screen.getByPlaceholderText("e.g. DB_PROTOCOL"), "DB_PROTOCOL");
+ await userEvent.type(screen.getByPlaceholderText("e.g. postgresql"), "postgresql");
+ await userEvent.click(screen.getByText("Submit"));
+
+ expect(onFinish).toHaveBeenCalledWith(
+ expect.objectContaining({
+ env_vars: [expect.objectContaining({ name: "DB_PROTOCOL", value: "postgresql", scope: "global" })],
+ }),
+ );
+ });
+
+ it("rejects a variable name that starts with a digit", async () => {
+ renderSection({ env_vars: [{ name: "", value: "", scope: "global", description: "" }] });
+
+ await userEvent.type(screen.getByPlaceholderText("e.g. DB_PROTOCOL"), "9LIVES");
+
+ expect(await screen.findByText("Use letters, digits, underscores; cannot start with a digit.")).toBeInTheDocument();
+ });
+});
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..539a06910c1 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,16 @@
import React from "react";
-import { Form, Input, Select, Button, Tooltip, Typography } from "antd";
+import { Input, Select, Button, Tooltip, Typography } from "antd";
import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
+import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
+
+import {
+ MountedFormField,
+ useMountedName,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
+import { antdRequired } from "@/components/common_components/antdFormRules";
+import { matchesPattern, selectControl, textControl } from "./mcpFieldRules";
+import { listControl } from "./mcpFormStore";
const { Text } = Typography;
@@ -9,6 +19,8 @@ const SCOPE_OPTIONS = [
{ value: "user", label: "Per-user" },
];
+const VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
+
/**
* Form section for admin-configured MCP environment variables.
*
@@ -20,6 +32,10 @@ const SCOPE_OPTIONS = [
* The parent form reads the ``env_vars`` field from the form values.
*/
const EnvVarsSection: React.FC = () => {
+ const { control } = useFormContext();
+ const { fields, append, remove } = useFieldArray({ control: listControl(control), name: "env_vars" });
+ useMountedName("env_vars");
+
return (
@@ -48,60 +64,52 @@ 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((item, index) => (
+
+
+ {(control) => (
+
+ )}
+
+
+
+
+
+ {(control) => (control)} options={SCOPE_OPTIONS} />}
+
+
+ remove(index)}
+ className="text-gray-500 hover:text-red-500 cursor-pointer"
+ />
+
+
+ ))}
+
append({ scope: "global" })} icon={ } block>
+ Add Variable
+
+
);
};
@@ -109,33 +117,33 @@ const EnvVarsSection: React.FC = () => {
// For instance-scoped vars this column holds the admin value. For per-user
// vars the value comes from each user later, so the column instead captures an
// optional description that the per-user fill-in modal shows as a hint.
-const ScopedValueOrDescription: React.FC<{
- name: number;
- restField: object;
-}> = ({ name, restField }) => {
- const isPerUser = Form.useWatch(["env_vars", name, "scope"]) === "user";
+const ScopedValueOrDescription: React.FC<{ index: number }> = ({ index }) => {
+ const isPerUser = useWatch({ name: `env_vars.${index}.scope` }) === "user";
if (isPerUser) {
return (
-
-
-
-
- Hint
-
-
- }
- placeholder="e.g. Your DB username"
- styles={{ input: { color: "#9ca3af" } }}
- />
-
+
+ {(control) => (
+
+
+
+ Hint
+
+
+ }
+ placeholder="e.g. Your DB username"
+ styles={{ input: { color: "#9ca3af" } }}
+ />
+ )}
+
);
}
return (
-
-
-
+
+ {(control) => }
+
);
};
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..9a6f4ab571f 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,7 +1,11 @@
import React from "react";
-import { Form, Input, Select, Tooltip } from "antd";
+import { Input, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { antdRequired } from "@/components/common_components/antdFormRules";
+import { requiredUnlessSiblingSet, selectControl, textControl } from "./mcpFieldRules";
+
interface IdJagFormFieldsProps {
isEditing?: boolean;
}
@@ -17,12 +21,16 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
);
+const PRIVATE_KEY_PATH = ["credentials", "client_private_key"] as const;
+
const IdJagFormFields: React.FC = ({ isEditing = false }) => {
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
+ const requiredWhenCreating = (message: string) =>
+ isEditing ? undefined : { validate: { required: antdRequired(message) } };
return (
<>
- = ({ isEditing = false })
/>
}
name="token_exchange_endpoint"
- rules={[{ required: !isEditing, message: "The org token endpoint is required for ID-JAG" }]}
+ required={!isEditing}
+ rules={requiredWhenCreating("The org token endpoint is required for ID-JAG")}
>
-
-
- (
+
+ )}
+
+ = ({ isEditing = false })
/>
}
name={["credentials", "id_jag_resource_token_endpoint"]}
- rules={[{ required: !isEditing, message: "The resource token endpoint is required for ID-JAG" }]}
+ required={!isEditing}
+ rules={requiredWhenCreating("The resource token endpoint is required for ID-JAG")}
>
-
-
- (
+
+ )}
+
+ }
name={["credentials", "client_id"]}
- rules={[{ required: !isEditing, message: "Client ID is required for ID-JAG" }]}
+ required={!isEditing}
+ rules={requiredWhenCreating("Client ID is required for ID-JAG")}
>
-
-
- (
+
+ )}
+
+ = ({ isEditing = false })
/>
}
name={["credentials", "client_secret"]}
- dependencies={[["credentials", "client_private_key"]]}
- rules={[
- ({ getFieldValue }) => ({
- validator: (_, value) => {
- if (isEditing || value || getFieldValue(["credentials", "client_private_key"])) {
- return Promise.resolve();
+ rules={
+ isEditing
+ ? undefined
+ : {
+ deps: ["credentials.client_private_key"],
+ validate: {
+ secretOrPrivateKey: requiredUnlessSiblingSet(
+ PRIVATE_KEY_PATH,
+ "Provide either a client secret or a client private key",
+ ),
+ },
}
- return Promise.reject(new Error("Provide either a client secret or a client private key"));
- },
- }),
- ]}
+ }
>
-
-
- (
+
+ )}
+
+
}
- name={["credentials", "client_private_key"]}
+ name={PRIVATE_KEY_PATH}
>
-
-
- (
+
+ )}
+
+ = ({ isEditing = false })
}
name={["credentials", "client_private_key_id"]}
>
-
-
- }
+
+ = ({ isEditing = false })
}
name={["credentials", "client_assertion_signing_alg"]}
>
-
-
- }
+
+ = ({ isEditing = false })
}
name="audience"
>
-
-
- (
+
+ )}
+
+ = ({ isEditing = false })
}
name={["credentials", "id_jag_resource"]}
>
-
-
- (
+
+ )}
+
+ = ({ isEditing = false })
}
name="subject_token_type"
>
-
-
- (
+
+ )}
+
+ }
name={["credentials", "scopes"]}
>
-
-
+ {(control) => (
+
+ )}
+
>
);
};
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..7d36872cee8 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
@@ -1,10 +1,10 @@
import React from "react";
-import { render, screen } from "@testing-library/react";
+import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
-import { Form } from "antd";
import MCPPermissionManagement from "./MCPPermissionManagement";
+import { renderInMcpForm } from "./McpFormTestHarness";
const defaultProps = {
availableAccessGroups: [],
@@ -12,6 +12,7 @@ const defaultProps = {
searchValue: "",
setSearchValue: () => {},
getAccessGroupOptions: () => [],
+ mountedAuthType: undefined,
};
describe("MCPPermissionManagement", () => {
@@ -24,22 +25,8 @@ describe("MCPPermissionManagement", () => {
return user;
};
- const renderWithForm = (props = {}) => {
- const Wrapper: React.FC = ({ children }) => {
- const [form] = Form.useForm();
- return (
-
- {children}
-
- );
- };
-
- return render(
-
-
- ,
- );
- };
+ const renderWithForm = (props = {}) =>
+ renderInMcpForm( , { allow_all_keys: false });
it("should default allow_all_keys switch to unchecked for new servers", async () => {
renderWithForm();
@@ -51,27 +38,15 @@ describe("MCPPermissionManagement", () => {
expect(toggle).not.toBeChecked();
});
- const renderWithInitialValues = (initialValues: Record, 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 = {}) =>
+ renderInMcpForm(
+ ,
+ initialValues,
);
- };
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..3711140562f 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,17 @@
import React, { useEffect } from "react";
-import { Alert, Form, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd";
+import { Alert, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd";
import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
+import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
import { MCPServer, AUTH_TYPE } from "@/components/mcp_tools/types";
+import {
+ MountedFormField,
+ useMountedName,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
+import { antdRequired } from "@/components/common_components/antdFormRules";
+import { Field, FieldLabel } from "@/components/shared/form/field";
+import { invertedSwitchControl, selectControl, switchControl, textControl } from "./mcpFieldRules";
+import { listControl } from "./mcpFormStore";
const { Panel } = Collapse;
interface MCPPermissionManagementProps {
@@ -13,20 +23,79 @@ interface MCPPermissionManagementProps {
value: string;
label: React.ReactNode;
}>;
+ /**
+ * The auth type as seen through the gate that mounts the auth_type field.
+ * Callers pass undefined whenever that field is unmounted, because both
+ * toggles below are mounted from this value and the payload only carries
+ * what is mounted.
+ */
+ mountedAuthType: string | null | undefined;
}
+const StaticHeadersFieldArray: React.FC = () => {
+ const { control } = useFormContext();
+ const { fields, append, remove } = useFieldArray({ control: listControl(control), name: "static_headers" });
+ useMountedName("static_headers");
+
+ return (
+
+ );
+};
+
const MCPPermissionManagement: React.FC = ({
availableAccessGroups,
mcpServer,
searchValue,
setSearchValue,
getAccessGroupOptions,
+ mountedAuthType,
}) => {
- const form = Form.useFormInstance();
- const watchedAuthType = Form.useWatch("auth_type", form);
- const isOAuth2 = watchedAuthType === AUTH_TYPE.OAUTH2;
- const isNoneAuth = watchedAuthType === AUTH_TYPE.NONE || watchedAuthType == null;
- const watchedExtraHeaders = Form.useWatch("extra_headers", form);
+ const { setValue } = useFormContext();
+ const isOAuth2 = mountedAuthType === AUTH_TYPE.OAUTH2;
+ const isNoneAuth = mountedAuthType === AUTH_TYPE.NONE || mountedAuthType == null;
+ const watchedExtraHeaders = useWatch({ name: "extra_headers" });
const hasAuthorizationHeader =
Array.isArray(watchedExtraHeaders) &&
watchedExtraHeaders.some((h) => typeof h === "string" && h.toLowerCase() === "authorization");
@@ -39,8 +108,8 @@ const MCPPermissionManagement: React.FC = ({
// Kept as separate flags so neither silently implies the other and existing
// oauth2 servers can't regress into pass-through behavior.
const canEnableOAuthPassthrough = isNoneAuth && hasAuthorizationHeader;
- const watchedDelegateAuth = Form.useWatch("delegate_auth_to_upstream", form);
- const watchedPublicInternet = Form.useWatch("available_on_public_internet", form);
+ const watchedDelegateAuth = useWatch({ name: "delegate_auth_to_upstream" });
+ const watchedPublicInternet = useWatch({ name: "available_on_public_internet" });
const showInternalDelegatePkceWarning = isOAuth2 && watchedDelegateAuth === true && watchedPublicInternet === false;
// Set initial values when mcpServer changes
@@ -51,10 +120,10 @@ const MCPPermissionManagement: React.FC = ({
header,
value: value != null ? String(value) : "",
}));
- form.setFieldValue("static_headers", staticHeaders);
+ setValue("static_headers", staticHeaders);
}
if (Array.isArray(mcpServer.env_vars) && mcpServer.env_vars.length > 0) {
- form.setFieldValue(
+ setValue(
"env_vars",
mcpServer.env_vars.map((entry) => ({
name: entry.name,
@@ -65,41 +134,41 @@ const MCPPermissionManagement: React.FC = ({
);
}
if (typeof mcpServer.allow_all_keys === "boolean") {
- form.setFieldValue("allow_all_keys", mcpServer.allow_all_keys);
+ setValue("allow_all_keys", mcpServer.allow_all_keys);
}
if (typeof mcpServer.available_on_public_internet === "boolean") {
- form.setFieldValue("available_on_public_internet", mcpServer.available_on_public_internet);
+ setValue("available_on_public_internet", mcpServer.available_on_public_internet);
}
if (typeof mcpServer.delegate_auth_to_upstream === "boolean") {
- form.setFieldValue("delegate_auth_to_upstream", mcpServer.delegate_auth_to_upstream);
+ setValue("delegate_auth_to_upstream", mcpServer.delegate_auth_to_upstream);
}
if (typeof mcpServer.oauth_passthrough === "boolean") {
- form.setFieldValue("oauth_passthrough", mcpServer.oauth_passthrough);
+ setValue("oauth_passthrough", mcpServer.oauth_passthrough);
}
} else {
- form.setFieldValue("allow_all_keys", false);
- form.setFieldValue("available_on_public_internet", true);
- form.setFieldValue("delegate_auth_to_upstream", false);
- form.setFieldValue("oauth_passthrough", false);
+ setValue("allow_all_keys", false);
+ setValue("available_on_public_internet", true);
+ setValue("delegate_auth_to_upstream", false);
+ setValue("oauth_passthrough", false);
}
- }, [mcpServer, form]);
+ }, [mcpServer, setValue]);
// delegate_auth_to_upstream is only honored server-side for oauth2 servers.
// Force it back to false whenever the user switches away from oauth2 so a
// stale toggle value doesn't get persisted unexpectedly.
useEffect(() => {
if (!isOAuth2) {
- form.setFieldValue("delegate_auth_to_upstream", false);
+ setValue("delegate_auth_to_upstream", false);
}
- }, [isOAuth2, form]);
+ }, [isOAuth2, setValue]);
// oauth_passthrough is only honored for auth_type=none servers that forward
// Authorization upstream. Force it back to false otherwise.
useEffect(() => {
if (!canEnableOAuthPassthrough) {
- form.setFieldValue("oauth_passthrough", false);
+ setValue("oauth_passthrough", false);
}
- }, [canEnableOAuthPassthrough, form]);
+ }, [canEnableOAuthPassthrough, setValue]);
return (
@@ -130,14 +199,9 @@ const MCPPermissionManagement: React.FC = ({
Enable if this server should be "public" to all keys.
-
-
-
+
+ {(control) => }
+
@@ -152,16 +216,9 @@ 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"
- >
-
-
+
+ {(control) => }
+
{isOAuth2 && (
@@ -177,14 +234,13 @@ const MCPPermissionManagement: React.FC = ({
Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server.
-
-
-
+ {(control) => }
+
)}
@@ -202,14 +258,13 @@ const MCPPermissionManagement: React.FC = ({
upstream MCP server.
-
-
-
+ {(control) => }
+
)}
@@ -223,7 +278,7 @@ const MCPPermissionManagement: React.FC = ({
/>
)}
-
MCP Access Groups
@@ -235,21 +290,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
- />
-
+ {(control) => (
+ (option?.value ?? "").toLowerCase().includes(input.toLowerCase())}
+ onSearch={(value) => setSearchValue(value)}
+ tokenSeparators={[","]}
+ options={getAccessGroupOptions()}
+ maxTagCount="responsive"
+ allowClear
+ />
+ )}
+
-
Extra Headers
@@ -265,70 +323,34 @@ 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
- />
-
+ {(control) => (
+ 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 }) => (
-
- )}
-
-
+
+
+
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/McpFormTestHarness.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/McpFormTestHarness.tsx
new file mode 100644
index 00000000000..0529c9c7c61
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/McpFormTestHarness.tsx
@@ -0,0 +1,37 @@
+import * as React from "react";
+import { render, type RenderResult } from "@testing-library/react";
+import { FormProvider, useForm } from "react-hook-form";
+
+import {
+ MountedFormProvider,
+ projectMountedValues,
+ useMountRegistry,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
+
+export const McpFormHarness: React.FC<{
+ defaultValues?: MountedFormValues;
+ onFinish?: (values: MountedFormValues) => void;
+ children: React.ReactNode;
+}> = ({ defaultValues, onFinish, children }) => {
+ const form = useForm({ mode: "onChange", defaultValues });
+ const registry = useMountRegistry();
+ return (
+
+
+ {
+ event.preventDefault();
+ onFinish?.(projectMountedValues(registry, form.getValues));
+ }}
+ >
+ {children}
+ Submit
+
+
+
+ );
+};
+
+export const renderInMcpForm = (ui: React.ReactNode, defaultValues: MountedFormValues = {}): RenderResult =>
+ render({ui} );
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..21bb2801e8c 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,24 +1,8 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
-import { Form } from "antd";
import OAuthFormFields from "./OAuthFormFields";
-
-// ── helpers ──────────────────────────────────────────────────────────────────
-
-/** Minimal Ant Form wrapper so Form.Item registers correctly. */
-const WithForm: React.FC<{ children: React.ReactNode; onFinish?: (values: any) => void }> = ({
- children,
- onFinish,
-}) => {
- const [form] = Form.useForm();
- return (
-
- {children}
- Submit
-
- );
-};
+import { McpFormHarness as WithForm } from "./McpFormTestHarness";
// ── tests ─────────────────────────────────────────────────────────────────────
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..545efb910c4 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,10 +1,13 @@
import React from "react";
-import { Form, Input as AntdInput, InputNumber, Select, Tooltip } from "antd";
+import { Input as AntdInput, InputNumber, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { OAUTH_FLOW } from "@/components/mcp_tools/types";
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { antdRequired } from "@/components/common_components/antdFormRules";
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
+import { numberControl, parsesAsJson, selectControl, textControl } from "./mcpFieldRules";
interface OAuthFlowStatus {
startOAuthFlow: () => void;
@@ -41,12 +44,14 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
);
const UpstreamResourceField: React.FC = () => (
- }
name={["credentials", "upstream_resource"]}
>
-
-
+ {(control) => (
+
+ )}
+
);
const OAuthFormFields: React.FC = ({
@@ -57,11 +62,12 @@ 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 ? undefined : { validate: { required: antdRequired(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
-
-
-
-
+ {(control) => (
+
+
+
+ Machine-to-Machine (M2M)
+ server-to-server, no user interaction
+
+
+
+
+ Interactive (PKCE)
+ browser-based user authorization
+
+
+
+ )}
+
{isM2M ? (
<>
- }
name={["credentials", "client_id"]}
+ required={!isEditing}
rules={requiredWhenCreating("Client ID is required for M2M OAuth")}
>
-
-
- (
+
+ )}
+
+
}
name={["credentials", "client_secret"]}
+ required={!isEditing}
rules={requiredWhenCreating("Client Secret is required for M2M OAuth")}
>
-
-
- (
+
+ )}
+
+ }
name="token_url"
+ required={!isEditing}
rules={requiredWhenCreating("Token URL is required for M2M OAuth")}
>
-
-
+ {(control) => (
+
+ )}
+
- = ({
}
name={["credentials", "scopes"]}
>
-
-
+ {(control) => (
+
+ )}
+
>
) : (
<>
-
= ({
}
name={["credentials", "client_id"]}
>
-
-
- (
+
+ )}
+
+ = ({
}
name={["credentials", "client_secret"]}
>
-
-
- (
+
+ )}
+
+ = ({
}
name={["credentials", "scopes"]}
>
-
-
+ {(control) => (
+
+ )}
+
- = ({
}
name="issuer"
>
-
-
- (
+
+ )}
+
+ = ({
}
name="authorization_url"
>
-
-
- (
+
+ )}
+
+ }
name="token_url"
>
-
-
+ {(control) => (
+
+ )}
+
- = ({
}
name="registration_url"
>
-
-
- (
+
+ )}
+
+ = ({
/>
}
name="token_validation_json"
- rules={[
- {
- validator: (_: any, value: string) => {
- if (!value || value.trim() === "") return Promise.resolve();
- try {
- JSON.parse(value);
- return Promise.resolve();
- } catch {
- return Promise.reject(new Error("Must be valid JSON"));
- }
- },
- },
- ]}
+ rules={{ validate: { json: parsesAsJson("Must be valid JSON") } }}
>
-
-
- (
+
+ )}
+
+ = ({
}
name="token_storage_ttl_seconds"
>
-
-
+ {(control) => (
+
+ )}
+
{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..78c8bbe73a9 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx
@@ -1,12 +1,15 @@
import React, { useState } from "react";
-import { Form, Input, Tooltip } from "antd";
+import { Input, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
-import { FormInstance } from "antd/es/form";
import { AUTH_TYPE, OAUTH_FLOW } from "@/components/mcp_tools/types";
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { antdRequired } from "@/components/common_components/antdFormRules";
import OpenAPIQuickPicker, { OpenAPIRegistryEntry, OpenAPIKeyTool } from "./OpenAPIQuickPicker";
+import { McpForm, resetFields, setFieldsValue } from "./mcpFormStore";
+import { textControl } from "./mcpFieldRules";
interface OpenAPIFormSectionProps {
- form: FormInstance;
+ form: McpForm;
accessToken: string | null;
/** Called when a preset is selected so the parent can sync its formValues state. */
onValuesChange: (updates: Record) => void;
@@ -47,13 +50,11 @@ const OpenAPIFormSection: React.FC = ({
updates.oauth_flow_type = OAUTH_FLOW.INTERACTIVE;
updates.authorization_url = entry.oauth.authorization_url;
updates.token_url = entry.oauth.token_url;
- form.setFieldsValue(updates);
+ setFieldsValue(form, updates);
onOAuthDocsUrlChange?.(entry.oauth.docs_url ?? null);
} else {
- // resetFields is required to visually clear Ant Design form fields —
- // setFieldsValue with undefined silently skips undefined keys.
- form.resetFields(["auth_type", "authorization_url", "token_url"]);
- form.setFieldsValue(updates);
+ resetFields(form, ["auth_type", "authorization_url", "token_url"]);
+ setFieldsValue(form, updates);
onOAuthDocsUrlChange?.(null);
}
onValuesChange(updates);
@@ -63,7 +64,7 @@ const OpenAPIFormSection: React.FC = ({
<>
-
OpenAPI Spec URL
@@ -73,20 +74,25 @@ const OpenAPIFormSection: React.FC = ({
}
name="spec_path"
- rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]}
+ required
+ rules={{ validate: { required: antdRequired("Please enter an OpenAPI spec URL") } }}
>
- {
- // Clear the preset selection when the user manually edits the spec URL
- // so stale suggested tools from a previous preset don't persist.
- setSelectedPreset(null);
- onKeyToolsChange?.([]);
- onOAuthDocsUrlChange?.(null);
- }}
- />
-
+ {(control) => (
+ {
+ control.onChange(event);
+ // Clear the preset selection when the user manually edits the spec URL
+ // so stale suggested tools from a previous preset don't persist.
+ setSelectedPreset(null);
+ onKeyToolsChange?.([]);
+ onOAuthDocsUrlChange?.(null);
+ }}
+ />
+ )}
+
>
);
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx
index 2ac4279e20a..83c841439f0 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx
@@ -1,91 +1,101 @@
import React from "react";
-import { Form, Input, Select, Switch, Tooltip } from "antd";
+import { Input, Select, Switch, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { useWatch } from "react-hook-form";
-const OpenApiByokFields: React.FC = () => (
- <>
-
- BYOK (Bring Your Own Key)
-
-
-
-
- }
- name="is_byok"
- valuePropName="checked"
- >
-
-
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { selectControl, switchControl, textControl } from "./mcpFieldRules";
- prev.is_byok !== cur.is_byok || prev.auth_type !== cur.auth_type}>
- {({ getFieldValue }) =>
- getFieldValue("is_byok") ? (
- <>
- {/* Auth format hint */}
- {getFieldValue("auth_type") && getFieldValue("auth_type") !== "none" && (
-
-
-
- User keys will be sent as:{" "}
-
- {getFieldValue("auth_type") === "bearer_token" && "Authorization: Bearer {key}"}
- {getFieldValue("auth_type") === "token" && "Authorization: token {key}"}
- {getFieldValue("auth_type") === "api_key" && "x-api-key: {key}"}
- {getFieldValue("auth_type") === "basic" && "Authorization: Basic {key}"}
- {getFieldValue("auth_type") === "authorization" && "Authorization: {key}"}
-
- {!getFieldValue("auth_type") && "Set Authentication Type below to specify the format."}
-
-
- )}
- {!getFieldValue("auth_type") && (
-
-
-
- Set the Authentication Type below to specify how user keys are sent (e.g., Bearer
- Token, API Key header).
-
-
- )}
-
- Access Description
-
-
-
-
- }
- name="byok_description"
- >
+const AUTH_HEADER_FORMATS: Readonly> = {
+ bearer_token: "Authorization: Bearer {key}",
+ token: "Authorization: token {key}",
+ api_key: "x-api-key: {key}",
+ basic: "Authorization: Basic {key}",
+ authorization: "Authorization: {key}",
+};
+
+const OpenApiByokFields: React.FC = () => {
+ const isByok = Boolean(useWatch({ name: "is_byok" }));
+ const authType = useWatch({ name: "auth_type" }) as string | undefined;
+ const hasAuthType = Boolean(authType) && authType !== "none";
+
+ return (
+ <>
+
+ BYOK (Bring Your Own Key)
+
+
+
+
+ }
+ name="is_byok"
+ >
+ {(control) => }
+
+
+ {isByok && (
+ <>
+ {hasAuthType && (
+
+
+
+ User keys will be sent as:{" "}
+
+ {authType === undefined ? "" : AUTH_HEADER_FORMATS[authType]}
+
+
+
+ )}
+ {!authType && (
+
+
+
+ Set the Authentication Type below to specify how user keys are sent (e.g., Bearer
+ Token, API Key header).
+
+
+ )}
+
+ Access Description
+
+
+
+
+ }
+ name="byok_description"
+ >
+ {(control) => (
-
+ )}
+
-
- API Key Help URL
-
-
-
-
- }
- name="byok_api_key_help_url"
- >
-
-
- >
- ) : null
- }
-
- >
-);
+
+ API Key Help URL
+
+
+
+
+ }
+ name="byok_api_key_help_url"
+ >
+ {(control) => }
+
+ >
+ )}
+ >
+ );
+};
export default OpenApiByokFields;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx
index 0a09ef3f856..a7577ccb781 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx
@@ -1,13 +1,10 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
-import { Form } from "antd";
import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
+import { McpFormHarness } from "./McpFormTestHarness";
-const WithForm: React.FC<{ children: React.ReactNode }> = ({ children }) => {
- const [form] = Form.useForm();
- return {children} ;
-};
+const WithForm = McpFormHarness;
const noopFlow = { startOAuthFlow: () => {}, status: "idle", error: null, tokenResponse: null };
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx
index dc10f0f1392..375e1dfe515 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx
@@ -1,6 +1,8 @@
import React from "react";
-import { Button, Checkbox, Form, Input } from "antd";
+import { Button, Checkbox, Input } from "antd";
import DcrBridgeToggle from "./DcrBridgeToggle";
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { textControl } from "./mcpFieldRules";
import { credentialAuthClass, isClientForwardedTokenMode } from "@/components/mcp_tools/types";
interface PassthroughOAuthFlow {
@@ -81,27 +83,33 @@ export default function PassthroughAuthorizeSection({
and may not be valid. Update the client ID, or clear it to use dynamic client registration.
)}
-
OAuth Client ID (optional)}
name={["credentials", "client_id"]}
- extra={clientIdExtra}
+ help={clientIdExtra}
>
-
-
-
(
+
+ )}
+
+ OAuth Client Secret (optional)}
name={["credentials", "client_secret"]}
>
-
-
+ {(control) => (
+
+ )}
+
{isEditing && onRemoveStoredAppChange && (
onRemoveStoredAppChange(e.target.checked)}>
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx
index 476a5b61683..bb410aa19dc 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx
@@ -1,7 +1,11 @@
import React from "react";
-import { Form, Input, Tooltip } from "antd";
+import { Input, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { antdRequired } from "@/components/common_components/antdFormRules";
+import { parsesAsJson, textControl } from "./mcpFieldRules";
+
interface StdioConfigurationProps {
isVisible: boolean;
/**
@@ -11,37 +15,7 @@ interface StdioConfigurationProps {
required?: boolean;
}
-const StdioConfiguration: React.FC = ({ isVisible, required = true }) => {
- if (!isVisible) return null;
-
- return (
-
- Stdio Configuration (JSON)
-
-
-
-
- }
- name="stdio_config"
- rules={[
- ...(required ? [{ required: true, message: "Please enter stdio configuration" }] : []),
- {
- validator: (_, value) => {
- if (!value) return Promise.resolve();
- try {
- JSON.parse(value);
- return Promise.resolve();
- } catch {
- return Promise.reject("Please enter valid JSON");
- }
- },
- },
- ]}
- >
- = ({ isVisible, requ
}
}
}
-}`}
- rows={12}
- className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm"
- />
-
+}`;
+
+const StdioConfiguration: React.FC = ({ isVisible, required = true }) => {
+ if (!isVisible) return null;
+
+ return (
+
+ Stdio Configuration (JSON)
+
+
+
+
+ }
+ name="stdio_config"
+ required={required}
+ rules={{
+ validate: {
+ ...(required ? { required: antdRequired("Please enter stdio configuration") } : {}),
+ json: parsesAsJson("Please enter valid JSON"),
+ },
+ }}
+ >
+ {(control) => (
+
+ )}
+
);
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx
index c97ce96bfb1..38fa3573079 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx
@@ -1,7 +1,10 @@
import React from "react";
-import { Form, Select, Tooltip } from "antd";
+import { Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { selectControl } from "./mcpFieldRules";
+
const TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS = [
{ value: "client_secret_basic", label: "Client Secret Basic" },
{ value: "client_secret_post", label: "Client Secret Post" },
@@ -12,7 +15,7 @@ interface TokenEndpointAuthMethodFieldProps {
}
const TokenEndpointAuthMethodField: React.FC = ({ isEditing = false }) => (
-
Token Endpoint Auth Method (optional)
@@ -23,16 +26,19 @@ const TokenEndpointAuthMethodField: React.FC
}
name={["credentials", "token_endpoint_auth_method"]}
>
-
-
+ {(control) => (
+
+ )}
+
);
export default TokenEndpointAuthMethodField;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx
index 9e1e1a85743..ba213b20655 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx
@@ -1,6 +1,11 @@
import React from "react";
-import { Form, Input, Select, Tooltip } from "antd";
+import { Input, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { useWatch } from "react-hook-form";
+
+import { MountedFormField } from "@/components/common_components/MountedFormField";
+import { antdRequired } from "@/components/common_components/antdFormRules";
+import { selectControl, textControl } from "./mcpFieldRules";
interface TokenExchangeFormFieldsProps {
isEditing?: boolean;
@@ -19,10 +24,13 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
const TokenExchangeFormFields: React.FC = ({ isEditing = false }) => {
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
+ const isEntraObo = useWatch({ name: "token_exchange_profile" }) === "entra_obo";
+ const requiredWhenCreating = (message: string) =>
+ isEditing ? undefined : { validate: { required: antdRequired(message) } };
return (
<>
- = ({ isEdi
/>
}
name="token_exchange_profile"
- {...(isEditing ? {} : { initialValue: "rfc8693" })}
+ {...(isEditing ? {} : { defaultValue: "rfc8693" })}
>
-
-
- RFC 8693 (standard)
-
-
- Microsoft Entra OBO
-
-
-
- (
+
+
+ RFC 8693 (standard)
+
+
+ Microsoft Entra OBO
+
+
+ )}
+
+ = ({ isEdi
}
name="token_exchange_endpoint"
>
-
-
- (
+
+ )}
+
+ = ({ isEdi
/>
}
name={["credentials", "client_id"]}
- rules={[{ required: !isEditing, message: "Client ID is required for token exchange" }]}
+ required={!isEditing}
+ rules={requiredWhenCreating("Client ID is required for token exchange")}
>
-
-
- (
+
+ )}
+
+ = ({ isEdi
/>
}
name={["credentials", "client_secret"]}
- rules={[{ required: !isEditing, message: "Client Secret is required for token exchange" }]}
+ required={!isEditing}
+ rules={requiredWhenCreating("Client Secret is required for token exchange")}
>
-
-
- prev.token_exchange_profile !== cur.token_exchange_profile}>
- {({ getFieldValue }) => {
- const isEntraObo = getFieldValue("token_exchange_profile") === "entra_obo";
- return (
- <>
- {!isEntraObo && (
- <>
-
- }
- name="audience"
- >
-
-
-
- }
- name="subject_token_type"
- >
-
-
- >
- )}
- /.default)."
- : "Optional scopes to request during the token exchange."
- }
- />
- }
- name={["credentials", "scopes"]}
- rules={
- isEntraObo
- ? [
- {
- required: true,
- message: "Microsoft Entra OBO requires a scope, e.g. api:///.default",
- },
- ]
- : []
- }
- >
- /.default" : "Add scopes"}
- className="rounded-lg"
- size="large"
- />
-
- >
- );
- }}
-
+ {(control) => (
+
+ )}
+
+ {!isEntraObo && (
+ <>
+
+ }
+ name="audience"
+ >
+ {(control) => (
+
+ )}
+
+
+ }
+ name="subject_token_type"
+ >
+ {(control) => (
+
+ )}
+
+ >
+ )}
+ /.default)."
+ : "Optional scopes to request during the token exchange."
+ }
+ />
+ }
+ name={["credentials", "scopes"]}
+ required={isEntraObo}
+ rules={
+ isEntraObo
+ ? {
+ validate: {
+ required: antdRequired("Microsoft Entra OBO requires a scope, e.g. api:///.default"),
+ },
+ }
+ : undefined
+ }
+ >
+ {(control) => (
+ /.default" : "Add scopes"}
+ className="rounded-lg"
+ size="large"
+ />
+ )}
+
>
);
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts
index 05e64468f4f..f9cab181e71 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts
@@ -236,7 +236,7 @@ const legacyBuild = (values: Record, ui: EditServerUiState) => {
allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys),
available_on_public_internet: Boolean(availableOnPublicInternetRaw ?? mcpServer.available_on_public_internet),
// ``delegate_auth_to_upstream`` is only honored server-side for
- // ``auth_type=oauth2`` (PKCE passthrough). The Form.Item is
+ // ``auth_type=oauth2`` (PKCE passthrough). The field is
// conditionally rendered so the value drops out of the form on
// auth_type change; force false for any other configuration to avoid
// persisting a stale ``true`` that would silently re-activate if the
@@ -258,7 +258,7 @@ const legacyBuild = (values: Record, ui: EditServerUiState) => {
return isNoneAuth && hasAuthorizationHeader ? Boolean(oauthPassthroughRaw ?? mcpServer.oauth_passthrough) : false;
})(),
// ``dcr_bridge`` is only meaningful for the client-forwarded token
- // modes (true_passthrough / oauth_delegate). The Form.Item is
+ // modes (true_passthrough / oauth_delegate). The field is
// conditionally rendered so the value drops out of the form on
// auth_type change; force false for any other configuration to avoid
// persisting a stale ``true`` that would silently re-activate if the
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFieldRules.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFieldRules.ts
new file mode 100644
index 00000000000..76ff74eac52
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFieldRules.ts
@@ -0,0 +1,98 @@
+import type { Validate } from "react-hook-form";
+
+import type { MountedFieldControlProps, MountedFormValues } from "@/components/common_components/MountedFormField";
+
+type McpValidate = Validate;
+
+const ariaOf = (control: MountedFieldControlProps) => ({
+ id: control.id,
+ onBlur: control.onBlur,
+ "aria-required": control["aria-required"],
+ "aria-invalid": control["aria-invalid"],
+ "aria-describedby": control["aria-describedby"],
+});
+
+export const textControl = (control: MountedFieldControlProps) => ({
+ ...ariaOf(control),
+ name: control.name,
+ value: control.value === null || control.value === undefined ? "" : String(control.value),
+ onChange: control.onChange,
+});
+
+export const selectControl = (control: MountedFieldControlProps) => ({
+ ...ariaOf(control),
+ value: control.value as TValue,
+ onChange: control.onChange,
+});
+
+export const numberControl = (control: MountedFieldControlProps) => ({
+ ...ariaOf(control),
+ value: control.value as number | null | undefined,
+ onChange: control.onChange,
+});
+
+export const switchControl = (control: MountedFieldControlProps) => ({
+ ...ariaOf(control),
+ checked: control.value === true,
+ onChange: control.onChange,
+});
+
+export const invertedSwitchControl = (control: MountedFieldControlProps) => ({
+ ...ariaOf(control),
+ checked: control.value !== true,
+ onChange: (checked: boolean) => control.onChange(!checked),
+});
+
+export const valueAt = (values: MountedFormValues, path: readonly string[]): unknown =>
+ path.reduce(
+ (node, segment) => (node === null || node === undefined ? undefined : (node as Record)[segment]),
+ values,
+ );
+
+export const parsesAsJson =
+ (message: string): McpValidate =>
+ (value) => {
+ if (typeof value !== "string" || value.trim() === "") {
+ return true;
+ }
+ try {
+ JSON.parse(value);
+ return true;
+ } catch {
+ return message;
+ }
+ };
+
+export const parsesAsJsonObject =
+ (message: string, notObjectMessage: string): McpValidate =>
+ (value) => {
+ if (typeof value !== "string" || value === "") {
+ return true;
+ }
+ try {
+ const parsed: unknown = JSON.parse(value);
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? true : notObjectMessage;
+ } catch {
+ return message;
+ }
+ };
+
+export const matchesPattern =
+ (pattern: RegExp, message: string): McpValidate =>
+ (value) =>
+ typeof value === "string" && value !== "" && !pattern.test(value) ? message : true;
+
+export const notOnlyWhitespace =
+ (message: string): McpValidate =>
+ (value) =>
+ typeof value === "string" && value !== "" && value.trim() === "" ? message : true;
+
+export const requiredWhenSiblingSet =
+ (siblingPath: readonly string[], message: string): McpValidate =>
+ (value, values) =>
+ valueAt(values, siblingPath) && !value ? message : true;
+
+export const requiredUnlessSiblingSet =
+ (siblingPath: readonly string[], message: string): McpValidate =>
+ (value, values) =>
+ value || valueAt(values, siblingPath) ? true : message;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.test.tsx
new file mode 100644
index 00000000000..25eba43335e
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.test.tsx
@@ -0,0 +1,132 @@
+import React from "react";
+import { describe, expect, it } from "vitest";
+import { render } from "@testing-library/react";
+import { useForm } from "react-hook-form";
+
+import type { MountedFormValues } from "@/components/common_components/MountedFormField";
+import { allFieldsValue, deepMergedFieldsValue, resetFields, setFieldsValue, singleBranchChange } from "./mcpFormStore";
+
+const withForm = (
+ defaultValues: MountedFormValues,
+ act: (form: ReturnType>) => void,
+) => {
+ let store: MountedFormValues = {};
+ const Probe: React.FC = () => {
+ const form = useForm({ defaultValues });
+ React.useEffect(() => {
+ act(form);
+ store = allFieldsValue(form);
+ }, [form]);
+ return null;
+ };
+ render( );
+ return store;
+};
+
+describe("deepMergedFieldsValue", () => {
+ it("keeps a sibling key when a nested object is written, which is what preserves a declared app", () => {
+ expect(
+ deepMergedFieldsValue(
+ { credentials: { client_id: "kept", access_token: "tok" } },
+ { credentials: { client_id: "typed" } },
+ ),
+ ).toStrictEqual({ credentials: { client_id: "typed", access_token: "tok" } });
+ });
+
+ it("replaces an array rather than merging it index by index", () => {
+ expect(deepMergedFieldsValue({ extra_headers: ["a", "b", "c"] }, { extra_headers: ["z"] })).toStrictEqual({
+ extra_headers: ["z"],
+ });
+ });
+
+ it("writes an explicit undefined instead of skipping the key, which is how a transport switch clears a field", () => {
+ const merged = deepMergedFieldsValue({ url: "https://old", auth_type: "api_key" }, { url: undefined });
+ expect(merged).toStrictEqual({ url: undefined, auth_type: "api_key" });
+ expect("url" in merged).toBe(true);
+ });
+
+ it("writes an explicit null rather than treating it as a merge target", () => {
+ expect(deepMergedFieldsValue({ credentials: { client_id: "x" } }, { credentials: null })).toStrictEqual({
+ credentials: null,
+ });
+ });
+
+ it("replaces a primitive with an object when the incoming value is an object", () => {
+ expect(deepMergedFieldsValue({ credentials: "not-an-object" }, { credentials: { client_id: "x" } })).toStrictEqual({
+ credentials: { client_id: "x" },
+ });
+ });
+
+ it("does not mutate the store it was handed", () => {
+ const store = { credentials: { client_id: "kept" } };
+ deepMergedFieldsValue(store, { credentials: { client_secret: "added" } });
+ expect(store).toStrictEqual({ credentials: { client_id: "kept" } });
+ });
+
+ it("treats a missing store as empty rather than throwing", () => {
+ expect(deepMergedFieldsValue(undefined, { alias: "a" })).toStrictEqual({ alias: "a" });
+ });
+});
+
+describe("singleBranchChange", () => {
+ it("carries only the changed leaf, so re-applying it cannot resurrect a sibling token key", () => {
+ expect(
+ singleBranchChange("credentials.client_id", { credentials: { client_id: "typed", access_token: "stale" } }),
+ ).toStrictEqual({ credentials: { client_id: "typed" } });
+ });
+
+ it("exposes the changed top-level key so an upstream-field check can test membership", () => {
+ const changed = singleBranchChange("url", { url: "https://new", alias: "a" });
+ expect("url" in changed).toBe(true);
+ expect("alias" in changed).toBe(false);
+ });
+
+ it("builds an array for a numeric segment so a list row does not become an object keyed by index", () => {
+ expect(
+ singleBranchChange("static_headers.1.value", { static_headers: [{ value: "a" }, { value: "b" }] }),
+ ).toStrictEqual({ static_headers: [undefined, { value: "b" }] });
+ });
+
+ it("yields an undefined leaf rather than throwing when the path is not in the store", () => {
+ expect(singleBranchChange("credentials.client_secret", {})).toStrictEqual({
+ credentials: { client_secret: undefined },
+ });
+ });
+});
+
+describe("resetFields", () => {
+ it("restores the seeded value rather than clearing the key, so an edit reset keeps the saved server's credentials", () => {
+ const store = withForm({ credentials: { client_id: "saved", access_token: "tok" } }, (form) => {
+ form.setValue("credentials", { client_id: "typed" });
+ resetFields(form, ["credentials"], { credentials: { client_id: "saved", access_token: "tok" } });
+ });
+
+ expect(store.credentials).toStrictEqual({ client_id: "saved", access_token: "tok" });
+ });
+
+ it("clears the key when no seed is supplied, which is what the create form's blank store means", () => {
+ const store = withForm({ credentials: { client_id: "typed" } }, (form) => {
+ resetFields(form, ["credentials"]);
+ });
+
+ expect(store).toHaveProperty("credentials", undefined);
+ });
+});
+
+describe("setFieldsValue", () => {
+ it("writes an undefined leaf into the live store, so a transport switch really clears the field", () => {
+ const store = withForm({ url: "https://example.com", command: "npx" }, (form) => {
+ setFieldsValue(form, { url: undefined });
+ });
+
+ expect(store).toStrictEqual({ url: undefined, command: "npx" });
+ });
+
+ it("merges a nested write into the live store instead of replacing the whole object", () => {
+ const store = withForm({ credentials: { client_id: "kept", scopes: ["a"] } }, (form) => {
+ setFieldsValue(form, { credentials: { client_secret: "new" } });
+ });
+
+ expect(store.credentials).toStrictEqual({ client_id: "kept", scopes: ["a"], client_secret: "new" });
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.ts
new file mode 100644
index 00000000000..20df3e32273
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcpFormStore.ts
@@ -0,0 +1,107 @@
+import { useWatch } from "react-hook-form";
+import type { Control, UseFormReturn } from "react-hook-form";
+
+import {
+ projectMountedValues,
+ type MountRegistry,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
+
+export type McpForm = UseFormReturn;
+
+const isPlainObject = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype;
+
+export const deepMergedFieldsValue = (store: unknown, values: Record): Record =>
+ Object.entries(values).reduce>(
+ (merged, [key, value]) => ({
+ ...merged,
+ [key]: isPlainObject(value) ? deepMergedFieldsValue(merged[key], value) : value,
+ }),
+ isPlainObject(store) ? { ...store } : {},
+ );
+
+export const setFieldsValue = (form: McpForm, values: Record): void => {
+ const merged = deepMergedFieldsValue(form.getValues(), values);
+ Object.keys(values).forEach((key) => form.setValue(key, merged[key]));
+};
+
+export const resetFields = (form: McpForm, names: readonly string[], defaults: MountedFormValues = {}): void => {
+ names.forEach((name) => {
+ form.setValue(name, defaults[name]);
+ form.clearErrors(name);
+ });
+};
+
+const branchAt = (segments: readonly string[], leaf: unknown): unknown => {
+ const [head, ...rest] = segments;
+ if (head === undefined) {
+ return leaf;
+ }
+ const child = branchAt(rest, leaf);
+ if (!/^\d+$/.test(head)) {
+ return { [head]: child };
+ }
+ const index = Number(head);
+ return Array.from({ length: index + 1 }, (_, position) => (position === index ? child : undefined));
+};
+
+export const singleBranchChange = (path: string, values: MountedFormValues): Record => {
+ const segments = path.split(".");
+ const leaf = segments.reduce(
+ (node, segment) => (node === null || node === undefined ? undefined : (node as Record)[segment]),
+ values,
+ );
+ return branchAt(segments, leaf) as Record;
+};
+
+export interface McpStaticHeaderRow {
+ header?: string;
+ value?: string;
+}
+
+export interface McpEnvVarRow {
+ name?: string;
+ value?: string;
+ scope?: string;
+ description?: string;
+}
+
+export interface McpListValues {
+ static_headers: McpStaticHeaderRow[];
+ env_vars: McpEnvVarRow[];
+}
+
+export interface McpFormSnapshot extends MountedFormValues {
+ readonly server_name?: string;
+ readonly alias?: string;
+ readonly description?: string;
+ readonly url?: string;
+ readonly spec_path?: string;
+ readonly transport?: string;
+ readonly auth_type?: string;
+ readonly oauth_flow_type?: string;
+ readonly credentials?: Record;
+ readonly issuer?: string;
+ readonly authorization_url?: string;
+ readonly token_url?: string;
+ readonly registration_url?: string;
+ readonly mcp_access_groups?: string[];
+ readonly static_headers?: readonly McpStaticHeaderRow[];
+ readonly command?: string;
+ readonly args?: string[];
+ readonly env?: Record;
+}
+
+export const allFieldsValue = (form: McpForm): McpFormSnapshot => form.getValues() as McpFormSnapshot;
+
+export const listControl = (control: Control): Control =>
+ control as unknown as Control;
+
+export const mountedPaths = (registry: MountRegistry): readonly string[] =>
+ registry.mountedNames().map((name) => (Array.isArray(name) ? name.join(".") : (name as string)));
+
+export const useMountedValues = (form: McpForm, registry: MountRegistry): MountedFormValues => {
+ useWatch({ control: form.control });
+ return projectMountedValues(registry, form.getValues);
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx
index 4e85aa41cfc..14e26fa5393 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx
@@ -781,7 +781,7 @@ describe("MCPServerEdit (interactive OAuth)", () => {
});
// Note: The M2M flow hiding logic is tested via OAuthFormFields.test.tsx (isM2M prop directly),
- // since Form.useWatch doesn't synchronously reflect initialValues in jsdom.
+ // since a mounted-values read doesn't synchronously reflect the seeded defaults in jsdom.
it("pre-populates token_validation_json from existing server token_validation", async () => {
const tokenValidation = { organization: "my-org", "team.id": "123" };
@@ -1003,7 +1003,7 @@ describe("MCPServerEdit (interactive OAuth)", () => {
fireEvent.click(saveButtons[0]);
});
- // The Form.Item inline validator intercepts invalid JSON before handleSave runs,
+ // The field's inline validator intercepts invalid JSON before handleSave runs,
// so the inline error message appears and updateMCPServer is never called.
await waitFor(() => {
expect(screen.getByText("Must be valid JSON")).toBeInTheDocument();
@@ -2190,7 +2190,7 @@ describe("MCPServerEdit (dcr_bridge toggle)", () => {
});
expect(getDcrToggle()).toHaveAttribute("aria-checked", "true");
- // The Form.Item stays mounted across the two client-forwarded modes, so the live toggle value is
+ // The field stays mounted across the two client-forwarded modes, so the live toggle value is
// preserved rather than forced false by the switch.
await selectAntOption("Authentication", "OAuth Delegate (client-supplied upstream token)");
await waitFor(() => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx
index c6ebde13bb8..3892b9d37b7 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react";
-import { Form, Select, Button as AntdButton, Tooltip, Input, InputNumber, Alert } from "antd";
+import { Select, Button as AntdButton, Tooltip, Input, InputNumber, Alert } from "antd";
+import { FormProvider, useForm } from "react-hook-form";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -44,6 +45,23 @@ import { validateMCPServerUrl, validateMCPServerName, normalizeToolOverrideMap }
import { EditServerFormValues, buildEditServerPayload, editPayloadErrorMessage } from "./editServerPayload";
import { toast } from "@/lib/toast";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
+import {
+ MountedFormField,
+ MountedFormProvider,
+ projectMountedValues,
+ useMountRegistry,
+ type MountedFormValues,
+} from "@/components/common_components/MountedFormField";
+import { antdRequired, antdRules } from "@/components/common_components/antdFormRules";
+import {
+ allFieldsValue,
+ mountedPaths,
+ resetFields,
+ setFieldsValue,
+ singleBranchChange,
+ useMountedValues,
+} from "./mcpFormStore";
+import { numberControl, notOnlyWhitespace, parsesAsJsonObject, selectControl, textControl } from "./mcpFieldRules";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
interface MCPServerEditProps {
@@ -66,174 +84,6 @@ const MCPServerEdit: React.FC = ({
onSuccess,
availableAccessGroups,
}) => {
- const [form] = Form.useForm();
- const [costConfig, setCostConfig] = useState({});
- const [tools, setTools] = useState([]);
- const [isLoadingTools, setIsLoadingTools] = useState(false);
- const [toolsError, setToolsError] = useState(null);
- const [searchValue, setSearchValue] = useState("");
- const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false);
- const [removeStoredApp, setRemoveStoredApp] = useState(false);
- // Set when the upstream identity (url/endpoints) changed while a declared app is present, so the
- // section warns that the saved app may not match the new upstream (the app is kept, not wiped).
- const [appMayNotMatchUpstream, setAppMayNotMatchUpstream] = useState(false);
- const [allowedTools, setAllowedTools] = useState([]);
- const [hasToolAllowlistInteraction, setHasToolAllowlistInteraction] = useState(false);
- const [toolNameToDisplayName, setToolNameToDisplayName] = useState>({});
- const [toolNameToDescription, setToolNameToDescription] = useState>({});
- const [pendingRestoredValues, setPendingRestoredValues] = useState | null>(null);
- const [logoUrl, setLogoUrl] = useState(mcpServer.mcp_info?.logo_url || undefined);
- const authType = Form.useWatch("auth_type", form) as string | undefined;
- const transportType = Form.useWatch("transport", form) as string | undefined;
- const isStdioTransport = transportType === "stdio";
- const isOpenAPITransport = transportType === TRANSPORT.OPENAPI;
- const isMCPTransport = !isStdioTransport && !isOpenAPITransport;
- const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
- const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
- const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE;
- const isIdJagAuthType = authType === AUTH_TYPE.OAUTH2_ID_JAG;
- const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
- const oauthFlowTypeValue = Form.useWatch("oauth_flow_type", form) as string | undefined;
- const isM2MFlow = isOAuthAuthType && oauthFlowTypeValue === OAUTH_FLOW.M2M;
- // Watch reflects a live toggle when the delegate switch is mounted; fall back to
- // the stored value otherwise (useWatch returns undefined for an unmounted field,
- // the same trap the oauth_flow_type field originally hit).
- const delegateAuthWatched = Form.useWatch("delegate_auth_to_upstream", form) as boolean | undefined;
- const isDelegateAuth = delegateAuthWatched ?? Boolean(mcpServer.delegate_auth_to_upstream);
-
- // Watch form fields that affect tool fetching
- const currentUrl = Form.useWatch("url", form);
- const currentSpecPath = Form.useWatch("spec_path", form);
- const currentServerName = Form.useWatch("server_name", form);
- const currentAuthType = Form.useWatch("auth_type", form);
- const currentStaticHeaders = Form.useWatch("static_headers", form);
- const currentCredentials = Form.useWatch("credentials", form);
- const currentIssuer = Form.useWatch("issuer", form);
- const currentAuthorizationUrl = Form.useWatch("authorization_url", form);
- const currentTokenUrl = Form.useWatch("token_url", form);
- const currentRegistrationUrl = Form.useWatch("registration_url", form);
- const hasExistingToolAllowlist =
- Boolean(mcpServer.mcp_info?.tool_allowlist_enforced) || (mcpServer.allowed_tools?.length ?? 0) > 0;
- const existingAllowedTools = hasExistingToolAllowlist ? mcpServer.allowed_tools ?? [] : null;
-
- const persistEditUiState = () => {
- if (typeof window === "undefined") {
- return;
- }
- try {
- const values = form.getFieldsValue(true);
- setSecureItem(
- EDIT_OAUTH_UI_STATE_KEY,
- JSON.stringify({
- serverId: mcpServer.server_id,
- formValues: values,
- costConfig,
- allowedTools,
- hasToolAllowlistInteraction,
- searchValue,
- aliasManuallyEdited,
- }),
- );
- } catch (err) {
- console.warn("Failed to persist MCP edit state", err);
- }
- };
-
- // The auth mode every decision must key off: the admin's in-flight form selection wins over the
- // saved record, so authorizing, loading tools, and saving all agree with what the form shows. Paths
- // that read only mcpServer.auth_type go stale the moment the admin switches modes in the form.
- const getEffectiveAuthType = () => form.getFieldValue("auth_type") ?? mcpServer.auth_type;
-
- // The OAuth authorization identity (see getOAuthAuthorizationIdentity) captured when a token is fetched
- // in this edit session; undefined when none is held. If a mint-relevant field later diverges from it,
- // the held token (hook response + sessionStorage) is discarded so the admin must re-authorize.
- const authorizedIdentityRef = React.useRef(undefined);
-
- const {
- startOAuthFlow,
- status: oauthStatus,
- error: oauthError,
- tokenResponse: oauthTokenResponse,
- reset: resetOAuthFlow,
- } = useMcpOAuthFlow({
- accessToken,
- getCredentials: () => form.getFieldValue("credentials"),
- getTemporaryPayload: () => {
- const values = form.getFieldsValue(true);
- const url = values.url || mcpServer.url;
- const transport = values.transport || mcpServer.transport;
- if (!url || !transport) {
- return null;
- }
- const staticHeaders = Array.isArray(values.static_headers)
- ? values.static_headers.reduce((acc: Record, entry: Record) => {
- const header = entry?.header?.trim();
- if (!header) {
- return acc;
- }
- acc[header] = (entry?.value ?? "").trim();
- return acc;
- }, {})
- : ({} as Record);
-
- return {
- server_id: mcpServer.server_id,
- server_name: values.server_name || mcpServer.server_name || mcpServer.alias,
- alias: values.alias || mcpServer.alias,
- description: values.description || mcpServer.description,
- url,
- transport,
- auth_type: isClientForwardedTokenMode(values.auth_type) ? values.auth_type : AUTH_TYPE.OAUTH2,
- credentials: isClientForwardedTokenMode(values.auth_type)
- ? preservedAdminCredentials(values.credentials)
- : values.credentials,
- mcp_access_groups: values.mcp_access_groups || mcpServer.mcp_access_groups,
- static_headers: staticHeaders,
- command: values.command,
- args: values.args,
- env: values.env,
- };
- },
- onTokenReceived: (token) => {
- if (!token?.access_token) {
- return;
- }
-
- authorizedIdentityRef.current = getOAuthAuthorizationIdentity(form.getFieldsValue(true));
- if (isClientForwardedTokenMode(getEffectiveAuthType())) {
- const browserHeldToken = {
- access_token: token.access_token,
- expires_in: token.expires_in,
- token_type: token.token_type,
- };
- setToken(mcpServer.server_id, browserHeldToken, userID);
- toast.success(
- "Token held for this browser session. Tools can now be loaded and configured; the token is not saved to LiteLLM.",
- );
- return;
- }
-
- const current = (form.getFieldValue("credentials") as Record | undefined) ?? {};
- const nextCredentials = {
- ...(preservedAdminCredentials(current) ?? {}),
- ...(current.scopes !== undefined && { scopes: current.scopes }),
- access_token: token.access_token,
- ...(token.refresh_token && { refresh_token: token.refresh_token }),
- ...(token.expires_in && { expires_in: token.expires_in }),
- ...(token.scope && { scope: token.scope }),
- };
- // Path-replace (not deep-merge) so a re-authorize with fewer token fields does not leave stale
- // siblings behind; the admin-typed client keys and scopes are carried explicitly above.
- form.setFieldValue("credentials", nextCredentials);
- // Re-capture after writing credentials so the token is not invalidated by its own credential write.
- authorizedIdentityRef.current = getOAuthAuthorizationIdentity(form.getFieldsValue(true));
-
- toast.success("OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.");
- },
- onBeforeRedirect: persistEditUiState,
- flowSource: "edit",
- });
-
const initialStaticHeaders = React.useMemo(() => {
if (!mcpServer.static_headers) {
return [];
@@ -292,6 +142,176 @@ const MCPServerEdit: React.FC = ({
[mcpServer, effectiveTransport, initialStaticHeaders, initialEnvVars, initialEnvJson],
);
+ const form = useForm({ mode: "onChange", defaultValues: initialValues });
+ const registry = useMountRegistry();
+ const mountedValues = useMountedValues(form, registry);
+ const [costConfig, setCostConfig] = useState({});
+ const [tools, setTools] = useState([]);
+ const [isLoadingTools, setIsLoadingTools] = useState(false);
+ const [toolsError, setToolsError] = useState(null);
+ const [searchValue, setSearchValue] = useState("");
+ const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false);
+ const [removeStoredApp, setRemoveStoredApp] = useState(false);
+ // Set when the upstream identity (url/endpoints) changed while a declared app is present, so the
+ // section warns that the saved app may not match the new upstream (the app is kept, not wiped).
+ const [appMayNotMatchUpstream, setAppMayNotMatchUpstream] = useState(false);
+ const [allowedTools, setAllowedTools] = useState([]);
+ const [hasToolAllowlistInteraction, setHasToolAllowlistInteraction] = useState(false);
+ const [toolNameToDisplayName, setToolNameToDisplayName] = useState>({});
+ const [toolNameToDescription, setToolNameToDescription] = useState>({});
+ const [pendingRestoredValues, setPendingRestoredValues] = useState | null>(null);
+ const [logoUrl, setLogoUrl] = useState(mcpServer.mcp_info?.logo_url || undefined);
+ const authType = mountedValues.auth_type as string | undefined;
+ const transportType = mountedValues.transport as string | undefined;
+ const isStdioTransport = transportType === "stdio";
+ const isOpenAPITransport = transportType === TRANSPORT.OPENAPI;
+ const isMCPTransport = !isStdioTransport && !isOpenAPITransport;
+ const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
+ const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
+ const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE;
+ const isIdJagAuthType = authType === AUTH_TYPE.OAUTH2_ID_JAG;
+ const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
+ const oauthFlowTypeValue = mountedValues.oauth_flow_type as string | undefined;
+ const isM2MFlow = isOAuthAuthType && oauthFlowTypeValue === OAUTH_FLOW.M2M;
+ // Watch reflects a live toggle when the delegate switch is mounted; fall back to
+ // the stored value otherwise (useWatch returns undefined for an unmounted field,
+ // the same trap the oauth_flow_type field originally hit).
+ const delegateAuthWatched = mountedValues.delegate_auth_to_upstream as boolean | undefined;
+ const isDelegateAuth = delegateAuthWatched ?? Boolean(mcpServer.delegate_auth_to_upstream);
+
+ // Watch form fields that affect tool fetching
+ const currentUrl = mountedValues.url;
+ const currentSpecPath = mountedValues.spec_path;
+ const currentServerName = mountedValues.server_name;
+ const currentAuthType = mountedValues.auth_type;
+ const currentStaticHeaders = mountedValues.static_headers;
+ const currentCredentials = mountedValues.credentials;
+ const currentIssuer = mountedValues.issuer;
+ const currentAuthorizationUrl = mountedValues.authorization_url;
+ const currentTokenUrl = mountedValues.token_url;
+ const currentRegistrationUrl = mountedValues.registration_url;
+ const hasExistingToolAllowlist =
+ Boolean(mcpServer.mcp_info?.tool_allowlist_enforced) || (mcpServer.allowed_tools?.length ?? 0) > 0;
+ const existingAllowedTools = hasExistingToolAllowlist ? mcpServer.allowed_tools ?? [] : null;
+
+ const persistEditUiState = () => {
+ if (typeof window === "undefined") {
+ return;
+ }
+ try {
+ const values = allFieldsValue(form);
+ setSecureItem(
+ EDIT_OAUTH_UI_STATE_KEY,
+ JSON.stringify({
+ serverId: mcpServer.server_id,
+ formValues: values,
+ costConfig,
+ allowedTools,
+ hasToolAllowlistInteraction,
+ searchValue,
+ aliasManuallyEdited,
+ }),
+ );
+ } catch (err) {
+ console.warn("Failed to persist MCP edit state", err);
+ }
+ };
+
+ // The auth mode every decision must key off: the admin's in-flight form selection wins over the
+ // saved record, so authorizing, loading tools, and saving all agree with what the form shows. Paths
+ // that read only mcpServer.auth_type go stale the moment the admin switches modes in the form.
+ const getEffectiveAuthType = () => allFieldsValue(form).auth_type ?? mcpServer.auth_type;
+
+ // The OAuth authorization identity (see getOAuthAuthorizationIdentity) captured when a token is fetched
+ // in this edit session; undefined when none is held. If a mint-relevant field later diverges from it,
+ // the held token (hook response + sessionStorage) is discarded so the admin must re-authorize.
+ const authorizedIdentityRef = React.useRef(undefined);
+
+ const {
+ startOAuthFlow,
+ status: oauthStatus,
+ error: oauthError,
+ tokenResponse: oauthTokenResponse,
+ reset: resetOAuthFlow,
+ } = useMcpOAuthFlow({
+ accessToken,
+ getCredentials: () => allFieldsValue(form).credentials,
+ getTemporaryPayload: () => {
+ const values = allFieldsValue(form);
+ const url = values.url || mcpServer.url;
+ const transport = values.transport || mcpServer.transport;
+ if (!url || !transport) {
+ return null;
+ }
+ const staticHeaders = Array.isArray(values.static_headers)
+ ? values.static_headers.reduce((acc: Record, entry: Record) => {
+ const header = entry?.header?.trim();
+ if (!header) {
+ return acc;
+ }
+ acc[header] = (entry?.value ?? "").trim();
+ return acc;
+ }, {})
+ : ({} as Record);
+
+ return {
+ server_id: mcpServer.server_id,
+ server_name: values.server_name || mcpServer.server_name || mcpServer.alias,
+ alias: values.alias || mcpServer.alias,
+ description: values.description || mcpServer.description,
+ url,
+ transport,
+ auth_type: isClientForwardedTokenMode(values.auth_type) ? values.auth_type : AUTH_TYPE.OAUTH2,
+ credentials: isClientForwardedTokenMode(values.auth_type)
+ ? preservedAdminCredentials(values.credentials)
+ : values.credentials,
+ mcp_access_groups: values.mcp_access_groups || mcpServer.mcp_access_groups,
+ static_headers: staticHeaders,
+ command: values.command,
+ args: values.args,
+ env: values.env,
+ };
+ },
+ onTokenReceived: (token) => {
+ if (!token?.access_token) {
+ return;
+ }
+
+ authorizedIdentityRef.current = getOAuthAuthorizationIdentity(allFieldsValue(form));
+ if (isClientForwardedTokenMode(getEffectiveAuthType())) {
+ const browserHeldToken = {
+ access_token: token.access_token,
+ expires_in: token.expires_in,
+ token_type: token.token_type,
+ };
+ setToken(mcpServer.server_id, browserHeldToken, userID);
+ toast.success(
+ "Token held for this browser session. Tools can now be loaded and configured; the token is not saved to LiteLLM.",
+ );
+ return;
+ }
+
+ const current = (allFieldsValue(form).credentials as Record | undefined) ?? {};
+ const nextCredentials = {
+ ...(preservedAdminCredentials(current) ?? {}),
+ ...(current.scopes !== undefined && { scopes: current.scopes }),
+ access_token: token.access_token,
+ ...(token.refresh_token && { refresh_token: token.refresh_token }),
+ ...(token.expires_in && { expires_in: token.expires_in }),
+ ...(token.scope && { scope: token.scope }),
+ };
+ // Path-replace (not deep-merge) so a re-authorize with fewer token fields does not leave stale
+ // siblings behind; the admin-typed client keys and scopes are carried explicitly above.
+ form.setValue("credentials", nextCredentials);
+ // Re-capture after writing credentials so the token is not invalidated by its own credential write.
+ authorizedIdentityRef.current = getOAuthAuthorizationIdentity(allFieldsValue(form));
+
+ toast.success("OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.");
+ },
+ onBeforeRedirect: persistEditUiState,
+ flowSource: "edit",
+ });
+
// antd applies `initialValues` only at first mount. When the server loads after
// mount (e.g. returning from the OAuth redirect lands on Overview and the form
// mounts before the server data is ready), the form would stay blank. Re-sync it
@@ -303,7 +323,7 @@ const MCPServerEdit: React.FC = ({
return;
}
syncedServerIdRef.current = mcpServer.server_id;
- form.setFieldsValue(initialValues);
+ setFieldsValue(form, initialValues);
// Reset per-server OAuth UI state so it never carries across a server switch without an unmount: a
// stale removeStoredApp would send an explicit-null credential write that deletes the new server's
// stored app, and a stale warning would show on a server whose upstream did not change.
@@ -394,11 +414,11 @@ const MCPServerEdit: React.FC = ({
// on the re-run triggered by the transportType watch (without it the effect's
// deps never change and the second pass never runs, leaving fields blank).
const transport = pendingRestoredValues.transport || mcpServer.transport;
- if (transport && transport !== form.getFieldValue("transport")) {
- form.setFieldsValue({ transport });
+ if (transport && transport !== allFieldsValue(form).transport) {
+ setFieldsValue(form, { transport });
return;
}
- form.setFieldsValue(pendingRestoredValues);
+ setFieldsValue(form, pendingRestoredValues);
setPendingRestoredValues(null);
}, [pendingRestoredValues, form, mcpServer.transport, transportType]);
@@ -407,7 +427,7 @@ const MCPServerEdit: React.FC = ({
if (mcpServer.mcp_access_groups) {
// If access groups are objects, extract the name property; if strings, use as is
const groupNames = mcpServer.mcp_access_groups.map((g: any) => (typeof g === "string" ? g : g.name || String(g)));
- form.setFieldValue("mcp_access_groups", groupNames);
+ form.setValue("mcp_access_groups", groupNames);
}
}, [mcpServer]);
@@ -439,16 +459,16 @@ const MCPServerEdit: React.FC = ({
resetOAuthFlow();
// The admin-typed app is upstream-scoped config, not minted material, so it survives every
// invalidation; only the held token is discarded. Token-shaped keys are excluded by the filter.
- const keptAdminCredentials = preservedAdminCredentials(form.getFieldValue("credentials"));
- form.resetFields([...CLEARED_ON_INVALIDATION]);
+ const keptAdminCredentials = preservedAdminCredentials(allFieldsValue(form).credentials);
+ resetFields(form, [...CLEARED_ON_INVALIDATION], initialValues as MountedFormValues);
if (keptAdminCredentials) {
- form.setFieldsValue({ credentials: keptAdminCredentials });
+ setFieldsValue(form, { credentials: keptAdminCredentials });
}
const preserved = Object.fromEntries(
CLEARED_ON_INVALIDATION.filter((key) => key in changedValues).map((key) => [key, changedValues[key]]),
);
if (Object.keys(preserved).length > 0) {
- form.setFieldsValue(preserved);
+ setFieldsValue(form, preserved);
}
};
@@ -463,12 +483,12 @@ const MCPServerEdit: React.FC = ({
const upstreamChanged = ["url", "spec_path", "issuer", "authorization_url", "token_url", "registration_url"].some(
(key) => key in changedValues,
);
- const hasDeclaredApp = preservedDeclaredAppCredentials(form.getFieldValue("credentials")) !== undefined;
+ const hasDeclaredApp = preservedDeclaredAppCredentials(allFieldsValue(form).credentials) !== undefined;
if (upstreamChanged && hasDeclaredApp) {
setAppMayNotMatchUpstream(true);
}
}
- if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentityRef.current)) {
+ if (isHeldOAuthTokenStale(allFieldsValue(form), authorizedIdentityRef.current)) {
clearHeldOAuthToken(changedValues);
}
};
@@ -492,7 +512,7 @@ const MCPServerEdit: React.FC = ({
setIsLoadingTools(true);
setToolsError(null);
try {
- const values = form.getFieldsValue(true);
+ const values = allFieldsValue(form);
const rawTransport = values.transport || mcpServer.transport;
// oauth2_flow must be explicit: the preview endpoint infers client_credentials from the
// inherited client_id/client_secret/token_url (common once DCR or discovery filled them) and
@@ -631,7 +651,7 @@ const MCPServerEdit: React.FC = ({
token_url: undefined,
registration_url: undefined,
};
- form.setFieldsValue(clearedForStdio);
+ setFieldsValue(form, clearedForStdio);
} else if (value === TRANSPORT.OPENAPI) {
const clearedForOpenapi = {
url: undefined,
@@ -640,9 +660,9 @@ const MCPServerEdit: React.FC = ({
env_json: undefined,
stdio_config: undefined,
};
- form.setFieldsValue(clearedForOpenapi);
+ setFieldsValue(form, clearedForOpenapi);
} else {
- form.setFieldsValue({
+ setFieldsValue(form, {
spec_path: undefined,
command: undefined,
args: undefined,
@@ -650,11 +670,32 @@ const MCPServerEdit: React.FC = ({
stdio_config: undefined,
});
}
- if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentityRef.current)) {
+ if (isHeldOAuthTokenStale(allFieldsValue(form), authorizedIdentityRef.current)) {
clearHeldOAuthToken();
}
};
+ const valuesChangeRef = React.useRef(handleFormValuesChange);
+ valuesChangeRef.current = handleFormValuesChange;
+
+ React.useEffect(() => {
+ const subscription = form.watch((values, { name, type }) => {
+ if (type !== "change" || name === undefined) {
+ return;
+ }
+ valuesChangeRef.current(singleBranchChange(name, values as MountedFormValues));
+ });
+ return () => subscription.unsubscribe();
+ }, [form]);
+
+ const submitForm = async () => {
+ const isValid = await form.trigger(mountedPaths(registry) as string[]);
+ if (!isValid) {
+ return;
+ }
+ await handleSave(projectMountedValues(registry, form.getValues) as unknown as EditServerFormValues);
+ };
+
const handleSave = async (values: EditServerFormValues) => {
if (!accessToken) return;
try {
@@ -732,452 +773,510 @@ const MCPServerEdit: React.FC = ({
-
- validateMCPServerName(value),
- },
- ]}
- >
-
-
- validateMCPServerName(value),
- },
- ]}
- >
- setAliasManuallyEdited(true)}
- className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
- />
-
-
-
-
-
-
-
- Streamable HTTP (Recommended)
- Server-Sent Events (SSE)
- Standard Input/Output (stdio)
- OpenAPI Spec
-
-
-
- {/* URL field - only for HTTP/SSE */}
- {isMCPTransport && (
- validateMCPServerUrl(value) },
- ]}
- >
-
-
- )}
-
- {/* OpenAPI Spec URL - only for OpenAPI transport */}
- {isOpenAPITransport && (
-
- OpenAPI Spec URL
-
-
-
-
- }
- name="spec_path"
- rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]}
- >
-
-
- )}
-
-
- Max Concurrent Requests (optional)
-
-
-
-
- }
- name="max_concurrent_requests"
- >
-
-
-
- {/* Authentication - for HTTP, SSE, and OpenAPI */}
- {!isStdioTransport && (
- <>
-
-
- None
- API Key
- Bearer Token
- Token
- Basic Auth
- OAuth
- OAuth Token Exchange (OBO)
- ID-JAG (Okta Cross App Access)
- AWS SigV4 (Bedrock AgentCore MCPs)
- True Passthrough (no LiteLLM auth)
-
- OAuth Delegate (client-supplied upstream token)
-
-
-
-
-
- >
- )}
-
- {isStdioTransport && (
-
-
- Configure the stdio transport used to launch the MCP server process. You can either fill in the fields
- below or paste a JSON configuration.
-
-
-
-
-
-
-
-
-
-
-
{
- if (!value) return Promise.resolve();
- try {
- const parsed = JSON.parse(value);
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
- return Promise.resolve();
- }
- return Promise.reject(new Error("Env must be a JSON object"));
- } catch {
- return Promise.reject(new Error("Please enter valid JSON"));
- }
- },
- },
- ]}
- >
-
-
-
- {/* Optional JSON config (if provided, it overrides command/args/env on save) */}
-
-
- )}
-
- {!isStdioTransport && shouldShowAuthValueField && (
-
- Authentication Value
-
-
-
-
- }
- name={["credentials", "auth_value"]}
- rules={[
- {
- validator: (_, value) =>
- value && typeof value === "string" && value.trim() === ""
- ? Promise.reject(new Error("Authentication value cannot be empty"))
- : Promise.resolve(),
- },
- ]}
- >
-
-
- )}
-
- {!isStdioTransport && isOAuthAuthType && (
- <>
- {!oauthFlowTypeValue && !isDelegateAuth && (
-
- )}
-
- >
- )}
-
- {!isStdioTransport && isTokenExchangeAuthType && }
-
- {!isStdioTransport && isIdJagAuthType && }
-
- {!isStdioTransport && isAwsSigV4AuthType && (
- <>
-
- For MCP servers hosted on AWS Bedrock AgentCore.{" "}
-
- View docs →
-
-
-
- AWS Region
-
-
-
-
- }
- name={["credentials", "aws_region_name"]}
- rules={[]}
- >
-
-
-
- AWS Service Name
-
-
-
-
- }
- name={["credentials", "aws_service_name"]}
- >
-
-
-
- AWS Access Key ID
-
-
-
-
- }
- name={["credentials", "aws_access_key_id"]}
- rules={[]}
- >
-
-
-
- AWS Secret Access Key
-
-
-
-
- }
- name={["credentials", "aws_secret_access_key"]}
- rules={[]}
- >
-
-
-
- AWS Session Token
-
-
-
-
- }
- name={["credentials", "aws_session_token"]}
- >
-
-
-
- AWS Role ARN
-
-
-
-
- }
- name={["credentials", "aws_role_name"]}
- >
-
-
-
- AWS Session Name
-
-
-
-
- }
- name={["credentials", "aws_session_name"]}
- >
-
-
- >
- )}
-
- {/* Environment Variables Section */}
-
-
-
-
- {/* Permission Management / Access Control Section */}
-
-
-
-
- {/* Tool Configuration Section */}
-
-
+
+ {
+ event.preventDefault();
+ void submitForm();
}}
- allowedTools={allowedTools}
- existingAllowedTools={existingAllowedTools}
- hasToolAllowlistInteraction={hasToolAllowlistInteraction}
- isEditMode
- onAllowedToolsChange={setAllowedTools}
- onToolAllowlistInteraction={() => setHasToolAllowlistInteraction(true)}
- toolNameToDisplayName={toolNameToDisplayName}
- toolNameToDescription={toolNameToDescription}
- onToolNameToDisplayNameChange={setToolNameToDisplayName}
- onToolNameToDescriptionChange={setToolNameToDescription}
- externalTools={tools}
- externalIsLoading={isLoadingTools}
- externalError={toolsError}
- externalCanFetch={true}
- />
-
+ >
+ validateMCPServerName(value) }) }}
+ >
+ {(control) => (
+
+ )}
+
+ validateMCPServerName(value) }) }}
+ >
+ {(control) => (
+ {
+ control.onChange(event);
+ setAliasManuallyEdited(true);
+ }}
+ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
+ />
+ )}
+
+
+ {(control) => (
+
+ )}
+
+
+
+ {(control) => (
+ (control)}
+ onChange={(value: string) => {
+ control.onChange(value);
+ handleTransportChange(value);
+ }}
+ >
+ Streamable HTTP (Recommended)
+ Server-Sent Events (SSE)
+ Standard Input/Output (stdio)
+ OpenAPI Spec
+
+ )}
+
-
-
Cancel
-
Save Changes
-
-
+ {/* URL field - only for HTTP/SSE */}
+ {isMCPTransport && (
+ validateMCPServerUrl(value) }),
+ },
+ }}
+ >
+ {(control) => (
+
+ )}
+
+ )}
+
+ {/* OpenAPI Spec URL - only for OpenAPI transport */}
+ {isOpenAPITransport && (
+
+ OpenAPI Spec URL
+
+
+
+
+ }
+ name="spec_path"
+ required
+ rules={{ validate: { required: antdRequired("Please enter an OpenAPI spec URL") } }}
+ >
+ {(control) => (
+
+ )}
+
+ )}
+
+
+ Max Concurrent Requests (optional)
+
+
+
+
+ }
+ name="max_concurrent_requests"
+ >
+ {(control) => (
+
+ )}
+
+
+ {/* Authentication - for HTTP, SSE, and OpenAPI */}
+ {!isStdioTransport && (
+ <>
+
+ {(control) => (
+ (control)} virtual={false}>
+ None
+ API Key
+ Bearer Token
+ Token
+ Basic Auth
+ OAuth
+ OAuth Token Exchange (OBO)
+ ID-JAG (Okta Cross App Access)
+ AWS SigV4 (Bedrock AgentCore MCPs)
+ True Passthrough (no LiteLLM auth)
+
+ OAuth Delegate (client-supplied upstream token)
+
+
+ )}
+
+
+
+ >
+ )}
+
+ {isStdioTransport && (
+
+
+ Configure the stdio transport used to launch the MCP server process. You can either fill in the
+ fields below or paste a JSON configuration.
+
+
+
+ {(control) => (
+
+ )}
+
+
+
+ {(control) => (
+ (control)}
+ mode="tags"
+ size="large"
+ tokenSeparators={[","]}
+ placeholder="Add args (press enter or comma)"
+ className="rounded-lg"
+ />
+ )}
+
+
+
+ {(control) => (
+
+ )}
+
+
+ {/* Optional JSON config (if provided, it overrides command/args/env on save) */}
+
+
+ )}
+
+ {!isStdioTransport && shouldShowAuthValueField && (
+
+ Authentication Value
+
+
+
+
+ }
+ name={["credentials", "auth_value"]}
+ rules={{ validate: { notWhitespace: notOnlyWhitespace("Authentication value cannot be empty") } }}
+ >
+ {(control) => (
+
+ )}
+
+ )}
+
+ {!isStdioTransport && isOAuthAuthType && (
+ <>
+ {!oauthFlowTypeValue && !isDelegateAuth && (
+
+ )}
+
+ >
+ )}
+
+ {!isStdioTransport && isTokenExchangeAuthType && }
+
+ {!isStdioTransport && isIdJagAuthType && }
+
+ {!isStdioTransport && isAwsSigV4AuthType && (
+ <>
+
+ For MCP servers hosted on AWS Bedrock AgentCore.{" "}
+
+ View docs →
+
+
+
+ AWS Region
+
+
+
+
+ }
+ name={["credentials", "aws_region_name"]}
+ >
+ {(control) => (
+
+ )}
+
+
+ AWS Service Name
+
+
+
+
+ }
+ name={["credentials", "aws_service_name"]}
+ >
+ {(control) => (
+
+ )}
+
+
+ AWS Access Key ID
+
+
+
+
+ }
+ name={["credentials", "aws_access_key_id"]}
+ >
+ {(control) => (
+
+ )}
+
+
+ AWS Secret Access Key
+
+
+
+
+ }
+ name={["credentials", "aws_secret_access_key"]}
+ >
+ {(control) => (
+
+ )}
+
+
+ AWS Session Token
+
+
+
+
+ }
+ name={["credentials", "aws_session_token"]}
+ >
+ {(control) => (
+
+ )}
+
+
+ AWS Role ARN
+
+
+
+
+ }
+ name={["credentials", "aws_role_name"]}
+ >
+ {(control) => (
+
+ )}
+
+
+ AWS Session Name
+
+
+
+
+ }
+ name={["credentials", "aws_session_name"]}
+ >
+ {(control) => (
+
+ )}
+
+ >
+ )}
+
+ {/* Environment Variables Section */}
+
+
+
+
+ {/* Permission Management / Access Control Section */}
+
+
+
+
+ {/* Tool Configuration Section */}
+
+ setHasToolAllowlistInteraction(true)}
+ toolNameToDisplayName={toolNameToDisplayName}
+ toolNameToDescription={toolNameToDescription}
+ onToolNameToDisplayNameChange={setToolNameToDisplayName}
+ onToolNameToDescriptionChange={setToolNameToDescription}
+ externalTools={tools}
+ externalIsLoading={isLoadingTools}
+ externalError={toolsError}
+ externalCanFetch={true}
+ />
+
+
+
+
Cancel
+
Save Changes
+
+
+
+
@@ -1186,7 +1285,7 @@ const MCPServerEdit: React.FC = ({
Cancel
-
form.submit()}>Save Changes
+
void submitForm()}>Save Changes
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts
index 5f06d03d595..dd9c8db6d30 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts
@@ -482,7 +482,7 @@ describe("projection shape", () => {
expect(projected.command).toBe("npx");
});
- it("passes Form.List rows through whole, since antd does not project a row to its mounted sub-fields", () => {
+ it("passes list rows through whole, since a list field is projected as one key and not per mounted sub-field", () => {
const row = { name: "N", value: "V", scope: "user", description: "D" };
const projected = projectMountedEditValues({ ...HTTP_NONE, env_vars: [row] });
expect(projected.env_vars).toStrictEqual([row]);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts
index 7911c3eb800..c1692f436d7 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts
@@ -4,6 +4,7 @@ import { expect } from "vitest";
export async function selectAntOption(labelText: string, optionText: string) {
const label = screen.getByText(labelText);
const select =
+ label.closest("[data-slot='field']")?.querySelector(".ant-select") ??
label.closest(".ant-form-item")?.querySelector(".ant-select") ??
label.closest(".ant-collapse-item")?.querySelector(".ant-select") ??
label.closest("div")?.querySelector(".ant-select") ??
diff --git a/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.ts b/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.ts
deleted file mode 100644
index b619ec5748a..00000000000
--- a/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { UseFormGetValues } from "react-hook-form";
-
-import {
- projectMountedValues,
- type MountedFieldName,
- type MountedFormValues,
- type MountRegistry,
-} from "./MountedFormField";
-
-const registryOf = (names: readonly MountedFieldName[]): MountRegistry => ({
- register: () => () => undefined,
- mountedNames: () => names,
-});
-
-const getValuesOf = (store: Readonly>): UseFormGetValues =>
- ((names: readonly string[]) => names.map((name) => store[name])) as unknown as UseFormGetValues;
-
-const project = (store: Readonly>) =>
- projectMountedValues(registryOf(Object.keys(store)), getValuesOf(store));
-
-const projectPaths = (entries: readonly (readonly [MountedFieldName, unknown])[]) => {
- const store = Object.fromEntries(
- entries.map(([name, value]) => [Array.isArray(name) ? name.join(".") : (name as string), value]),
- );
- return projectMountedValues(registryOf(entries.map(([name]) => name)), getValuesOf(store));
-};
-
-describe("projectMountedValues", () => {
- it("keeps a flat name flat", () => {
- expect(project({ server_name: "s1", transport: "http" })).toStrictEqual({ server_name: "s1", transport: "http" });
- });
-
- it("nests an ARRAY name into a credentials object", () => {
- expect(
- projectPaths([
- [["credentials", "aws_region_name"], "us-east-1"],
- [["credentials", "aws_access_key_id"], "AKIA"],
- ]),
- ).toStrictEqual({ credentials: { aws_region_name: "us-east-1", aws_access_key_id: "AKIA" } });
- });
-
- it("keeps a literal dotted STRING name flat, matching antd getNamePath toArray", () => {
- expect(projectPaths([["a.b", 1]])).toStrictEqual({ "a.b": 1 });
- expect(projectPaths([["schema.property.with.dots", "v"]])).toStrictEqual({ "schema.property.with.dots": "v" });
- });
-
- it("rebuilds Form.List rows as an array, not an object keyed by digits", () => {
- const projected = projectPaths([
- [["env_vars", "0", "name"], "API_KEY"],
- [["env_vars", "0", "description"], "the key"],
- [["env_vars", "1", "name"], "REGION"],
- ]);
- expect(projected).toStrictEqual({
- env_vars: [{ name: "API_KEY", description: "the key" }, { name: "REGION" }],
- });
- expect(Array.isArray(projected.env_vars)).toBe(true);
- });
-
- it("rebuilds static_headers rows, the second Form.List site", () => {
- expect(
- projectPaths([
- [["static_headers", "0", "key"], "X-Tenant"],
- [["static_headers", "0", "value"], "acme"],
- ]),
- ).toStrictEqual({
- static_headers: [{ key: "X-Tenant", value: "acme" }],
- });
- });
-
- it("emits a mounted-but-unset field as a key holding undefined, matching antd onFinish", () => {
- const projected = project({ alias: undefined });
- expect(Object.keys(projected)).toStrictEqual(["alias"]);
- expect(projected.alias).toBeUndefined();
- });
-
- it("leaves a sparse row index as a hole rather than shifting later rows down", () => {
- const projected = projectPaths([[["env_vars", "2", "name"], "THIRD"]]) as { env_vars: readonly unknown[] };
- expect(projected.env_vars).toHaveLength(3);
- expect(projected.env_vars[2]).toStrictEqual({ name: "THIRD" });
- });
-
- it("mixes flat, nested and list names in one projection", () => {
- expect(
- projectPaths([
- ["transport", "http"],
- [["credentials", "client_id"], "cid"],
- [["env_vars", "0", "name"], "K"],
- ]),
- ).toStrictEqual({
- transport: "http",
- credentials: { client_id: "cid" },
- env_vars: [{ name: "K" }],
- });
- });
-});
diff --git a/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.tsx b/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.tsx
new file mode 100644
index 00000000000..62cdd935131
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.tsx
@@ -0,0 +1,187 @@
+import React from "react";
+import { render, renderHook, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { useForm } from "react-hook-form";
+import type { UseFormGetValues } from "react-hook-form";
+
+import {
+ MountedFormField,
+ MountedFormProvider,
+ projectMountedValues,
+ useMountRegistry,
+ type MountedFieldName,
+ type MountedFormValues,
+ type MountRegistry,
+} from "./MountedFormField";
+
+const registryOf = (names: readonly MountedFieldName[]): MountRegistry => ({
+ register: () => () => undefined,
+ mountedNames: () => names,
+});
+
+const getValuesOf = (store: Readonly>): UseFormGetValues =>
+ ((names: readonly string[]) => names.map((name) => store[name])) as unknown as UseFormGetValues;
+
+const project = (store: Readonly>) =>
+ projectMountedValues(registryOf(Object.keys(store)), getValuesOf(store));
+
+const projectPaths = (entries: readonly (readonly [MountedFieldName, unknown])[]) => {
+ const store = Object.fromEntries(
+ entries.map(([name, value]) => [Array.isArray(name) ? name.join(".") : (name as string), value]),
+ );
+ return projectMountedValues(registryOf(entries.map(([name]) => name)), getValuesOf(store));
+};
+
+describe("projectMountedValues", () => {
+ it("keeps a flat name flat", () => {
+ expect(project({ server_name: "s1", transport: "http" })).toStrictEqual({ server_name: "s1", transport: "http" });
+ });
+
+ it("nests an ARRAY name into a credentials object", () => {
+ expect(
+ projectPaths([
+ [["credentials", "aws_region_name"], "us-east-1"],
+ [["credentials", "aws_access_key_id"], "AKIA"],
+ ]),
+ ).toStrictEqual({ credentials: { aws_region_name: "us-east-1", aws_access_key_id: "AKIA" } });
+ });
+
+ it("keeps a literal dotted STRING name flat, matching antd getNamePath toArray", () => {
+ expect(projectPaths([["a.b", 1]])).toStrictEqual({ "a.b": 1 });
+ expect(projectPaths([["schema.property.with.dots", "v"]])).toStrictEqual({ "schema.property.with.dots": "v" });
+ });
+
+ it("rebuilds Form.List rows as an array, not an object keyed by digits", () => {
+ const projected = projectPaths([
+ [["env_vars", "0", "name"], "API_KEY"],
+ [["env_vars", "0", "description"], "the key"],
+ [["env_vars", "1", "name"], "REGION"],
+ ]);
+ expect(projected).toStrictEqual({
+ env_vars: [{ name: "API_KEY", description: "the key" }, { name: "REGION" }],
+ });
+ expect(Array.isArray(projected.env_vars)).toBe(true);
+ });
+
+ it("rebuilds static_headers rows, the second Form.List site", () => {
+ expect(
+ projectPaths([
+ [["static_headers", "0", "key"], "X-Tenant"],
+ [["static_headers", "0", "value"], "acme"],
+ ]),
+ ).toStrictEqual({
+ static_headers: [{ key: "X-Tenant", value: "acme" }],
+ });
+ });
+
+ it("emits a mounted-but-unset field as a key holding undefined, matching antd onFinish", () => {
+ const projected = project({ alias: undefined });
+ expect(Object.keys(projected)).toStrictEqual(["alias"]);
+ expect(projected.alias).toBeUndefined();
+ });
+
+ it("leaves a sparse row index as a hole rather than shifting later rows down", () => {
+ const projected = projectPaths([[["env_vars", "2", "name"], "THIRD"]]) as { env_vars: readonly unknown[] };
+ expect(projected.env_vars).toHaveLength(3);
+ expect(projected.env_vars[2]).toStrictEqual({ name: "THIRD" });
+ });
+
+ it("mixes flat, nested and list names in one projection", () => {
+ expect(
+ projectPaths([
+ ["transport", "http"],
+ [["credentials", "client_id"], "cid"],
+ [["env_vars", "0", "name"], "K"],
+ ]),
+ ).toStrictEqual({
+ transport: "http",
+ credentials: { client_id: "cid" },
+ env_vars: [{ name: "K" }],
+ });
+ });
+});
+
+describe("useMountRegistry lifecycle", () => {
+ const GatedForm: React.FC<{
+ showOptional: boolean;
+ showRequired: boolean;
+ onFinish: (v: MountedFormValues) => void;
+ }> = ({ showOptional, showRequired, onFinish }) => {
+ const form = useForm({ mode: "onChange", defaultValues: { server_name: "keep" } });
+ const registry = useMountRegistry();
+ return (
+
+ {
+ event.preventDefault();
+ void form
+ .trigger(registry.mountedNames().map((n) => (Array.isArray(n) ? n.join(".") : (n as string))))
+ .then((valid) => {
+ if (valid) onFinish(projectMountedValues(registry, form.getValues));
+ });
+ }}
+ >
+
+ {(control) => (
+
+ )}
+
+ {showOptional && (
+
+ {(control) => (
+
+ )}
+
+ )}
+ {showRequired && (
+
+ {(control) => (
+
+ )}
+
+ )}
+ Submit
+
+
+ );
+ };
+
+ it("drops a field's key from the submitted payload once its gate unmounts it", async () => {
+ const onFinish = vi.fn();
+ const { rerender } = render( );
+
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
+ await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
+ expect(Object.keys(onFinish.mock.calls[0][0] as object)).toContain("alias");
+
+ rerender( );
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
+ await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(2));
+ expect(Object.keys(onFinish.mock.calls[1][0] as object)).not.toContain("alias");
+ });
+
+ it("submits after a required field is unmounted, rather than validating a field the user can no longer see", async () => {
+ const onFinish = vi.fn();
+ const { rerender } = render( );
+
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
+ expect(await screen.findByText("Token URL is required")).toBeInTheDocument();
+ expect(onFinish).not.toHaveBeenCalled();
+
+ rerender( );
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
+ await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
+ expect(Object.keys(onFinish.mock.calls[0][0] as object)).not.toContain("token_url");
+ });
+
+ it("keeps a name mounted while a second field still holds a registration on it", () => {
+ const registry = renderHook(() => useMountRegistry()).result.current;
+ const releaseFirst = registry.register("credentials.scopes");
+ registry.register("credentials.scopes");
+
+ releaseFirst();
+
+ expect(registry.mountedNames()).toStrictEqual(["credentials.scopes"]);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx
index 0ae58e102e7..f0efa885d92 100644
--- a/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx
+++ b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx
@@ -94,6 +94,11 @@ export const projectMountedValues = (
);
};
+export const useMountedName = (name: MountedFieldName): void => {
+ const { registry } = React.useContext(MountedFormContext);
+ React.useEffect(() => registry.register(name), [registry, name]);
+};
+
export type MountedFieldControlProps = {
readonly id: string;
readonly name: string;
@@ -131,9 +136,9 @@ export const MountedFormField: React.FC = ({
className,
children,
}) => {
- const { control, registry } = React.useContext(MountedFormContext);
+ const { control } = React.useContext(MountedFormContext);
const path = fieldKey(name);
- React.useEffect(() => registry.register(name), [registry, name]);
+ useMountedName(name);
const helpId = `${path}_help`;
const hasHelp = help !== undefined && help !== null;