From 8a8a2e1d94570362f9c6bf20096d80c76ec3c970 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:43:51 -0700 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=94=84=20fix:=20Per-Entry=20Reset=20t?= =?UTF-8?q?o=20YAML=20for=20MCP=20Servers=20&=20Custom=20Endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YAML-defined MCP servers and custom endpoints edited in the admin panel accumulate DB overrides that could never be cleared. The delete affordance was hidden (MCP) or silently ineffective (custom endpoints, where LibreChat merges the override array into the YAML array by name and re-adds unmatched YAML items), leaving entries permanently shadowed even after librechat.yaml changed. Each YAML-defined entry with stored overrides now shows a reset action that, after confirmation, clears its override layer: MCP servers unset the mcpServers. subtree in the base override document; custom endpoints rewrite the endpoints.custom override array without the name-matched item, or unset the array path when it was the only override. The rewrite is built from a freshly fetched override document rather than the client cache, so a concurrent admin's change to the array is never discarded by a stale read-modify-write; an item already removed resolves as a no-op. The action is base-mode only and locked while unsaved edits are pending. Scope-profile overrides are untouched. Custom endpoints defined in YAML also hide the trash button (deletion is impossible at the merge layer and previously reappeared on reload) and lock the name field, since name is the merge identity and renaming orphans the override while duplicating the entry. Fixes #73 Fixes #108 --- src/components/configuration/ConfigPage.tsx | 77 ++++++++ .../configuration/ConfigTabContent.tsx | 4 + .../configuration/ResetOverridesDialog.tsx | 62 ++++++ .../configuration/fields/ArrayObjectField.tsx | 39 ++-- .../configuration/fields/ObjectEntryCard.tsx | 21 +- .../sections/EndpointsRenderer.tsx | 100 +++++++++- .../sections/McpServersRenderer.tsx | 39 ++++ .../__tests__/EndpointsRenderer.test.tsx | 162 ++++++++++++++- .../__tests__/McpServersRenderer.test.tsx | 89 ++++++++- src/components/configuration/utils.test.ts | 186 +++++++++++++++++- src/components/configuration/utils.ts | 77 ++++++++ src/locales/en/translation.json | 5 + src/server/config.ts | 37 ++++ src/types/config-ui.ts | 38 ++++ src/types/fields.ts | 16 ++ 15 files changed, 924 insertions(+), 28 deletions(-) create mode 100644 src/components/configuration/ResetOverridesDialog.tsx diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 6d30330d..2876c055 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -10,6 +10,7 @@ import { tombstoneFieldProfileValueFn, bulkSaveProfileValuesFn, getBatchFieldProfilesFn, + getBaseConfigOverridesFn, availableScopesOptions, resetBaseConfigFieldFn, getResolvedConfigFn, @@ -38,12 +39,14 @@ import { buildSavePayload, mergeIndexedArrayEdits, partitionScopeResetPaths, + executeEntryOverridesReset, } from './utils'; import { validateMcpCrossField } from './sections/McpServersRenderer'; import { ScopeSelector, ScopeTriggerButton } from './ScopeSelector'; import { StickyActionBar } from '@/components/shared'; import { ConfigTableOfContents } from './ConfigTableOfContents'; import { ResetBaseConfigDialog } from './ResetBaseConfigDialog'; +import { ResetOverridesDialog } from './ResetOverridesDialog'; import { ConfirmSaveDialog } from './ConfirmSaveDialog'; import { ConfigTabContent } from './ConfigTabContent'; import { ImportYamlDialog } from './ImportYamlDialog'; @@ -152,9 +155,38 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi if (yamlMcpKeys && Array.isArray(yamlMcpKeys)) { result.mcpServers = new Set(yamlMcpKeys); } + const yamlCustomEndpointKeys = baseConfigData?.yamlCustomEndpointKeys; + if (yamlCustomEndpointKeys && Array.isArray(yamlCustomEndpointKeys)) { + result.endpoints = new Set(yamlCustomEndpointKeys); + } return result; }, [baseConfigData]); + /** Entry identities stored in the base override document, so the per-entry "reset to YAML" affordance only appears where overrides actually exist. */ + const dbOverrideKeys = useMemo(() => { + const result: Record> = {}; + if (!dbOverrides) return result; + const mcp = dbOverrides.mcpServers; + if (mcp && typeof mcp === 'object' && !Array.isArray(mcp)) { + result.mcpServers = new Set(Object.keys(mcp as Record)); + } + const endpoints = dbOverrides.endpoints; + const custom = + endpoints && typeof endpoints === 'object' && !Array.isArray(endpoints) + ? (endpoints as Record).custom + : undefined; + if (Array.isArray(custom)) { + const names = new Set(); + for (const item of custom) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue; + const name = (item as Record).name; + if (typeof name === 'string' && name) names.add(name); + } + result.endpoints = names; + } + return result; + }, [dbOverrides]); + const hasUnmappedSections = useMemo( () => schemaTree.some( @@ -536,6 +568,37 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi }); }, []); + const [entryResetTarget, setEntryResetTarget] = useState(null); + const [entryResetting, setEntryResetting] = useState(false); + const [entryResetError, setEntryResetError] = useState(null); + + const handleEntryOverridesReset = useCallback((target: t.EntryResetTarget) => { + setEntryResetError(null); + setEntryResetTarget(target); + }, []); + + const handleEntryResetConfirm = useCallback(async () => { + if (!entryResetTarget || entryResetting) return; + setEntryResetting(true); + setEntryResetError(null); + try { + await executeEntryOverridesReset(entryResetTarget, schemaPathSet, { + fetchOverrides: () => getBaseConfigOverridesFn().then((r) => r.overrides), + resetField: (fieldPath) => resetBaseConfigFieldFn({ data: { fieldPath } }), + saveEntries: (entries) => saveBaseConfigFn({ data: { entries } }), + }); + await queryClient.invalidateQueries({ queryKey: ['baseConfig'] }); + setEntryResetting(false); + setEntryResetTarget(null); + notifySuccess(localize('com_config_reset_entry_success', { name: entryResetTarget.label })); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setEntryResetting(false); + setEntryResetError(message); + notifyError(message); + } + }, [entryResetTarget, entryResetting, schemaPathSet, queryClient, localize]); + const handleConfirmSave = useCallback(async () => { if (saving) return; const { touched, saves, resets } = buildSavePayload(touchedPaths, editedValues, schemaPathSet); @@ -1006,6 +1069,8 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi showConfiguredOnly={showConfiguredOnly} isEditingScope={isEditingScope} baseRecordKeys={baseRecordKeys} + dbOverrideKeys={isEditingScope ? undefined : dbOverrideKeys} + onResetEntryOverrides={isEditingScope ? undefined : handleEntryOverridesReset} onValidationError={(message) => notifyError(message)} editSessionId={editSessionId} /> @@ -1068,6 +1133,18 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi setResetBaseError(null); }} /> + + { + if (entryResetting) return; + setEntryResetTarget(null); + setEntryResetError(null); + }} + /> ); } diff --git a/src/components/configuration/ConfigTabContent.tsx b/src/components/configuration/ConfigTabContent.tsx index fa0fcd7e..13940af7 100644 --- a/src/components/configuration/ConfigTabContent.tsx +++ b/src/components/configuration/ConfigTabContent.tsx @@ -57,6 +57,8 @@ export function ConfigTabContent({ showConfiguredOnly, isEditingScope, baseRecordKeys, + dbOverrideKeys, + onResetEntryOverrides, onValidationError, editSessionId, }: t.ConfigTabContentProps) { @@ -188,6 +190,8 @@ export function ConfigTabContent({ showConfiguredOnly, isEditingScope, yamlBaseKeys: baseRecordKeys?.[dataKey], + dbOverrideKeys: dbOverrideKeys?.[dataKey], + onResetEntryOverrides, onValidationError, editSessionId, }; diff --git a/src/components/configuration/ResetOverridesDialog.tsx b/src/components/configuration/ResetOverridesDialog.tsx new file mode 100644 index 00000000..7198cad9 --- /dev/null +++ b/src/components/configuration/ResetOverridesDialog.tsx @@ -0,0 +1,62 @@ +import { Button, Dialog } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { useLocalize } from '@/hooks'; + +export function ResetOverridesDialog({ + target, + resetting, + error, + onConfirm, + onCancel, +}: t.ResetOverridesDialogProps) { + const localize = useLocalize(); + + return ( + { + if (!isOpen) onCancel(); + }} + > + +
+

