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 ; +};