From ad3f324f3dc338862fb38995892eb72826324faf Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 18 Aug 2026 22:32:38 -0700 Subject: [PATCH] refactor(ui): port MCP server forms from antd Form to react-hook-form The MCP server create and edit screens carry the dashboard's largest antd `Form` graph: 92 `Form.Item`s across 14 files, against 39 in the next largest. This replaces `Form` and `Form.Item` with react-hook-form plus the shared shadcn field wrapper, and leaves every antd widget in place, so the rendered form is unchanged. antd submits only the fields that are currently mounted, which the conditional transport and auth sections rely on. react-hook-form keeps unmounted values instead, so the port adds an explicit mount registry and projects the store through it at submit time. `MountedFormField` also reproduces the antd behaviours the graph depended on: an unmounted watch reading as undefined, `setFieldsValue` deep-merging objects while replacing arrays, and `resetFields` restoring values for names that have no registered field. Field-level `initialValue` props do not survive the port, so the four defaults they carried now live in `defaultValues`. The edit form builds `defaultValues` from named keys rather than spreading the server record, which keeps read-only columns such as `created_at` and `approval_status` out of the store as well as out of the payload. The registry itself is shared with the create key port, so it keeps that lane's reference counting: a name stays mounted while any field still binds it, which matters here because 23 of the 58 names are bound in more than one place. `projectMountedValues` accepts either a store or a `getValues` function so both call styles keep working. Removes an auth hint in OpenApiByokFields that sat inside a block already guarded by the same condition and could never render. --- ui/litellm-dashboard/eslint-suppressions.json | 28 +- .../_components/AwsSigV4Fields.tsx | 299 ++-- .../_components/CreateMCPServer.tsx | 741 +++++---- .../_components/DcrBridgeToggle.tsx | 18 +- .../_components/EnvVarsSection.tsx | 165 +- .../_components/IdJagFormFields.tsx | 184 ++- .../MCPPermissionManagement.test.tsx | 66 +- .../_components/MCPPermissionManagement.tsx | 248 +-- .../_components/OAuthFormFields.test.tsx | 24 +- .../_components/OAuthFormFields.tsx | 275 ++-- .../_components/OpenAPIFormSection.tsx | 56 +- .../_components/OpenApiByokFields.tsx | 163 +- .../PassthroughAuthorizeSection.test.tsx | 16 +- .../PassthroughAuthorizeSection.tsx | 43 +- .../_components/StdioConfiguration.tsx | 44 +- .../TokenEndpointAuthMethodField.tsx | 30 +- .../_components/TokenExchangeFormFields.tsx | 206 +-- .../_components/mcp_server_edit.tsx | 1377 +++++++++-------- .../mcp-servers/_components/testUtils.ts | 1 + .../mcp-servers/_components/utils.tsx | 12 + .../MountedFormField.test.tsx | 232 +++ .../common_components/MountedFormField.tsx | 275 ++++ 22 files changed, 2731 insertions(+), 1772 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/common_components/MountedFormField.test.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 3f32048cac1..23491515968 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -558,9 +558,6 @@ }, "no-restricted-imports": { "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 4 } }, "src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx": { @@ -583,11 +580,6 @@ "count": 2 } }, - "src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx": { "no-restricted-imports": { "count": 1 @@ -609,11 +601,6 @@ "count": 1 } }, - "src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": { "no-nested-ternary": { "count": 1 @@ -627,7 +614,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 2 + "count": 1 } }, "src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": { @@ -640,11 +627,6 @@ "count": 1 } }, - "src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx": { "no-restricted-imports": { "count": 1 @@ -732,12 +714,6 @@ }, "no-restricted-imports": { "count": 1 - }, - "react-hooks/immutability": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 5 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": { @@ -3014,4 +2990,4 @@ "count": 1 } } -} +} \ No newline at end of file diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/AwsSigV4Fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/AwsSigV4Fields.tsx index d4ae537bffa..a4b2408ac9e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/AwsSigV4Fields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/AwsSigV4Fields.tsx @@ -1,155 +1,156 @@ import React from "react"; -import { Form, Input, Tooltip } from "antd"; +import { Input, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; +import { useFormContext } from "react-hook-form"; +import { MountedFormField, bindControl, type MountedFormValues } from "@/components/common_components/MountedFormField"; -const AwsSigV4Fields: React.FC = () => ( - <> -

- For MCP servers hosted on AWS Bedrock AgentCore.{" "} - - View docs → - -

- - AWS Region - - - - - } - name={["credentials", "aws_region_name"]} - rules={[{ required: true, message: "AWS region is required for SigV4 auth" }]} - > - - - - AWS Service Name - - - - - } - name={["credentials", "aws_service_name"]} - > - - - - AWS Access Key ID - - - - - } - name={["credentials", "aws_access_key_id"]} - dependencies={[["credentials", "aws_secret_access_key"]]} - rules={[ - ({ getFieldValue }) => ({ - validator(_, value) { - const secretKey = getFieldValue(["credentials", "aws_secret_access_key"]); - if (secretKey && !value) { - return Promise.reject(new Error("Access Key ID is required when Secret Access Key is provided")); - } - return Promise.resolve(); - }, - }), - ]} - > - - - - AWS Secret Access Key - - - - - } - name={["credentials", "aws_secret_access_key"]} - dependencies={[["credentials", "aws_access_key_id"]]} - rules={[ - ({ getFieldValue }) => ({ - validator(_, value) { - const accessKeyId = getFieldValue(["credentials", "aws_access_key_id"]); - if (accessKeyId && !value) { - return Promise.reject(new Error("Secret Access Key is required when Access Key ID is provided")); - } - return Promise.resolve(); - }, - }), - ]} - > - - - - AWS Session Token - - - - - } - name={["credentials", "aws_session_token"]} - > - - - - AWS Role ARN - - - - - } - name={["credentials", "aws_role_name"]} - > - - - - AWS Session Name - - - - - } - name={["credentials", "aws_session_name"]} - > - - - +const fieldClassName = "rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"; + +const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, tooltip }) => ( + + {label} + + + + ); +const AwsSigV4Fields: React.FC = () => { + const { getValues } = useFormContext(); + + return ( + <> +

+ For MCP servers hosted on AWS Bedrock AgentCore.{" "} + + View docs → + +

+ } + name="credentials.aws_region_name" + required + rules={{ required: "AWS region is required for SigV4 auth" }} + > + {(field) => ( + (field)} placeholder="us-east-1" className={fieldClassName} /> + )} + + + } + name="credentials.aws_service_name" + > + {(field) => ( + (field)} + placeholder="bedrock-agentcore" + className={fieldClassName} + /> + )} + + + } + name="credentials.aws_access_key_id" + rules={{ + deps: ["credentials.aws_secret_access_key"], + validate: (value) => + getValues("credentials.aws_secret_access_key") && !value + ? "Access Key ID is required when Secret Access Key is provided" + : true, + }} + > + {(field) => ( + (field)} + placeholder="AKIA... (optional — uses IAM role if blank)" + className={fieldClassName} + /> + )} + + + } + name="credentials.aws_secret_access_key" + rules={{ + deps: ["credentials.aws_access_key_id"], + validate: (value) => + getValues("credentials.aws_access_key_id") && !value + ? "Secret Access Key is required when Access Key ID is provided" + : true, + }} + > + {(field) => ( + (field)} + placeholder="Enter secret key (optional — uses IAM role if blank)" + className={fieldClassName} + /> + )} + + } + name="credentials.aws_session_token" + > + {(field) => ( + (field)} + placeholder="Enter session token (optional)" + className={fieldClassName} + /> + )} + + + } + name="credentials.aws_role_name" + > + {(field) => ( + (field)} + placeholder="arn:aws:iam::123456789012:role/MyRole (optional)" + className={fieldClassName} + /> + )} + + + } + name="credentials.aws_session_name" + > + {(field) => ( + (field)} + placeholder="litellm-prod (optional, auto-generated if blank)" + className={fieldClassName} + /> + )} + + + ); +}; + export default AwsSigV4Fields; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx index ed8d3bddc98..ac7960c1884 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx @@ -1,6 +1,18 @@ import React, { useState } from "react"; -import { Modal, Tooltip, Form, Select, Input as AntdInput, InputNumber, Collapse } from "antd"; +import { Modal, Tooltip, Select, Input as AntdInput, InputNumber, Collapse } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; +import { FormProvider, useForm } from "react-hook-form"; +import { + MountedFormField, + MountedFormProvider, + applyFieldValues, + bindControl, + changedValuesFor, + projectMountedValues, + resetFieldsToDefaults, + useMountRegistry, + type MountedFormValues, +} from "@/components/common_components/MountedFormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; @@ -45,7 +57,7 @@ import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection"; import MCPLogoSelector from "./MCPLogoSelector"; import EnvVarsSection from "./EnvVarsSection"; import { isAdminRole } from "@/utils/roles"; -import { validateMCPServerUrl, validateMCPServerName } from "./utils"; +import { antdValidator, validateMCPServerUrl, validateMCPServerName } from "./utils"; import { toast } from "@/lib/toast"; import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow"; import { useTestMCPConnection } from "@/hooks/useTestMCPConnection"; @@ -76,6 +88,16 @@ const payloadErrorMessage = (result: Exclude = ({ userID, userRole, @@ -87,7 +109,8 @@ const CreateMCPServer: React.FC = ({ prefillData, onBackToDiscovery, }) => { - const [form] = Form.useForm(); + const form = useForm({ defaultValues: CREATE_DEFAULTS }); + const registry = useMountRegistry(); const [isLoading, setIsLoading] = useState(false); const [costConfig, setCostConfig] = useState({}); const [formValues, setFormValues] = useState>({}); @@ -147,7 +170,7 @@ const CreateMCPServer: React.FC = ({ const persistCreateUiState = () => { writeCreateUiSnapshot({ modalVisible: isModalVisible, - formValues: form.getFieldsValue(true), + formValues: form.getValues() as Record, transportType, costConfig, allowedTools, @@ -170,11 +193,11 @@ const CreateMCPServer: React.FC = ({ // Merge the ref-held DCR client so a re-authorize reuses the registered client instead of // re-registering; the form store itself never holds the DCR client (see onTokenReceived). getCredentials: () => ({ - ...((form.getFieldValue("credentials") as Record | undefined) ?? {}), + ...((form.getValues("credentials") as Record | undefined) ?? {}), ...(dcrClientRef.current ?? {}), }), getTemporaryPayload: () => { - const values = form.getFieldsValue(true); + const values: Record = form.getValues(); const transport = values.transport || transportType; // For OpenAPI transport the form has spec_path instead of url. // We pass the spec_path as url so the temp-session endpoint has something @@ -218,12 +241,12 @@ const CreateMCPServer: React.FC = ({ return; } - if (isClientForwardedTokenMode(form.getFieldValue("auth_type"))) { + if (isClientForwardedTokenMode(form.getValues("auth_type") as string | undefined)) { // Browser-only modes: the token is held in local state (oauthAccessToken) for tool preview // and committed to sessionStorage on submit; it must never be written into form.credentials, // which would persist it as server-level credentials on the created server row. Mirrors the // edit form's onTokenReceived early return. - setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true))); + setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getValues())); toast.success( "Token held for this browser session. Tools can now be previewed and configured; the token is not saved to LiteLLM.", ); @@ -240,7 +263,7 @@ const CreateMCPServer: React.FC = ({ } : null; - const current = (form.getFieldValue("credentials") as Record | undefined) ?? {}; + const current = (form.getValues("credentials") as Record | undefined) ?? {}; const nextCredentials = { ...(preservedAdminCredentials(current) ?? {}), ...(current.scopes !== undefined && { scopes: current.scopes }), @@ -252,10 +275,10 @@ const CreateMCPServer: React.FC = ({ // Path-replace (not deep-merge) so a re-authorize with fewer token fields does not leave stale // siblings from the previous token behind; the admin-typed client keys and scopes are carried // explicitly above. - form.setFieldValue("credentials", nextCredentials); + form.setValue("credentials", nextCredentials); // Capture the identity AFTER writing the token so the held token is not spuriously invalidated by // its own credential write. - setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true))); + setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getValues())); toast.success("OAuth authorization successful! Please click 'Create MCP Server' to save the configuration."); }, @@ -277,10 +300,12 @@ const CreateMCPServer: React.FC = ({ // Capture the admin-typed app before resetFields destroys it, then re-apply it: the app is // upstream-scoped config, not minted material, so it survives every invalidation (the token is // what gets discarded). Token-shaped keys are excluded by the helper's key filter. - const keptAdminCredentials = preservedAdminCredentials(form.getFieldValue("credentials")); - form.resetFields([...CLEARED_ON_INVALIDATION]); + const keptAdminCredentials = preservedAdminCredentials( + form.getValues("credentials") as Record | undefined, + ); + resetFieldsToDefaults(form, CREATE_DEFAULTS, CLEARED_ON_INVALIDATION); if (keptAdminCredentials) { - form.setFieldsValue({ credentials: keptAdminCredentials }); + applyFieldValues(form, { credentials: keptAdminCredentials }); } // Re-apply the in-flight edit last; rc-field-form deep-merges nested objects, so a changed // credentials sub-field composes with the preserved sibling instead of replacing the object. @@ -288,7 +313,7 @@ const CreateMCPServer: React.FC = ({ CLEARED_ON_INVALIDATION.filter((key) => key in changedValues).map((key) => [key, changedValues[key]]), ); if (Object.keys(preserved).length > 0) { - form.setFieldsValue(preserved); + applyFieldValues(form, preserved); } }; @@ -337,7 +362,7 @@ const CreateMCPServer: React.FC = ({ // wait until transportType state catches up so the URL field is mounted return; } - form.setFieldsValue(pendingRestoredValues.values); + applyFieldValues(form, pendingRestoredValues.values); setFormValues(pendingRestoredValues.values); setPendingRestoredValues(null); }, [pendingRestoredValues, form, transportType]); @@ -381,7 +406,7 @@ const CreateMCPServer: React.FC = ({ prefillValues.url = prefillData.url; } - form.setFieldsValue(prefillValues); + applyFieldValues(form, prefillValues); setFormValues(prefillValues); setAliasManuallyEdited(false); }, [isModalVisible, prefillData, form]); @@ -446,7 +471,7 @@ const CreateMCPServer: React.FC = ({ description: "Once an admin approves it, the server will appear in your MCP Servers list.", }); } - form.resetFields(); + form.reset(CREATE_DEFAULTS); setCostConfig({}); clearTools(); setAllowedTools([]); @@ -466,7 +491,7 @@ const CreateMCPServer: React.FC = ({ // state const handleCancel = () => { - form.resetFields(); + form.reset(CREATE_DEFAULTS); setCostConfig({}); clearTools(); setAllowedTools([]); @@ -489,11 +514,11 @@ const CreateMCPServer: React.FC = ({ ? { url: undefined, command: undefined, args: undefined, env: undefined } : { spec_path: undefined, command: undefined, args: undefined, env: undefined }; - form.setFieldsValue(transportValues); - if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentity)) { + applyFieldValues(form, transportValues); + if (isHeldOAuthTokenStale(form.getValues(), authorizedIdentity)) { clearHeldOAuthToken(); } - setFormValues(form.getFieldsValue(true)); + setFormValues(form.getValues() as Record); }; // Generate options with existing groups and potential new group @@ -532,7 +557,7 @@ const CreateMCPServer: React.FC = ({ React.useEffect(() => { if (!aliasManuallyEdited && formValues.server_name) { const normalized = formValues.server_name.replace(/\s+/g, "_"); - form.setFieldsValue({ alias: normalized }); + applyFieldValues(form, { alias: normalized }); setFormValues((prev) => ({ ...prev, alias: normalized })); } }, [formValues.server_name]); @@ -549,7 +574,7 @@ const CreateMCPServer: React.FC = ({ const wasVisible = wasModalVisibleRef.current; wasModalVisibleRef.current = isModalVisible; if (!isModalVisible && wasVisible) { - form.resetFields(); + form.reset(CREATE_DEFAULTS); setFormValues({}); setOauthAccessToken(null); clearTools(); @@ -582,19 +607,38 @@ const CreateMCPServer: React.FC = ({ const upstreamChanged = ["url", "spec_path", "issuer", "authorization_url", "token_url", "registration_url"].some( (key) => key in changedValues, ); - const hasDeclaredApp = preservedDeclaredAppCredentials(form.getFieldValue("credentials")) !== undefined; + const hasDeclaredApp = + preservedDeclaredAppCredentials(form.getValues("credentials") as Record | undefined) !== + undefined; if (upstreamChanged && hasDeclaredApp) { setAppMayNotMatchUpstream(true); } } - if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentity)) { + if (isHeldOAuthTokenStale(form.getValues(), authorizedIdentity)) { clearHeldOAuthToken(changedValues); - setFormValues(form.getFieldsValue(true)); + setFormValues(form.getValues() as Record); return; } setFormValues(allValues); }; + const valuesChangeRef = React.useRef(handleFormValuesChange); + React.useEffect(() => { + valuesChangeRef.current = handleFormValuesChange; + }); + React.useEffect(() => { + const subscription = form.watch((values, { name, type }) => { + if (type !== "change" || !name) { + return; + } + valuesChangeRef.current( + changedValuesFor(name, values as MountedFormValues), + projectMountedValues(registry, values as MountedFormValues), + ); + }); + return () => subscription.unsubscribe(); + }, [form, registry]); + // rendering return ( = ({ }} >
-
- {!isAdmin && ( -
- Your submission will be sent for admin review. Once approved, the server will appear in your MCP Servers - list. The request must be made with a team-scoped API key. -
- )} -
- - MCP Server Name - - - - - } - name="server_name" - rules={[ - { required: false, message: "Please enter a server name" }, - { validator: (_, value) => validateMCPServerName(value) }, - ]} + + + handleCreate(projectMountedValues(registry, store)))} + className="space-y-6" > - - + {!isAdmin && ( +
+ Your submission will be sent for admin review. Once approved, the server will appear in your MCP + Servers list. The request must be made with a team-scoped API key. +
+ )} +
+ + MCP Server Name + + + + + } + name="server_name" + rules={{ validate: (value) => antdValidator(validateMCPServerName, value) }} + > + {(field) => ( + (field)} + placeholder="e.g., GitHub_MCP, Zapier_MCP, etc." + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + - - Alias - - - - - } - name="alias" - rules={[{ required: false }, { validator: (_, value) => validateMCPServerName(value) }]} - > - setAliasManuallyEdited(true)} - /> - + + Alias + + + + + } + name="alias" + rules={{ validate: (value) => antdValidator(validateMCPServerName, value) }} + > + {(field) => ( + (field)} + placeholder="e.g., GitHub_MCP, Zapier_MCP, etc." + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + onChange={(event) => { + setAliasManuallyEdited(true); + field.onChange(event); + }} + /> + )} + - Description} - name="description" - rules={[ - { - required: false, - message: "Please enter a server description", - }, - ]} - > - - + Description} + name="description" + > + {(field) => ( + (field)} + placeholder="Brief description of what this server does" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + - + - GitHub / Source URL} - name="source_url" - > - - + GitHub / Source URL} + name="source_url" + > + {(field) => ( + (field)} + placeholder="https://github.com/org/mcp-server" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + - Transport Type} - name="transport" - rules={[{ required: true, message: "Please select a transport type" }]} - > - - + Transport Type} + name="transport" + required + rules={{ required: "Please select a transport type" }} + > + {(field) => ( + + )} + - {/* URL field - only show for HTTP and SSE */} - {(transportType === "http" || transportType === "sse") && ( - MCP Server URL} - name="url" - rules={[ - { required: true, message: "Please enter a server URL" }, - { validator: (_, value) => validateMCPServerUrl(value) }, - ]} - > - - - )} + {/* URL field - only show for HTTP and SSE */} + {(transportType === "http" || transportType === "sse") && ( + MCP Server URL} + name="url" + required + rules={{ + required: "Please enter a server URL", + validate: (value) => antdValidator(validateMCPServerUrl, value), + }} + > + {(field) => ( + (field)} + placeholder="https://your-mcp-server.com" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + )} - {/* OpenAPI: logo picker + spec URL input */} - {transportType === TRANSPORT.OPENAPI && ( - - handleFormValuesChange(updates, { ...form.getFieldsValue(true), ...updates }) - } - onKeyToolsChange={setKeyTools} - onLogoUrlChange={setLogoUrl} - onOAuthDocsUrlChange={setOauthDocsUrl} - /> - )} + {/* OpenAPI: logo picker + spec URL input */} + {transportType === TRANSPORT.OPENAPI && ( + handleFormValuesChange(updates, { ...form.getValues(), ...updates })} + onKeyToolsChange={setKeyTools} + onLogoUrlChange={setLogoUrl} + onOAuthDocsUrlChange={setOauthDocsUrl} + /> + )} - {/* BYOK toggle - only for OpenAPI */} - {transportType === TRANSPORT.OPENAPI && } + {/* BYOK toggle - only for OpenAPI */} + {transportType === TRANSPORT.OPENAPI && } - - Max Concurrent Requests (optional) - - - - - } - name="max_concurrent_requests" - > - - + + Max Concurrent Requests (optional) + + + + + } + name="max_concurrent_requests" + > + {(field) => ( + (field)} + min={1} + precision={0} + placeholder="e.g. 10" + style={{ width: "100%" }} + className="rounded-lg" + /> + )} + - {/* Authentication - show for HTTP, SSE, and OpenAPI */} - {transportType !== "stdio" && transportType !== "" && ( - Authentication, - children: ( - <> - - - + {/* Authentication - show for HTTP, SSE, and OpenAPI */} + {transportType !== "stdio" && transportType !== "" && ( + Authentication, + children: ( + <> + + {(field) => ( + + )} + - + - - - {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 whitespace")) - : Promise.resolve(), - }, - ]} - > - - - )} - {isOAuthAuthType && ( - - )} + {shouldShowAuthValueField && ( + + Authentication Value + + + + + } + name="credentials.auth_value" + rules={{ + validate: (value) => + value && typeof value === "string" && value.trim() === "" + ? "Authentication value cannot be empty whitespace" + : true, + }} + > + {(field) => ( + (field)} + placeholder="Enter token or secret" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + )} - {isTokenExchangeAuthType && } + {isOAuthAuthType && ( + + )} - {isIdJagAuthType && } - - ), - }, - ]} - /> - )} + {isTokenExchangeAuthType && } - {transportType !== "stdio" && transportType !== "" && isAwsSigV4AuthType && } + {isIdJagAuthType && } + + ), + }, + ]} + /> + )} - {/* Stdio Configuration - only show for stdio transport */} - -
+ {transportType !== "stdio" && transportType !== "" && isAwsSigV4AuthType && } - {/* Environment Variables Section */} -
- -
+ {/* Stdio Configuration - only show for stdio transport */} + +
- {/* Permission Management / Access Control Section */} -
- -
+ {/* Environment Variables Section */} +
+ +
- {/* Connection Status Section */} -
- -
+ {/* Permission Management / Access Control Section */} +
+ +
- {/* Tool Configuration Section */} -
- setHasToolAllowlistInteraction(true)} - toolNameToDisplayName={toolNameToDisplayName} - toolNameToDescription={toolNameToDescription} - onToolNameToDisplayNameChange={setToolNameToDisplayName} - onToolNameToDescriptionChange={setToolNameToDescription} - keyTools={keyTools} - externalTools={tools} - externalIsLoading={isLoadingTools} - externalError={toolsError} - externalErrorStatus={toolsErrorStatus} - externalCanFetch={canFetchTools} - /> -
+ {/* Connection Status Section */} +
+ +
- {/* Cost Configuration Section */} -
- allowedTools.includes(tool.name))} - disabled={false} - /> -
+ {/* Tool Configuration Section */} +
+ setHasToolAllowlistInteraction(true)} + toolNameToDisplayName={toolNameToDisplayName} + toolNameToDescription={toolNameToDescription} + onToolNameToDisplayNameChange={setToolNameToDisplayName} + onToolNameToDescriptionChange={setToolNameToDescription} + keyTools={keyTools} + externalTools={tools} + externalIsLoading={isLoadingTools} + externalError={toolsError} + externalErrorStatus={toolsErrorStatus} + externalCanFetch={canFetchTools} + /> +
-
- - -
-
+ {/* Cost Configuration Section */} +
+ allowedTools.includes(tool.name))} + disabled={false} + /> +
+ +
+ + +
+ + +
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx index 49c182aa6be..ad78aa2c3ff 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx @@ -1,16 +1,17 @@ import React from "react"; -import { Form, Switch, Tooltip } from "antd"; +import { Switch, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { isClientForwardedTokenMode } from "@/components/mcp_tools/types"; +import { MountedFormField } from "@/components/common_components/MountedFormField"; /** * DCR-bridge toggle for the client-forwarded token modes (true_passthrough / * oauth_delegate); self-gates to those two auth types and renders nothing * otherwise. When on, OAuth-only clients like Claude Desktop can register and * sign in through the gateway; when off, the gateway relays the upstream - * server's own OAuth metadata instead. `initialChecked` seeds the antd - * Form.Item `initialValue` (not the Switch's DOM defaultChecked): the create - * form defaults it on, the edit form seeds it from the stored value. + * server's own OAuth metadata instead. `initialChecked` seeds the field's + * default value (not the Switch's DOM defaultChecked): the create form defaults + * it on, the edit form seeds it from the stored value. */ export default function DcrBridgeToggle({ authType, @@ -21,7 +22,7 @@ export default function DcrBridgeToggle({ }) { if (!isClientForwardedTokenMode(authType)) return null; return ( - Gateway-hosted sign-in (DCR bridge) @@ -31,10 +32,9 @@ export default function DcrBridgeToggle({ } name="dcr_bridge" - valuePropName="checked" - initialValue={initialChecked} + defaultValue={initialChecked} > - - + {(field) => } + ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.tsx index fbaacc40263..19b8f9a89b5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.tsx @@ -1,6 +1,13 @@ import React from "react"; -import { Form, Input, Select, Button, Tooltip, Typography } from "antd"; +import { Input, Select, Button, Tooltip, Typography } from "antd"; import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons"; +import { + MountedFormField, + bindControl, + useMountedFieldArray, + useMountedFormContext, + useMountedWatch, +} from "@/components/common_components/MountedFormField"; const { Text } = Typography; @@ -20,6 +27,9 @@ const SCOPE_OPTIONS = [ * The parent form reads the ``env_vars`` field from the form values. */ const EnvVarsSection: React.FC = () => { + const { control } = useMountedFormContext(); + const { fields, append, remove } = useMountedFieldArray(control, "env_vars"); + return (
@@ -48,60 +58,59 @@ const EnvVarsSection: React.FC = () => { - - {(fields, { add, remove }) => ( -
- {fields.length > 0 && ( -
-
Variable Name
-
Value / Description
-
Scope
-
-
- )} - {fields.map(({ key, name, ...restField }) => ( -
- - - -
- -
- - (field)} + placeholder="e.g. DB_PROTOCOL" + className="rounded-md font-mono" + /> + )} + +
+
+ +
+
+ + {(field) => - - - Hint - - - } - placeholder="e.g. Your DB username" - styles={{ input: { color: "#9ca3af" } }} - /> - + + {(field) => ( + (field)} + addonBefore={ + + + + Hint + + + } + placeholder="e.g. Your DB username" + styles={{ input: { color: "#9ca3af" } }} + /> + )} + ); } return ( - - - + + {(field) => ( + (field)} + placeholder="e.g. postgresql" + className="rounded-md font-mono" + /> + )} + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx index e8730a5b974..565db208366 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx @@ -1,6 +1,8 @@ import React from "react"; -import { Form, Input, Select, Tooltip } from "antd"; +import { Input, Select, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; +import { useFormContext } from "react-hook-form"; +import { MountedFormField, bindControl, type MountedFormValues } from "@/components/common_components/MountedFormField"; interface IdJagFormFieldsProps { isEditing?: boolean; @@ -19,10 +21,11 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt const IdJagFormFields: React.FC = ({ isEditing = false }) => { const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : ""; + const { getValues } = useFormContext(); return ( <> - = ({ isEditing = false }) /> } name="token_exchange_endpoint" - rules={[{ required: !isEditing, message: "The org token endpoint is required for ID-JAG" }]} + required={!isEditing} + rules={isEditing ? {} : { required: "The org token endpoint is required for ID-JAG" }} > - - - ( + (field)} + placeholder="https://your-org.okta.com/oauth2/v1/token" + className={fieldClassName} + /> + )} + + } - name={["credentials", "id_jag_resource_token_endpoint"]} - rules={[{ required: !isEditing, message: "The resource token endpoint is required for ID-JAG" }]} + name="credentials.id_jag_resource_token_endpoint" + required={!isEditing} + rules={isEditing ? {} : { required: "The resource token endpoint is required for ID-JAG" }} > - - - ( + (field)} + placeholder="https://upstream.example.com/oauth2/token" + className={fieldClassName} + /> + )} + + } - name={["credentials", "client_id"]} - rules={[{ required: !isEditing, message: "Client ID is required for ID-JAG" }]} + name="credentials.client_id" + required={!isEditing} + rules={isEditing ? {} : { required: "Client ID is required for ID-JAG" }} > - - - ( + (field)} + placeholder={`Enter OAuth client ID${placeholderSuffix}`} + className={fieldClassName} + /> + )} + + } - name={["credentials", "client_secret"]} - dependencies={[["credentials", "client_private_key"]]} - rules={[ - ({ getFieldValue }) => ({ - validator: (_, value) => { - if (isEditing || value || getFieldValue(["credentials", "client_private_key"])) { - return Promise.resolve(); - } - return Promise.reject(new Error("Provide either a client secret or a client private key")); - }, - }), - ]} + name="credentials.client_secret" + rules={{ + deps: ["credentials.client_private_key"], + validate: (value) => + isEditing || value || getValues("credentials.client_private_key") + ? true + : "Provide either a client secret or a client private key", + }} > - - - ( + (field)} + placeholder={`Enter OAuth client secret${placeholderSuffix}`} + className={fieldClassName} + /> + )} + + } - name={["credentials", "client_private_key"]} + name="credentials.client_private_key" > - - - ( + (field)} + rows={3} + placeholder={`-----BEGIN PRIVATE KEY-----${placeholderSuffix}`} + className={fieldClassName} + /> + )} + + } - name={["credentials", "client_private_key_id"]} + name="credentials.client_private_key_id" > - - - ( + (field)} + placeholder="my-signing-key-1" + className={fieldClassName} + /> + )} + + } - name={["credentials", "client_assertion_signing_alg"]} + name="credentials.client_assertion_signing_alg" > - - - ( + (field)} placeholder="RS256" className={fieldClassName} /> + )} + + = ({ isEditing = false }) } name="audience" > - - - ( + (field)} + placeholder="https://upstream.example.com" + className={fieldClassName} + /> + )} + + } - name={["credentials", "id_jag_resource"]} + name="credentials.id_jag_resource" > - - - ( + (field)} + placeholder="https://upstream.example.com/mcp" + className={fieldClassName} + /> + )} + + = ({ isEditing = false }) } name="subject_token_type" > - - - ( + (field)} + placeholder="urn:ietf:params:oauth:token-type:id_token" + className={fieldClassName} + /> + )} + + } - name={["credentials", "scopes"]} + name="credentials.scopes" > - (field)} + mode="tags" + tokenSeparators={[","]} + placeholder="Add scopes" + className="rounded-lg" + size="large" + /> + )} + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx index ee6aee86a4d..4e14f7e5ba4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx @@ -2,10 +2,37 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect } from "vitest"; -import { Form } from "antd"; +import { FormProvider, useForm } from "react-hook-form"; +import { + MountedFormField, + MountedFormProvider, + useMountRegistry, + type MountedFormValues, +} from "@/components/common_components/MountedFormField"; import MCPPermissionManagement from "./MCPPermissionManagement"; +const Wrapper: React.FC<{ children: React.ReactNode; defaultValues: MountedFormValues; withAuthType?: boolean }> = ({ + children, + defaultValues, + withAuthType = false, +}) => { + const form = useForm({ defaultValues }); + const registry = useMountRegistry(); + return ( + + + {withAuthType && ( + + {(field) => } + + )} + {children} + + + ); +}; + const defaultProps = { availableAccessGroups: [], mcpServer: null, @@ -24,22 +51,12 @@ describe("MCPPermissionManagement", () => { return user; }; - const renderWithForm = (props = {}) => { - const Wrapper: React.FC = ({ children }) => { - const [form] = Form.useForm(); - return ( -
- {children} -
- ); - }; - - return render( - + const renderWithForm = (props = {}) => + render( + , ); - }; it("should default allow_all_keys switch to unchecked for new servers", async () => { renderWithForm(); @@ -51,27 +68,12 @@ describe("MCPPermissionManagement", () => { expect(toggle).not.toBeChecked(); }); - const renderWithInitialValues = (initialValues: Record, props = {}) => { - const Wrapper: React.FC = ({ children }) => { - const [form] = Form.useForm(); - return ( -
- {/* In the real app auth_type is registered by the parent form; the - component only watches it. Register a hidden field here so - Form.useWatch("auth_type") resolves the initial value. */} - - {children} -
- ); - }; - return render( - + const renderWithInitialValues = (initialValues: Record, props = {}) => + render( + , ); - }; it("shows only the oauth2 PKCE-delegation toggle for oauth2 servers", async () => { renderWithInitialValues({ allow_all_keys: false, auth_type: "oauth2" }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx index aae13d4b467..79e4b8af9c1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx @@ -1,7 +1,16 @@ import React, { useEffect } from "react"; -import { Alert, Form, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd"; +import { Alert, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd"; import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons"; +import { useFormContext } from "react-hook-form"; import { MCPServer, AUTH_TYPE } from "@/components/mcp_tools/types"; +import { + MountedFormField, + bindControl, + useMountedFieldArray, + useMountedFormContext, + useMountedWatch, + type MountedFormValues, +} from "@/components/common_components/MountedFormField"; const { Panel } = Collapse; interface MCPPermissionManagementProps { @@ -22,11 +31,13 @@ const MCPPermissionManagement: React.FC = ({ setSearchValue, getAccessGroupOptions, }) => { - const form = Form.useFormInstance(); - const watchedAuthType = Form.useWatch("auth_type", form); + const form = useFormContext(); + const { control } = useMountedFormContext(); + const staticHeaders = useMountedFieldArray(control, "static_headers"); + const watchedAuthType = useMountedWatch("auth_type"); const isOAuth2 = watchedAuthType === AUTH_TYPE.OAUTH2; const isNoneAuth = watchedAuthType === AUTH_TYPE.NONE || watchedAuthType == null; - const watchedExtraHeaders = Form.useWatch("extra_headers", form); + const watchedExtraHeaders = useMountedWatch("extra_headers"); const hasAuthorizationHeader = Array.isArray(watchedExtraHeaders) && watchedExtraHeaders.some((h) => typeof h === "string" && h.toLowerCase() === "authorization"); @@ -39,22 +50,22 @@ const MCPPermissionManagement: React.FC = ({ // Kept as separate flags so neither silently implies the other and existing // oauth2 servers can't regress into pass-through behavior. const canEnableOAuthPassthrough = isNoneAuth && hasAuthorizationHeader; - const watchedDelegateAuth = Form.useWatch("delegate_auth_to_upstream", form); - const watchedPublicInternet = Form.useWatch("available_on_public_internet", form); + const watchedDelegateAuth = useMountedWatch("delegate_auth_to_upstream"); + const watchedPublicInternet = useMountedWatch("available_on_public_internet"); const showInternalDelegatePkceWarning = isOAuth2 && watchedDelegateAuth === true && watchedPublicInternet === false; // Set initial values when mcpServer changes useEffect(() => { if (mcpServer) { if (mcpServer.static_headers) { - const staticHeaders = Object.entries(mcpServer.static_headers).map(([header, value]) => ({ + const headerRows = Object.entries(mcpServer.static_headers).map(([header, value]) => ({ header, value: value != null ? String(value) : "", })); - form.setFieldValue("static_headers", staticHeaders); + form.setValue("static_headers", headerRows); } if (Array.isArray(mcpServer.env_vars) && mcpServer.env_vars.length > 0) { - form.setFieldValue( + form.setValue( "env_vars", mcpServer.env_vars.map((entry) => ({ name: entry.name, @@ -65,22 +76,22 @@ const MCPPermissionManagement: React.FC = ({ ); } if (typeof mcpServer.allow_all_keys === "boolean") { - form.setFieldValue("allow_all_keys", mcpServer.allow_all_keys); + form.setValue("allow_all_keys", mcpServer.allow_all_keys); } if (typeof mcpServer.available_on_public_internet === "boolean") { - form.setFieldValue("available_on_public_internet", mcpServer.available_on_public_internet); + form.setValue("available_on_public_internet", mcpServer.available_on_public_internet); } if (typeof mcpServer.delegate_auth_to_upstream === "boolean") { - form.setFieldValue("delegate_auth_to_upstream", mcpServer.delegate_auth_to_upstream); + form.setValue("delegate_auth_to_upstream", mcpServer.delegate_auth_to_upstream); } if (typeof mcpServer.oauth_passthrough === "boolean") { - form.setFieldValue("oauth_passthrough", mcpServer.oauth_passthrough); + form.setValue("oauth_passthrough", mcpServer.oauth_passthrough); } } else { - form.setFieldValue("allow_all_keys", false); - form.setFieldValue("available_on_public_internet", true); - form.setFieldValue("delegate_auth_to_upstream", false); - form.setFieldValue("oauth_passthrough", false); + form.setValue("allow_all_keys", false); + form.setValue("available_on_public_internet", true); + form.setValue("delegate_auth_to_upstream", false); + form.setValue("oauth_passthrough", false); } }, [mcpServer, form]); @@ -89,7 +100,7 @@ const MCPPermissionManagement: React.FC = ({ // stale toggle value doesn't get persisted unexpectedly. useEffect(() => { if (!isOAuth2) { - form.setFieldValue("delegate_auth_to_upstream", false); + form.setValue("delegate_auth_to_upstream", false); } }, [isOAuth2, form]); @@ -97,7 +108,7 @@ const MCPPermissionManagement: React.FC = ({ // Authorization upstream. Force it back to false otherwise. useEffect(() => { if (!canEnableOAuthPassthrough) { - form.setFieldValue("oauth_passthrough", false); + form.setValue("oauth_passthrough", false); } }, [canEnableOAuthPassthrough, form]); @@ -130,14 +141,9 @@ const MCPPermissionManagement: React.FC = ({ Enable if this server should be "public" to all keys.

- - - + + {(field) => } +
@@ -152,16 +158,11 @@ const MCPPermissionManagement: React.FC = ({ Turn on to restrict access to callers within your internal network only.

- ({ checked: !value })} - getValueFromEvent={(checked: boolean) => !checked} - initialValue={true} - className="mb-0" - > - - + + {(field) => ( + field.onChange(!checked)} /> + )} +
{isOAuth2 && ( @@ -177,14 +178,13 @@ const MCPPermissionManagement: React.FC = ({ Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server.

- - - + {(field) => } +
)} @@ -202,14 +202,13 @@ const MCPPermissionManagement: React.FC = ({ upstream MCP server.

- - - + {(field) => } + )} @@ -223,7 +222,7 @@ const MCPPermissionManagement: React.FC = ({ /> )} - MCP Access Groups @@ -235,21 +234,24 @@ const MCPPermissionManagement: React.FC = ({ name="mcp_access_groups" className="mb-4" > - (field)} + mode="tags" + showSearch + placeholder="Select existing groups or type to create new ones" + optionFilterProp="value" + filterOption={(input, option) => (option?.value ?? "").toLowerCase().includes(input.toLowerCase())} + onSearch={(value) => setSearchValue(value)} + tokenSeparators={[","]} + options={getAccessGroupOptions()} + maxTagCount="responsive" + allowClear + /> + )} + - Extra Headers @@ -265,70 +267,78 @@ const MCPPermissionManagement: React.FC = ({ } name="extra_headers" > - (field)} + mode="tags" + placeholder={ + mcpServer?.extra_headers && mcpServer.extra_headers.length > 0 + ? `Currently: ${mcpServer.extra_headers.join(", ")}` + : "Enter header names (e.g., Authorization, X-Custom-Header)" + } + className="rounded-lg" + size="large" + tokenSeparators={[","]} + allowClear + /> + )} + - - Static Headers - - - - - } - required={false} - > - - {(fields, { add, remove }) => ( -
- {fields.map(({ key, name, ...restField }) => ( - - +
+
+ Static Headers + + + +
+
+ {staticHeaders.fields.map((row, index) => ( + +
+ + {(field) => ( (field)} size="large" allowClear className="rounded-lg" placeholder="Header name (e.g., X-API-Key)" /> - - - - - remove(name)} - className="text-gray-500 hover:text-red-500 cursor-pointer" - /> - - ))} - -
- )} - - + )} + +
+
+ + {(field) => ( + (field)} + size="large" + allowClear + className="rounded-lg" + placeholder="Header value" + /> + )} + +
+ staticHeaders.remove(index)} + className="text-gray-500 hover:text-red-500 cursor-pointer" + /> + + ))} + +
+
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx index 48964490339..99158fcf5b3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx @@ -1,22 +1,32 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; -import { Form } from "antd"; +import { FormProvider, useForm } from "react-hook-form"; +import { + MountedFormProvider, + projectMountedValues, + useMountRegistry, + type MountedFormValues, +} from "@/components/common_components/MountedFormField"; import OAuthFormFields from "./OAuthFormFields"; // ── helpers ────────────────────────────────────────────────────────────────── -/** Minimal Ant Form wrapper so Form.Item registers correctly. */ const WithForm: React.FC<{ children: React.ReactNode; onFinish?: (values: any) => void }> = ({ children, onFinish, }) => { - const [form] = Form.useForm(); + const form = useForm({ defaultValues: {} }); + const registry = useMountRegistry(); return ( -
- {children} - -
+ + +
onFinish?.(projectMountedValues(registry, store)))}> + {children} + +
+
+
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx index cbe6ac18d22..125f33c6a0e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx @@ -1,9 +1,10 @@ import React from "react"; -import { Form, Input as AntdInput, InputNumber, Select, Tooltip } from "antd"; +import { Input as AntdInput, InputNumber, Select, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { OAUTH_FLOW } from "@/components/mcp_tools/types"; +import { MountedFormField, bindControl } from "@/components/common_components/MountedFormField"; import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField"; interface OAuthFlowStatus { @@ -41,12 +42,19 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt ); const UpstreamResourceField: React.FC = () => ( - } - name={["credentials", "upstream_resource"]} + name="credentials.upstream_resource" > - - + {(field) => ( + (field)} + value={(field.value as string | undefined) ?? ""} + placeholder="auto, or https://mcp.example.com/mcp" + className={fieldClassName} + /> + )} + ); const OAuthFormFields: React.FC = ({ @@ -57,11 +65,11 @@ const OAuthFormFields: React.FC = ({ docsUrl, }) => { const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : ""; - const requiredWhenCreating = (message: string) => (isEditing ? [] : [{ required: true, message }]); + const requiredWhenCreating = (message: string) => (isEditing ? {} : { required: message }); return ( <> - = ({ /> } name="oauth_flow_type" - {...(initialFlowType ? { initialValue: initialFlowType } : {})} + {...(initialFlowType ? { defaultValue: initialFlowType } : {})} > - - + {(field) => ( + + )} + {isM2M ? ( <> - } - name={["credentials", "client_id"]} + name="credentials.client_id" + required={!isEditing} rules={requiredWhenCreating("Client ID is required for M2M OAuth")} > - - - ( + (field)} + placeholder={`Enter OAuth client ID${placeholderSuffix}`} + className={fieldClassName} + /> + )} + + } - name={["credentials", "client_secret"]} + name="credentials.client_secret" + required={!isEditing} rules={requiredWhenCreating("Client Secret is required for M2M OAuth")} > - - - ( + (field)} + placeholder={`Enter OAuth client secret${placeholderSuffix}`} + className={fieldClassName} + /> + )} + + } name="token_url" + required={!isEditing} rules={requiredWhenCreating("Token URL is required for M2M OAuth")} > - - + {(field) => ( + (field)} + value={(field.value as string | undefined) ?? ""} + placeholder="https://auth.example.com/oauth/token" + className={fieldClassName} + /> + )} + - } - name={["credentials", "scopes"]} + name="credentials.scopes" > - (field)} + mode="tags" + tokenSeparators={[","]} + placeholder="Add scopes" + className="rounded-lg" + size="large" + /> + )} + ) : ( <> - = ({ )} } - name={["credentials", "client_id"]} + name="credentials.client_id" > - - - ( + (field)} + placeholder={`Enter client ID${placeholderSuffix}`} + className={fieldClassName} + /> + )} + + } - name={["credentials", "client_secret"]} + name="credentials.client_secret" > - - - ( + (field)} + placeholder={`Enter client secret${placeholderSuffix}`} + className={fieldClassName} + /> + )} + + } - name={["credentials", "scopes"]} + name="credentials.scopes" > - (field)} + mode="tags" + tokenSeparators={[","]} + placeholder="Add scopes" + className="rounded-lg" + size="large" + /> + )} + - = ({ } name="issuer" > - - - ( + (field)} + value={(field.value as string | undefined) ?? ""} + placeholder="https://issuer.example.com" + className={fieldClassName} + /> + )} + + = ({ } name="authorization_url" > - - - ( + (field)} + value={(field.value as string | undefined) ?? ""} + placeholder="https://example.com/oauth/authorize" + className={fieldClassName} + /> + )} + + } name="token_url" > - - + {(field) => ( + (field)} + value={(field.value as string | undefined) ?? ""} + placeholder="https://example.com/oauth/token" + className={fieldClassName} + /> + )} + - = ({ } name="registration_url" > - - - ( + (field)} + value={(field.value as string | undefined) ?? ""} + placeholder="https://example.com/oauth/register" + className={fieldClassName} + /> + )} + + = ({ /> } name="token_validation_json" - rules={[ - { - validator: (_: any, value: string) => { - if (!value || value.trim() === "") return Promise.resolve(); - try { - JSON.parse(value); - return Promise.resolve(); - } catch { - return Promise.reject(new Error("Must be valid JSON")); - } - }, + rules={{ + validate: (value) => { + if (typeof value !== "string" || value.trim() === "") return true; + try { + JSON.parse(value); + return true; + } catch { + return "Must be valid JSON"; + } }, - ]} + }} > - - - ( + (field)} + placeholder={'{\n "organization": "my-org",\n "team.id": "123"\n}'} + rows={4} + className="font-mono text-sm rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + = ({ } name="token_storage_ttl_seconds" > - - + {(field) => ( + (field)} + min={1} + placeholder="e.g. 3600" + className="w-full rounded-lg" + style={{ width: "100%" }} + /> + )} + {oauthFlow && (

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx index 073780b359f..ab6cceb9073 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx @@ -1,12 +1,20 @@ import React, { useState } from "react"; -import { Form, Input, Tooltip } from "antd"; +import { Input, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; -import { FormInstance } from "antd/es/form"; +import { UseFormReturn } from "react-hook-form"; import { AUTH_TYPE, OAUTH_FLOW } from "@/components/mcp_tools/types"; +import { + MountedFormField, + applyFieldValues, + bindControl, + resetFieldsToDefaults, + type MountedFormValues, +} from "@/components/common_components/MountedFormField"; import OpenAPIQuickPicker, { OpenAPIRegistryEntry, OpenAPIKeyTool } from "./OpenAPIQuickPicker"; interface OpenAPIFormSectionProps { - form: FormInstance; + form: UseFormReturn; + defaultValues: MountedFormValues; accessToken: string | null; /** Called when a preset is selected so the parent can sync its formValues state. */ onValuesChange: (updates: Record) => void; @@ -25,6 +33,7 @@ interface OpenAPIFormSectionProps { */ const OpenAPIFormSection: React.FC = ({ form, + defaultValues, accessToken, onValuesChange, onKeyToolsChange, @@ -47,13 +56,11 @@ const OpenAPIFormSection: React.FC = ({ updates.oauth_flow_type = OAUTH_FLOW.INTERACTIVE; updates.authorization_url = entry.oauth.authorization_url; updates.token_url = entry.oauth.token_url; - form.setFieldsValue(updates); + applyFieldValues(form, updates); onOAuthDocsUrlChange?.(entry.oauth.docs_url ?? null); } else { - // resetFields is required to visually clear Ant Design form fields — - // setFieldsValue with undefined silently skips undefined keys. - form.resetFields(["auth_type", "authorization_url", "token_url"]); - form.setFieldsValue(updates); + resetFieldsToDefaults(form, defaultValues, ["auth_type", "authorization_url", "token_url"]); + applyFieldValues(form, updates); onOAuthDocsUrlChange?.(null); } onValuesChange(updates); @@ -63,7 +70,7 @@ const OpenAPIFormSection: React.FC = ({ <> - OpenAPI Spec URL @@ -73,20 +80,25 @@ const OpenAPIFormSection: React.FC = ({ } name="spec_path" - rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]} + required + rules={{ required: "Please enter an OpenAPI spec URL" }} > - { - // Clear the preset selection when the user manually edits the spec URL - // so stale suggested tools from a previous preset don't persist. - setSelectedPreset(null); - onKeyToolsChange?.([]); - onOAuthDocsUrlChange?.(null); - }} - /> - + {(field) => ( + (field)} + placeholder="https://petstore3.swagger.io/api/v3/openapi.json" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + onChange={(event) => { + // Clear the preset selection when the user manually edits the spec URL + // so stale suggested tools from a previous preset don't persist. + setSelectedPreset(null); + onKeyToolsChange?.([]); + onOAuthDocsUrlChange?.(null); + field.onChange(event); + }} + /> + )} + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx index 2ac4279e20a..a7e81b6bd3d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenApiByokFields.tsx @@ -1,91 +1,98 @@ import React from "react"; -import { Form, Input, Select, Switch, Tooltip } from "antd"; +import { Input, Select, Switch, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; +import { useWatch } from "react-hook-form"; +import { MountedFormField, bindControl, useMountedFormContext } from "@/components/common_components/MountedFormField"; -const OpenApiByokFields: React.FC = () => ( - <> - - BYOK (Bring Your Own Key) - - - - - } - name="is_byok" - valuePropName="checked" - > - - +const OpenApiByokFields: React.FC = () => { + const { control } = useMountedFormContext(); + const isByok = useWatch({ control, name: "is_byok" }); + const authType = useWatch({ control, name: "auth_type" }) as string | undefined; - prev.is_byok !== cur.is_byok || prev.auth_type !== cur.auth_type}> - {({ getFieldValue }) => - getFieldValue("is_byok") ? ( - <> - {/* Auth format hint */} - {getFieldValue("auth_type") && getFieldValue("auth_type") !== "none" && ( -

- - - User keys will be sent as:{" "} - - {getFieldValue("auth_type") === "bearer_token" && "Authorization: Bearer {key}"} - {getFieldValue("auth_type") === "token" && "Authorization: token {key}"} - {getFieldValue("auth_type") === "api_key" && "x-api-key: {key}"} - {getFieldValue("auth_type") === "basic" && "Authorization: Basic {key}"} - {getFieldValue("auth_type") === "authorization" && "Authorization: {key}"} - - {!getFieldValue("auth_type") && "Set Authentication Type below to specify the format."} - -
- )} - {!getFieldValue("auth_type") && ( -
- - - Set the Authentication Type below to specify how user keys are sent (e.g., Bearer - Token, API Key header). - -
- )} - - Access Description - - - - - } - name="byok_description" - > + return ( + <> + + BYOK (Bring Your Own Key) + + + + + } + name="is_byok" + > + {(field) => } + + + {isByok ? ( + <> + {/* Auth format hint */} + {authType && authType !== "none" && ( +
+ + + User keys will be sent as:{" "} + + {authType === "bearer_token" && "Authorization: Bearer {key}"} + {authType === "token" && "Authorization: token {key}"} + {authType === "api_key" && "x-api-key: {key}"} + {authType === "basic" && "Authorization: Basic {key}"} + {authType === "authorization" && "Authorization: {key}"} + + +
+ )} + {!authType && ( +
+ + + Set the Authentication Type below to specify how user keys are sent (e.g., Bearer + Token, API Key header). + +
+ )} + + Access Description + + + + + } + name="byok_description" + > + {(field) => ( -
- - ) : null - } - - -); + + API Key Help URL + + + + + } + name="byok_api_key_help_url" + > + {(field) => ( + (field)} placeholder="https://docs.example.com/api-keys" /> + )} + + + ) : null} + + ); +}; export default OpenApiByokFields; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx index 0a09ef3f856..38111840103 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx @@ -1,12 +1,22 @@ import React from "react"; import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; -import { Form } from "antd"; +import { FormProvider, useForm } from "react-hook-form"; +import { + MountedFormProvider, + useMountRegistry, + type MountedFormValues, +} from "@/components/common_components/MountedFormField"; import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection"; const WithForm: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [form] = Form.useForm(); - return
{children}
; + const form = useForm({ defaultValues: {} }); + const registry = useMountRegistry(); + return ( + + {children} + + ); }; const noopFlow = { startOAuthFlow: () => {}, status: "idle", error: null, tokenResponse: null }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx index dc10f0f1392..9adf609eb2a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Button, Checkbox, Form, Input } from "antd"; +import { Button, Checkbox, Input } from "antd"; +import { MountedFormField, bindControl } from "@/components/common_components/MountedFormField"; import DcrBridgeToggle from "./DcrBridgeToggle"; import { credentialAuthClass, isClientForwardedTokenMode } from "@/components/mcp_tools/types"; @@ -81,27 +82,33 @@ export default function PassthroughAuthorizeSection({ and may not be valid. Update the client ID, or clear it to use dynamic client registration.

)} - OAuth Client ID (optional)} - name={["credentials", "client_id"]} - extra={clientIdExtra} + name="credentials.client_id" + help={clientIdExtra} > - - - ( + (field)} + placeholder={clientIdPlaceholder} + disabled={removeStoredApp} + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + OAuth Client Secret (optional)} - name={["credentials", "client_secret"]} + name="credentials.client_secret" > - - + {(field) => ( + (field)} + placeholder={clientSecretPlaceholder} + disabled={removeStoredApp} + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + {isEditing && onRemoveStoredAppChange && ( onRemoveStoredAppChange(e.target.checked)}> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx index 476a5b61683..47913b86e64 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx @@ -1,6 +1,7 @@ import React from "react"; -import { Form, Input, Tooltip } from "antd"; +import { Input, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; +import { MountedFormField, bindControl } from "@/components/common_components/MountedFormField"; interface StdioConfigurationProps { isVisible: boolean; @@ -15,7 +16,7 @@ const StdioConfiguration: React.FC = ({ isVisible, requ if (!isVisible) return null; return ( - Stdio Configuration (JSON) @@ -25,23 +26,23 @@ const StdioConfiguration: React.FC = ({ isVisible, requ } name="stdio_config" - rules={[ - ...(required ? [{ required: true, message: "Please enter stdio configuration" }] : []), - { - validator: (_, value) => { - if (!value) return Promise.resolve(); - try { - JSON.parse(value); - return Promise.resolve(); - } catch { - return Promise.reject("Please enter valid JSON"); - } - }, + required={required} + rules={{ + validate: (value) => { + if (!value) return required ? "Please enter stdio configuration" : true; + try { + JSON.parse(String(value)); + return true; + } catch { + return "Please enter valid JSON"; + } }, - ]} + }} > - ( + (field)} + placeholder={`{ "mcpServers": { "circleci-mcp-server": { "command": "npx", @@ -53,10 +54,11 @@ const StdioConfiguration: React.FC = ({ isVisible, requ } } }`} - rows={12} - className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm" - /> - + rows={12} + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm" + /> + )} + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx index c97ce96bfb1..74cc1ce7d34 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx @@ -1,6 +1,7 @@ import React from "react"; -import { Form, Select, Tooltip } from "antd"; +import { Select, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; +import { MountedFormField, bindControl } from "@/components/common_components/MountedFormField"; const TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS = [ { value: "client_secret_basic", label: "Client Secret Basic" }, @@ -12,7 +13,7 @@ interface TokenEndpointAuthMethodFieldProps { } const TokenEndpointAuthMethodField: React.FC = ({ isEditing = false }) => ( - Token Endpoint Auth Method (optional) @@ -21,18 +22,21 @@ const TokenEndpointAuthMethodField: React.FC } - name={["credentials", "token_endpoint_auth_method"]} + name="credentials.token_endpoint_auth_method" > - (field)} + allowClear + placeholder={ + isEditing ? "Leave blank to keep existing (default Client Secret Post)" : "Default (Client Secret Post)" + } + className="rounded-lg" + size="large" + options={TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS} + /> + )} + ); export default TokenEndpointAuthMethodField; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx index 9e1e1a85743..cc8eeeb3bd8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx @@ -1,6 +1,8 @@ import React from "react"; -import { Form, Input, Select, Tooltip } from "antd"; +import { Input, Select, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; +import { useWatch } from "react-hook-form"; +import { MountedFormField, bindControl, useMountedFormContext } from "@/components/common_components/MountedFormField"; interface TokenExchangeFormFieldsProps { isEditing?: boolean; @@ -19,10 +21,12 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt const TokenExchangeFormFields: React.FC = ({ isEditing = false }) => { const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : ""; + const { control } = useMountedFormContext(); + const isEntraObo = useWatch({ control, name: "token_exchange_profile" }) === "entra_obo"; return ( <> - = ({ isEdi /> } name="token_exchange_profile" - {...(isEditing ? {} : { initialValue: "rfc8693" })} + {...(isEditing ? {} : { defaultValue: "rfc8693" })} > - - - ( + + )} + + = ({ isEdi } name="token_exchange_endpoint" > - - - ( + (field)} + placeholder="https://idp.example.com/oauth2/token" + className={fieldClassName} + /> + )} + + } - name={["credentials", "client_id"]} - rules={[{ required: !isEditing, message: "Client ID is required for token exchange" }]} + name="credentials.client_id" + required={!isEditing} + rules={isEditing ? {} : { required: "Client ID is required for token exchange" }} > - - - ( + (field)} + placeholder={`Enter OAuth client ID${placeholderSuffix}`} + className={fieldClassName} + /> + )} + + } - name={["credentials", "client_secret"]} - rules={[{ required: !isEditing, message: "Client Secret is required for token exchange" }]} + name="credentials.client_secret" + required={!isEditing} + rules={isEditing ? {} : { required: "Client Secret is required for token exchange" }} > - - - prev.token_exchange_profile !== cur.token_exchange_profile}> - {({ getFieldValue }) => { - const isEntraObo = getFieldValue("token_exchange_profile") === "entra_obo"; - return ( - <> - {!isEntraObo && ( - <> - - } - name="audience" - > - - - - } - name="subject_token_type" - > - - - - )} - /.default)." - : "Optional scopes to request during the token exchange." - } - /> - } - name={["credentials", "scopes"]} - rules={ - isEntraObo - ? [ - { - required: true, - message: "Microsoft Entra OBO requires a scope, e.g. api:///.default", - }, - ] - : [] - } - > - (field)} + placeholder="https://upstream.example.com" + className={fieldClassName} + /> + )} + + + } + name="subject_token_type" + > + {(field) => ( + (field)} + placeholder="urn:ietf:params:oauth:token-type:access_token" + className={fieldClassName} + /> + )} + + + )} + /.default)." + : "Optional scopes to request during the token exchange." + } + /> + } + name="credentials.scopes" + required={isEntraObo} + rules={isEntraObo ? { required: "Microsoft Entra OBO requires a scope, e.g. api:///.default" } : {}} + > + {(field) => ( + - - validateMCPServerName(value), - }, - ]} - > - setAliasManuallyEdited(true)} - className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" - /> - - - - - - - - - - {/* 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 && ( - <> - - - - - - - )} - - {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. -

- - + +
handleSave(projectMountedValues(registry, store)))}> + antdValidator(validateMCPServerName, value) }} > - - - - - (field)} + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + antdValidator(validateMCPServerName, value) }} > - - + {(field) => ( + (field)} + onChange={(event) => { + setAliasManuallyEdited(true); + field.onChange(event); + }} + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + + {(field) => ( + (field)} + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + + + {(field) => ( + + )} + - {/* Optional JSON config (if provided, it overrides command/args/env on save) */} - -
- )} - - {!isStdioTransport && shouldShowAuthValueField && ( - - Authentication Value - - - - - } - name={["credentials", "auth_value"]} - rules={[ - { - validator: (_, value) => - value && typeof value === "string" && value.trim() === "" - ? Promise.reject(new Error("Authentication value cannot be empty")) - : Promise.resolve(), - }, - ]} - > - - - )} - - {!isStdioTransport && isOAuthAuthType && ( - <> - {!oauthFlowTypeValue && !isDelegateAuth && ( - - )} - - - )} - - {!isStdioTransport && isTokenExchangeAuthType && } - - {!isStdioTransport && isIdJagAuthType && } - - {!isStdioTransport && isAwsSigV4AuthType && ( - <> -

- For MCP servers hosted on AWS Bedrock AgentCore.{" "} - antdValidator(validateMCPServerUrl, value), + }} > - View docs → - -