+ {localize('com_config_reset_entry_confirm', { name: target?.label ?? '' })} +

+ {error && ( +
+ {error} +
+ )} +
+
+
+
+
+ ); +} diff --git a/src/components/configuration/fields/ArrayObjectField.tsx b/src/components/configuration/fields/ArrayObjectField.tsx index 52b807cb..fd3cb11b 100644 --- a/src/components/configuration/fields/ArrayObjectField.tsx +++ b/src/components/configuration/fields/ArrayObjectField.tsx @@ -25,6 +25,7 @@ export function ArrayObjectField({ addTriggerRef, renderFields, entryIdPrefix, + entryControls, editSessionId, }: t.ArrayObjectFieldProps) { const localize = useLocalize(); @@ -104,21 +105,29 @@ export function ArrayObjectField({ onClick={handleAdd} /> )} - {items.map((item, index) => ( - handleEntryChange(index, v)} - onRemove={disabled ? undefined : () => handleRemove(index)} - disabled={disabled} - defaultExpanded={keys[index] === expandedKeyRef.current} - renderFields={renderFields} - editSessionId={editSessionId} - /> - ))} + {items.map((item, index) => { + const controls = entryControls?.(index, item); + return ( + handleEntryChange(index, v)} + onRemove={ + disabled || controls?.canRemove === false ? undefined : () => handleRemove(index) + } + resetOverrides={controls?.resetOverrides} + disabled={disabled} + defaultExpanded={keys[index] === expandedKeyRef.current} + renderFields={renderFields} + editSessionId={editSessionId} + /> + ); + })} {items.length === 0 && !hideAddButton && (

{localize('com_config_no_entries')} diff --git a/src/components/configuration/fields/ObjectEntryCard.tsx b/src/components/configuration/fields/ObjectEntryCard.tsx index 4e4a3121..aae99154 100644 --- a/src/components/configuration/fields/ObjectEntryCard.tsx +++ b/src/components/configuration/fields/ObjectEntryCard.tsx @@ -1,4 +1,4 @@ -import { Icon } from '@clickhouse/click-ui'; +import { Icon, IconButton } from '@clickhouse/click-ui'; import { useState, useCallback, useRef, useEffect } from 'react'; import type * as t from '@/types'; import { TrashButton } from '@/components/shared'; @@ -14,6 +14,7 @@ export function ObjectEntryCard({ onValueChange, onRemove, onRename, + resetOverrides, disabled, defaultExpanded = false, renderFields, @@ -191,6 +192,24 @@ export function ObjectEntryCard({ {localize('com_config_add_field')} )} + {!disabled && resetOverrides && ( + e.stopPropagation()} + title={resetOverrides.title} + > + { + e.preventDefault(); + resetOverrides.onClick(); + }} + disabled={resetOverrides.disabled} + aria-label={localize('com_a11y_reset_entry_overrides', { name: entryKey })} + /> + + )} {!disabled && onRemove && ( = { */ const REQUIRED_ENDPOINT_KEYS = new Set(['name', 'apiKey', 'baseURL']); +/** + * LibreChat merges the `endpoints.custom` override array into the YAML array + * by item `name`, so `name` is the entry's identity across config layers. + * Renaming a YAML-defined endpoint can never work: the renamed override item + * no longer matches its YAML counterpart, which survives unchanged while the + * override is appended as a duplicate entry. + */ +const YAML_LOCKED_ENDPOINT_FIELDS = new Set(['name']); + function withRequired(field: t.SchemaField): t.SchemaField { if (REQUIRED_ENDPOINT_KEYS.has(field.key)) { return { ...field, isOptional: false }; @@ -129,6 +138,7 @@ function flattenGroupFields( disabled?: boolean, collectionRenderOverrides?: Record, editSessionId?: number, + lockedKeys?: Set, ): ReactNode[] { const values = typeof parentValue === 'object' && parentValue !== null && !Array.isArray(parentValue) @@ -137,6 +147,7 @@ function flattenGroupFields( const nodes: ReactNode[] = []; for (const field of fields) { + const fieldDisabled = disabled || (lockedKeys?.has(field.key) ?? false); if (field.children && field.children.length > 0 && !field.isArray && field.type !== 'record') { const nested = values[field.key]; const nestedObj = @@ -153,7 +164,7 @@ function flattenGroupFields( onChange(field.key, { ...nestedObj, [childKey]: childValue }); }, localize, - disabled, + fieldDisabled, collectionRenderOverrides, true, editSessionId, @@ -168,7 +179,7 @@ function flattenGroupFields( parentPath, onChange, localize, - disabled, + fieldDisabled, collectionRenderOverrides, true, editSessionId, @@ -189,6 +200,7 @@ function FieldGroup({ defaultExpanded, collectionRenderOverrides, editSessionId, + lockedKeys, }: { labelKey: string; fields: t.SchemaField[]; @@ -199,6 +211,7 @@ function FieldGroup({ defaultExpanded: boolean; collectionRenderOverrides?: Record; editSessionId?: number; + lockedKeys?: Set; }) { const localize = useLocalize(); const { isExpanded, hasEverExpanded, sectionRef, toggle } = useCollapsibleSection({ @@ -242,6 +255,7 @@ function FieldGroup({ disabled, collectionRenderOverrides, editSessionId, + lockedKeys, )} , )} @@ -258,6 +272,7 @@ function GroupedFieldRenderer({ disabled, collectionRenderOverrides, editSessionId, + lockedKeys, }: { groupKey: string; fields: t.SchemaField[]; @@ -267,6 +282,7 @@ function GroupedFieldRenderer({ disabled?: boolean; collectionRenderOverrides?: Record; editSessionId?: number; + lockedKeys?: Set; }) { const groups = FIELD_GROUPS[groupKey]; if (!groups) return null; @@ -293,6 +309,7 @@ function GroupedFieldRenderer({ defaultExpanded={group.defaultExpanded} collectionRenderOverrides={collectionRenderOverrides} editSessionId={editSessionId} + lockedKeys={lockedKeys} /> ); })} @@ -307,6 +324,7 @@ function GroupedFieldRenderer({ defaultExpanded={false} collectionRenderOverrides={collectionRenderOverrides} editSessionId={editSessionId} + lockedKeys={lockedKeys} /> )} @@ -466,7 +484,10 @@ const COLLECTION_RENDER_OVERRIDES: Record = { * Matches the `CollectionRenderFields` signature so it can be injected * into `ArrayObjectField` and `ObjectEntryCard`. */ -function makeGroupedEndpointFields(disabled?: boolean): t.CollectionRenderFields { +function makeGroupedEndpointFields( + disabled?: boolean, + isEntryNameLocked?: (entry: t.ConfigValue) => boolean, +): t.CollectionRenderFields { return (fields, parentValue, parentPath, onChange, _addFieldTriggerRef, editSessionId) => ( ); } @@ -611,12 +633,73 @@ function ProviderSection({ // --------------------------------------------------------------------------- export function CustomEndpointsRenderer(props: t.FieldRendererProps) { - const { fields, parentPath, parentValue, getValue, onChange, disabled, editSessionId } = props; + const { + fields, + parentPath, + parentValue, + getValue, + onChange, + disabled, + editedValues, + yamlBaseKeys, + dbOverrideKeys, + isEditingScope, + onResetEntryOverrides, + editSessionId, + } = props; const localize = useLocalize(); const [createOpen, setCreateOpen] = useState(false); + + /** YAML identity locks apply in base mode only; scope overrides layer on top of the resolved base and keep their own affordances. */ + const yamlNames = useMemo( + () => (isEditingScope ? new Set() : (yamlBaseKeys ?? new Set())), + [isEditingScope, yamlBaseKeys], + ); + + const isEntryNameLocked = useCallback( + (entry: t.ConfigValue) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false; + const name = (entry as Record).name; + return typeof name === 'string' && yamlNames.has(name); + }, + [yamlNames], + ); + const renderGroupedEndpointFields = useMemo( - () => makeGroupedEndpointFields(disabled), - [disabled], + () => makeGroupedEndpointFields(disabled, isEntryNameLocked), + [disabled, isEntryNameLocked], + ); + + const renderCreateEndpointFields = useMemo(() => makeGroupedEndpointFields(disabled), [disabled]); + + const hasPendingEdits = !!editedValues && Object.keys(editedValues).length > 0; + + const entryControls = useCallback( + (_index: number, item: t.ConfigValue): t.EntryCardControls => { + const obj = + item && typeof item === 'object' && !Array.isArray(item) + ? (item as Record) + : {}; + const name = typeof obj.name === 'string' ? obj.name : ''; + const isYamlSource = name !== '' && yamlNames.has(name); + if (!isYamlSource) return {}; + /** YAML-defined endpoints cannot be deleted from the admin panel: the merge preserves unmatched YAML items, so the entry would reappear on the next load. Offer clearing the override layer instead. */ + const resetOverrides: t.EntryResetAction | undefined = + onResetEntryOverrides && dbOverrideKeys?.has(name) + ? { + onClick: () => + onResetEntryOverrides({ + fieldPath: `${parentPath}.custom`, + itemName: name, + label: name, + }), + disabled: hasPendingEdits, + title: hasPendingEdits ? localize('com_config_reset_base_dirty') : undefined, + } + : undefined; + return { canRemove: false, resetOverrides }; + }, + [yamlNames, dbOverrideKeys, onResetEntryOverrides, parentPath, hasPendingEdits, localize], ); const customField = fields.find((f) => f.key === 'custom'); @@ -661,6 +744,7 @@ export function CustomEndpointsRenderer(props: t.FieldRendererProps) { hideAddButton renderFields={renderGroupedEndpointFields} entryIdPrefix={`section-${path.split('.')[0]}-custom`} + entryControls={entryControls} editSessionId={editSessionId} /> )} @@ -669,7 +753,7 @@ export function CustomEndpointsRenderer(props: t.FieldRendererProps) { onClose={() => setCreateOpen(false)} onSave={handleCreate} fields={customField.children ?? []} - renderFields={renderGroupedEndpointFields} + renderFields={renderCreateEndpointFields} /> ); diff --git a/src/components/configuration/sections/McpServersRenderer.tsx b/src/components/configuration/sections/McpServersRenderer.tsx index 5eb0f985..43a874d9 100644 --- a/src/components/configuration/sections/McpServersRenderer.tsx +++ b/src/components/configuration/sections/McpServersRenderer.tsx @@ -662,7 +662,9 @@ export function McpServersRenderer(props: t.FieldRendererProps) { disabled, editedValues, yamlBaseKeys, + dbOverrideKeys, isEditingScope, + onResetEntryOverrides, onValidationError, } = props; const localize = useLocalize(); @@ -786,6 +788,21 @@ export function McpServersRenderer(props: t.FieldRendererProps) { localizeRef.current = localize; }, [localize]); + const onResetEntryOverridesRef = useRef(onResetEntryOverrides); + useEffect(() => { + onResetEntryOverridesRef.current = onResetEntryOverrides; + }, [onResetEntryOverrides]); + + const handleResetOverrides = useCallback( + (key: string) => { + onResetEntryOverridesRef.current?.({ fieldPath: `${path}.${key}`, label: key }); + }, + [path], + ); + + /** Immediate reset would interleave with staged edits, so it stays locked until they are saved or discarded (same rule as the global reset button). */ + const hasPendingEdits = !!editedValues && Object.keys(editedValues).length > 0; + const handleCreate = useCallback( (serverName: string, entry: Record) => { if (serverName.includes('.')) { @@ -942,9 +959,14 @@ export function McpServersRenderer(props: t.FieldRendererProps) { disabled={disabled} isEditingScope={!!isEditingScope} isYamlSource={yamlSourceKeys.has(key)} + canResetOverrides={ + !isEditingScope && yamlSourceKeys.has(key) && (dbOverrideKeys?.has(key) ?? false) + } + resetDisabled={hasPendingEdits} onChange={onChange} onRemove={handleRemove} onRename={handleRename} + onResetOverrides={onResetEntryOverrides ? handleResetOverrides : undefined} justAdded={key === justAddedKey} /> ))} @@ -981,9 +1003,12 @@ const McpEntryRow = memo(function McpEntryRowImpl({ disabled, isEditingScope, isYamlSource, + canResetOverrides, + resetDisabled, onChange, onRemove, onRename, + onResetOverrides, justAdded, }: { entryKey: string; @@ -993,11 +1018,15 @@ const McpEntryRow = memo(function McpEntryRowImpl({ disabled?: boolean; isEditingScope: boolean; isYamlSource: boolean; + canResetOverrides: boolean; + resetDisabled: boolean; onChange: (path: string, value: t.ConfigValue) => void; onRemove: (key: string) => void; onRename: (oldKey: string, newKey: string) => void; + onResetOverrides?: (key: string) => void; justAdded: boolean; }) { + const localize = useLocalize(); const entryObj = isPlainObject(entryValue) ? entryValue : {}; const rawType = typeof entryObj.type === 'string' ? entryObj.type : ''; const inferred = rawType || inferTransportType(entryObj); @@ -1043,6 +1072,15 @@ const McpEntryRow = memo(function McpEntryRowImpl({ [onChange, entryPathBase, isDottedLegacy], ); + const resetOverrides: t.EntryResetAction | undefined = + !isReadOnly && canResetOverrides && onResetOverrides + ? { + onClick: () => onResetOverrides(entryKey), + disabled: resetDisabled, + title: resetDisabled ? localize('com_config_reset_base_dirty') : undefined, + } + : undefined; + return ( onRename(entryKey, renamed) } + resetOverrides={resetOverrides} disabled={isReadOnly} defaultExpanded={justAdded} renderFields={renderEntryFields} diff --git a/src/components/configuration/sections/__tests__/EndpointsRenderer.test.tsx b/src/components/configuration/sections/__tests__/EndpointsRenderer.test.tsx index 76d63adf..4c796fd4 100644 --- a/src/components/configuration/sections/__tests__/EndpointsRenderer.test.tsx +++ b/src/components/configuration/sections/__tests__/EndpointsRenderer.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import type * as t from '@/types'; -import { ProvidersRenderer } from '../EndpointsRenderer'; +import { ProvidersRenderer, CustomEndpointsRenderer } from '../EndpointsRenderer'; import { createField } from '@/test/fixtures'; vi.mock('@/hooks/useLocalize', () => ({ @@ -17,6 +17,13 @@ interface MockTextFieldProps { 'aria-label'?: string; } +interface MockIconButtonProps { + icon: string; + onClick?: () => void; + disabled?: boolean; + 'aria-label'?: string; +} + vi.mock('@clickhouse/click-ui', () => ({ Icon: () => , MultiAccordion: Object.assign( @@ -25,6 +32,14 @@ vi.mock('@clickhouse/click-ui', () => ({ Item: ({ children }: { children: React.ReactNode }) =>

{children}
, }, ), + IconButton: ({ icon, onClick, disabled, ...rest }: MockIconButtonProps) => ( +