- - AWS Region - - - - - } - name={["credentials", "aws_region_name"]} - rules={[]} - > - - - - AWS Service Name - - - - - } - name={["credentials", "aws_service_name"]} - > - - - - AWS Access Key ID - - - - - } - name={["credentials", "aws_access_key_id"]} - rules={[]} - > - - - - AWS Secret Access Key - - - - - } - name={["credentials", "aws_secret_access_key"]} - rules={[]} - > - - - - AWS Session Token - - - - - } - name={["credentials", "aws_session_token"]} - > - - - - AWS Role ARN - - - - - } - name={["credentials", "aws_role_name"]} - > - - - - AWS Session Name - - - - - } - name={["credentials", "aws_session_name"]} - > - - - - )} + {(field) => ( + (field)} + placeholder="https://your-mcp-server.com" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + )} - {/* Environment Variables Section */} -
- -
+ {/* OpenAPI Spec URL - only for OpenAPI transport */} + {isOpenAPITransport && ( + + OpenAPI Spec URL + + + + + } + name="spec_path" + required + rules={{ required: "Please enter an OpenAPI spec URL" }} + > + {(field) => ( + (field)} + placeholder="https://petstore3.swagger.io/api/v3/openapi.json" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + )} - {/* Permission Management / Access Control Section */} -
- -
+ + Max Concurrent Requests (optional) + + + + + } + name="max_concurrent_requests" + > + {(field) => ( + (field)} + min={1} + precision={0} + placeholder="e.g. 10" + style={{ width: "100%" }} + className="rounded-lg" + /> + )} + - {/* Tool Configuration Section */} -
- setHasToolAllowlistInteraction(true)} - toolNameToDisplayName={toolNameToDisplayName} - toolNameToDescription={toolNameToDescription} - onToolNameToDisplayNameChange={setToolNameToDisplayName} - onToolNameToDescriptionChange={setToolNameToDescription} - externalTools={tools} - externalIsLoading={isLoadingTools} - externalError={toolsError} - externalCanFetch={true} - /> -
+ {/* Authentication - for HTTP, SSE, and OpenAPI */} + {!isStdioTransport && ( + <> + + {(field) => ( + + )} + + + + + )} -
- Cancel - -
- + {isStdioTransport && ( +
+

+ Configure the stdio transport used to launch the MCP server process. You can either fill in the + fields below or paste a JSON configuration. +

+ + + {(field) => ( + (field)} + placeholder="e.g., npx" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + + + {(field) => ( + (field)} + placeholder="us-east-1 (leave blank to keep existing)" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + + AWS Service Name + + + + + } + name="credentials.aws_service_name" + > + {(field) => ( + (field)} + placeholder="bedrock-agentcore (leave blank to keep existing)" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + + AWS Access Key ID + + + + + } + name="credentials.aws_access_key_id" + > + {(field) => ( + (field)} + placeholder="Leave blank to keep existing" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + + AWS Secret Access Key + + + + + } + name="credentials.aws_secret_access_key" + > + {(field) => ( + (field)} + placeholder="Leave blank to keep existing" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + + AWS Session Token + + + + + } + name="credentials.aws_session_token" + > + {(field) => ( + (field)} + placeholder="Leave blank to keep existing" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + + AWS Role ARN + + + + + } + name="credentials.aws_role_name" + > + {(field) => ( + (field)} + placeholder="Leave blank to keep existing" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + + AWS Session Name + + + + + } + name="credentials.aws_session_name" + > + {(field) => ( + (field)} + placeholder="Leave blank to keep existing" + className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" + /> + )} + + + )} + + {/* Environment Variables Section */} +
+ +
+ + {/* Permission Management / Access Control Section */} +
+ +
+ + {/* Tool Configuration Section */} +
+ setHasToolAllowlistInteraction(true)} + toolNameToDisplayName={toolNameToDisplayName} + toolNameToDescription={toolNameToDescription} + onToolNameToDisplayNameChange={setToolNameToDisplayName} + onToolNameToDescriptionChange={setToolNameToDescription} + externalTools={tools} + externalIsLoading={isLoadingTools} + externalError={toolsError} + externalCanFetch={true} + /> +
+ +
+ Cancel + +
+ + + @@ -1186,7 +1299,7 @@ const MCPServerEdit: React.FC = ({
Cancel - +
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts index 7911c3eb800..4c137609579 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts @@ -4,6 +4,7 @@ import { expect } from "vitest"; export async function selectAntOption(labelText: string, optionText: string) { const label = screen.getByText(labelText); const select = + label.closest('[data-slot="field"]')?.querySelector(".ant-select") ?? label.closest(".ant-form-item")?.querySelector(".ant-select") ?? label.closest(".ant-collapse-item")?.querySelector(".ant-select") ?? label.closest("div")?.querySelector(".ant-select") ?? diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/utils.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/utils.tsx index 4738e1e8fba..98bd7c6821f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/utils.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/utils.tsx @@ -54,6 +54,18 @@ export const validateMCPServerName = (value: string) => { : Promise.resolve(); }; +export const antdValidator = async ( + validate: (value: string) => Promise, + value: unknown, +): Promise => { + try { + await validate(value as string); + return true; + } catch (reason) { + return reason instanceof Error ? reason.message : String(reason); + } +}; + export const TOOL_DISPLAY_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; export const validateToolDisplayName = (value: string) => { diff --git a/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.tsx b/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.tsx new file mode 100644 index 00000000000..4171e9306b2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/MountedFormField.test.tsx @@ -0,0 +1,232 @@ +import React from "react"; +import { render, screen, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import { useForm, type UseFormReturn } from "react-hook-form"; + +import { + MountedFormField, + MountedFormProvider, + applyFieldValues, + changedValuesFor, + projectMountedValues, + resetFieldsToDefaults, + useMountRegistry, + useMountedWatch, + type MountRegistry, + type MountedFormValues, +} from "./MountedFormField"; + +const harness: { + form?: UseFormReturn; + registry?: MountRegistry; +} = {}; + +interface HarnessProps { + readonly defaultValues: MountedFormValues; + readonly showGated?: boolean; + readonly showNested?: boolean; + readonly showRows?: number; + readonly duplicateGated?: boolean; +} + +const Watcher: React.FC = () => { + const gated = useMountedWatch("gated"); + const credentials = useMountedWatch("credentials"); + const rows = useMountedWatch("rows"); + return ( + <> +
{JSON.stringify(gated) ?? "undefined"}
+
{JSON.stringify(credentials) ?? "undefined"}
+
{JSON.stringify(rows) ?? "undefined"}
+ + ); +}; + +const Harness: React.FC = ({ + defaultValues, + showGated = true, + showNested = true, + showRows = 0, + duplicateGated = false, +}) => { + const form = useForm({ defaultValues }); + const registry = useMountRegistry(); + React.useEffect(() => { + harness.form = form; + harness.registry = registry; + }, [form, registry]); + return ( + + + + {(field) => ( + + )} + + {showGated && ( + + {(field) => ( + + )} + + )} + {duplicateGated && ( + + {(field) => ( + + )} + + )} + {showNested && ( + + {(field) => ( + + )} + + )} + {Array.from({ length: showRows }, (_, index) => ( + + {(field) => ( + + )} + + ))} + + ); +}; + +const DEFAULTS: MountedFormValues = { + always: "a", + gated: "seeded", + credentials: { client_id: "cid", access_token: "tok" }, + rows: [{ header: "h0" }, { header: "h1" }], + never_bound: "leaked", +}; + +describe("projectMountedValues", () => { + it("drops store keys that no field mounted, which is what keeps a spread payload out of the request", () => { + render(); + const projected = projectMountedValues(harness.registry!, harness.form!.getValues()); + expect(projected).not.toHaveProperty("never_bound"); + expect(harness.form!.getValues()).toHaveProperty("never_bound", "leaked"); + }); + + it("rebuilds a container from only its mounted descendants", () => { + render(); + const projected = projectMountedValues(harness.registry!, harness.form!.getValues()); + expect(projected.credentials).toStrictEqual({ client_id: "cid" }); + }); + + it("rebuilds an indexed path as an array, not an object", () => { + render(); + const projected = projectMountedValues(harness.registry!, harness.form!.getValues()); + expect(projected.rows).toStrictEqual([{ header: "h0" }, { header: "h1" }]); + }); + + it("keeps a name mounted while a second field still binds it, so a shared name is not dropped early", () => { + const { rerender } = render(); + rerender(); + expect(projectMountedValues(harness.registry!, harness.form!.getValues())).toHaveProperty("gated", "seeded"); + }); + + it("accepts a getValues function as well as a plain store", () => { + render(); + expect(projectMountedValues(harness.registry!, harness.form!.getValues)).toStrictEqual( + projectMountedValues(harness.registry!, harness.form!.getValues()), + ); + }); + + it("omits a gated field once it unmounts", () => { + const { rerender } = render(); + expect(projectMountedValues(harness.registry!, harness.form!.getValues())).toHaveProperty("gated"); + rerender(); + expect(projectMountedValues(harness.registry!, harness.form!.getValues())).not.toHaveProperty("gated"); + }); +}); + +describe("useMountedWatch", () => { + it("is undefined for a seeded field that never mounted, so a `watched ?? saved` fallback still reads the saved value", () => { + render(); + expect(screen.getByTestId("watch-gated")).toHaveTextContent("undefined"); + }); + + it("reports the live value while the field is mounted", async () => { + render(); + await userEvent.clear(screen.getByLabelText("gated")); + await userEvent.type(screen.getByLabelText("gated"), "typed"); + expect(screen.getByTestId("watch-gated")).toHaveTextContent('"typed"'); + }); + + it("goes back to undefined after the field unmounts even though the store keeps the value", async () => { + const { rerender } = render(); + await userEvent.clear(screen.getByLabelText("gated")); + await userEvent.type(screen.getByLabelText("gated"), "typed"); + rerender(); + expect(screen.getByTestId("watch-gated")).toHaveTextContent("undefined"); + expect(harness.form!.getValues("gated")).toBe("typed"); + }); + + it("narrows a container to its mounted descendants", () => { + render(); + expect(screen.getByTestId("watch-credentials")).toHaveTextContent('{"client_id":"cid"}'); + }); + + it("is undefined for a container whose descendants all unmounted", () => { + render(); + expect(screen.getByTestId("watch-credentials")).toHaveTextContent("undefined"); + }); +}); + +describe("applyFieldValues", () => { + it("deep-merges a partial object instead of replacing it", () => { + render(); + act(() => applyFieldValues(harness.form!, { credentials: { client_id: "next" } })); + expect(harness.form!.getValues("credentials")).toStrictEqual({ client_id: "next", access_token: "tok" }); + }); + + it("replaces arrays rather than merging them index by index", () => { + render(); + act(() => applyFieldValues(harness.form!, { rows: [{ header: "only" }] })); + expect(harness.form!.getValues("rows")).toStrictEqual([{ header: "only" }]); + }); + + it("clears a key when the patch carries undefined", () => { + render(); + act(() => applyFieldValues(harness.form!, { credentials: undefined })); + expect(harness.form!.getValues("credentials")).toBeUndefined(); + }); +}); + +describe("resetFieldsToDefaults", () => { + it("restores a container path that has no field registered under that exact name", () => { + render(); + act(() => harness.form!.setValue("credentials", { client_id: "dirty", minted: "token" })); + act(() => resetFieldsToDefaults(harness.form!, DEFAULTS, ["credentials"])); + expect(harness.form!.getValues("credentials")).toStrictEqual({ client_id: "cid", access_token: "tok" }); + }); + + it("restores a path whose field is not mounted at all", () => { + render(); + act(() => harness.form!.setValue("gated", "dirty")); + act(() => resetFieldsToDefaults(harness.form!, DEFAULTS, ["gated"])); + expect(harness.form!.getValues("gated")).toBe("seeded"); + }); +}); + +describe("changedValuesFor", () => { + it("nests a dotted path the way an antd onValuesChange payload is shaped", () => { + expect(changedValuesFor("credentials.client_id", { credentials: { client_id: "x", other: "y" } })).toStrictEqual({ + credentials: { client_id: "x" }, + }); + }); + + it("keeps a top-level key flat so `key in changedValues` still answers", () => { + expect("url" in changedValuesFor("url", { url: "https://example.com" })).toBe(true); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx new file mode 100644 index 00000000000..a2998b2c6ed --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx @@ -0,0 +1,275 @@ +"use client"; + +import * as React from "react"; +import { + Controller, + useFieldArray, + useWatch, + type Control, + type ControllerProps, + type RegisterOptions, + type UseFormGetValues, + type UseFormReturn, +} from "react-hook-form"; + +import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/shared/form/field"; + +export type MountedFormValues = Record; + +export interface MountRegistry { + readonly register: (name: string) => () => void; + readonly mountedNames: () => readonly string[]; + readonly subscribe: (listener: () => void) => () => void; + readonly version: () => number; +} + +export interface MountedFormContextValue { + readonly control: Control; + readonly registry: MountRegistry; +} + +const missingProvider = (): never => { + throw new Error("MountedFormField requires a MountedFormProvider ancestor"); +}; + +const MountedFormContext = React.createContext({ + get control(): Control { + return missingProvider(); + }, + registry: { + register: missingProvider, + mountedNames: missingProvider, + subscribe: missingProvider, + version: missingProvider, + }, +}); + +export const MountedFormProvider = MountedFormContext.Provider; + +export const useMountedFormContext = (): MountedFormContextValue => React.useContext(MountedFormContext); + +export const useMountRegistry = (): MountRegistry => { + const counts = React.useRef>(new Map()); + const listeners = React.useRef void>>(new Set()); + const version = React.useRef(0); + return React.useMemo(() => { + const bump = () => { + version.current += 1; + listeners.current.forEach((listener) => listener()); + }; + return { + register: (name: string) => { + const before = counts.current.get(name) ?? 0; + counts.current.set(name, before + 1); + if (before === 0) bump(); + return () => { + const remaining = (counts.current.get(name) ?? 0) - 1; + if (remaining > 0) { + counts.current.set(name, remaining); + return; + } + counts.current.delete(name); + bump(); + }; + }, + mountedNames: () => Array.from(counts.current.keys()), + subscribe: (listener: () => void) => { + listeners.current.add(listener); + return () => { + listeners.current.delete(listener); + }; + }, + version: () => version.current, + }; + }, []); +}; + +const isIndexSegment = (segment: string): boolean => /^\d+$/.test(segment); + +const readPath = (source: unknown, path: readonly string[]): unknown => + path.reduce( + (value, segment) => + value === null || value === undefined ? undefined : (value as Record)[segment], + source, + ); + +const cloneContainer = (target: unknown, head: string): Record | unknown[] => { + if (Array.isArray(target)) return [...target]; + if (target !== null && typeof target === "object") return { ...(target as Record) }; + return isIndexSegment(head) ? [] : {}; +}; + +const writePath = (target: unknown, path: readonly string[], value: unknown): unknown => { + const [head, ...rest] = path; + const container = cloneContainer(target, head); + const next = rest.length === 0 ? value : writePath(readPath(container, [head]), rest, value); + if (Array.isArray(container)) { + const copy = [...container]; + copy[Number(head)] = next; + return copy; + } + return { ...container, [head]: next }; +}; + +const collectPaths = (store: unknown, paths: readonly string[], seed: unknown): unknown => + paths.reduce((acc, path) => { + const segments = path.split("."); + return writePath(acc, segments, readPath(store, segments)); + }, seed); + +export const projectMountedValues = ( + registry: MountRegistry, + source: MountedFormValues | UseFormGetValues, +): MountedFormValues => + collectPaths(typeof source === "function" ? source() : source, registry.mountedNames(), {}) as MountedFormValues; + +export const changedValuesFor = (name: string, store: MountedFormValues): MountedFormValues => + collectPaths(store, [name], {}) as MountedFormValues; + +const projectSubtree = (mountedNames: readonly string[], name: string, subtree: unknown): unknown => { + if (mountedNames.includes(name)) { + return subtree; + } + const prefix = `${name}.`; + const relative = mountedNames + .filter((mounted) => mounted.startsWith(prefix)) + .map((mounted) => mounted.slice(prefix.length)); + return relative.length === 0 ? undefined : collectPaths(subtree, relative, undefined); +}; + +const useMountedNames = (registry: MountRegistry): readonly string[] => { + React.useSyncExternalStore(registry.subscribe, registry.version, registry.version); + return registry.mountedNames(); +}; + +export const useMountedWatch = (name: string, context?: MountedFormContextValue): unknown => { + const fallback = React.useContext(MountedFormContext); + const { control, registry } = context ?? fallback; + const mountedNames = useMountedNames(registry); + const subtree = useWatch({ control, name }); + return React.useMemo(() => projectSubtree(mountedNames, name, subtree), [mountedNames, name, subtree]); +}; + +const isPlainObject = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +const mergeValues = (target: unknown, patch: unknown): unknown => { + if (!isPlainObject(target) || !isPlainObject(patch)) { + return patch; + } + return Object.entries(patch).reduce>( + (acc, [key, value]) => ({ ...acc, [key]: mergeValues(target[key], value) }), + { ...target }, + ); +}; + +export const applyFieldValues = (form: UseFormReturn, patch: MountedFormValues): void => { + const current = form.getValues(); + Object.entries(patch).forEach(([key, value]) => { + form.setValue(key, mergeValues(current[key], value)); + }); +}; + +export const resetFieldsToDefaults = ( + form: UseFormReturn, + defaultValues: MountedFormValues, + names: readonly string[], +): void => { + names.forEach((name) => { + form.setValue(name, readPath(defaultValues, name.split("."))); + form.clearErrors(name); + }); +}; + +export type MountedFieldControlProps = { + readonly id: string; + readonly name: string; + readonly value: unknown; + readonly onChange: (...event: unknown[]) => void; + readonly onBlur: () => void; + readonly "aria-required": "true" | undefined; + readonly "aria-invalid": "true" | undefined; + readonly "aria-describedby": string | undefined; +}; + +export interface MountedFieldArray { + readonly fields: readonly { readonly id: string }[]; + readonly append: (value: MountedFormValues) => void; + readonly remove: (index: number) => void; +} + +export const useMountedFieldArray = (control: Control, name: string): MountedFieldArray => { + const { fields, append, remove } = useFieldArray({ control, name: name as never }); + return { fields, append: append as (value: MountedFormValues) => void, remove }; +}; + +export const bindControl = ( + control: MountedFieldControlProps, +): Omit & { + value: TValue; +} => ({ ...control, value: control.value as TValue }); + +export interface MountedFormFieldProps { + readonly name: string; + readonly label?: React.ReactNode; + readonly help?: React.ReactNode; + readonly required?: boolean; + readonly rules?: Omit< + RegisterOptions, + "valueAsNumber" | "valueAsDate" | "setValueAs" | "disabled" + >; + readonly defaultValue?: unknown; + readonly bare?: boolean; + readonly className?: string; + readonly children: (control: MountedFieldControlProps) => React.ReactNode; +} + +export const MountedFormField: React.FC = ({ + name, + label, + help, + required, + rules, + defaultValue, + bare, + className, + children, +}) => { + const { control, registry } = useMountedFormContext(); + React.useEffect(() => registry.register(name), [registry, name]); + + const helpId = `${name}_help`; + const hasHelp = help !== undefined && help !== null; + + const renderField: ControllerProps["render"] = ({ field, fieldState }) => { + const invalid = fieldState.error !== undefined; + const controlProps: MountedFieldControlProps = { + id: name, + name: field.name, + value: field.value, + onChange: field.onChange, + onBlur: field.onBlur, + "aria-required": required ? "true" : undefined, + "aria-invalid": invalid ? "true" : undefined, + "aria-describedby": hasHelp || invalid ? helpId : undefined, + }; + + if (bare) { + return <>{children(controlProps)}; + } + + return ( + + {label !== undefined && {label}} + {children(controlProps)} + {hasHelp ? ( + {help} + ) : ( + + )} + + ); + }; + + return ; +};