From b522a1184c2d8b1883deb8b917f2a1b5222c6495 Mon Sep 17 00:00:00 2001
From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com>
Date: Sun, 9 Aug 2026 17:36:16 -0700
Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Keep=20KV=20Record=20?=
=?UTF-8?q?Edits=20Object-Typed=20so=20Headers=20Never=20Save=20as=20Array?=
=?UTF-8?q?s?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Emptying a KeyValueField (removing the last header, env, or addParams row) stored a bare [] in edit state. serializeKVPairs cannot recognize an empty array as KV pairs, so the [] reached the backend as an array where the schema expects a record, failing base-config validation with "Expected object, received array" and persisting invalid values on the unvalidated profile-value route.
Record-control edits now collapse an emptied pair list to {} via a shared kvPairsEditValue helper (FieldRenderer record branches and ProfileValueModal), getDefaultValue returns {} for record fields so an untouched profile-value save stays object-typed, and FieldProfilePopover serializes modal values with deepSerializeKVPairs so no nested pairs shape can leak through the one save route that bypasses buildSavePayload.
Adds regression tests proving MCP header edits serialize to a plain record in the save payload (issue #56 repro) and that emptied KV lists emit {} rather than [].
---
.../configuration/FieldProfilePopover.tsx | 6 +-
.../configuration/FieldRenderer.test.tsx | 16 ++++
.../configuration/FieldRenderer.tsx | 7 +-
.../configuration/ProfileValueModal.test.tsx | 85 +++++++++++++++++++
.../configuration/ProfileValueModal.tsx | 6 +-
.../__tests__/McpServersRenderer.test.tsx | 58 +++++++++++++
src/components/configuration/utils.test.ts | 48 +++++++++++
src/components/configuration/utils.ts | 11 +++
8 files changed, 228 insertions(+), 9 deletions(-)
create mode 100644 src/components/configuration/ProfileValueModal.test.tsx
diff --git a/src/components/configuration/FieldProfilePopover.tsx b/src/components/configuration/FieldProfilePopover.tsx
index f495d964..7f2e589e 100644
--- a/src/components/configuration/FieldProfilePopover.tsx
+++ b/src/components/configuration/FieldProfilePopover.tsx
@@ -6,9 +6,9 @@ import { ProfileValueModal, getDefaultValue } from './ProfileValueModal';
import { DeleteProfileValueModal } from './DeleteProfileValueModal';
import { EditButton, TrashButton } from '@/components/shared';
import { useProfileMutations, useLocalize } from '@/hooks';
+import { deepSerializeKVPairs, cn } from '@/utils';
import { availableScopesOptions } from '@/server';
import { getScopeTypeConfig } from '@/constants';
-import { serializeKVPairs, cn } from '@/utils';
import { getControlType } from './utils';
export function FieldProfilePopover({
@@ -71,7 +71,7 @@ export function FieldProfilePopover({
const handleModalSave = useCallback(() => {
if (modalIsBase && onBaseValueChange) {
- onBaseValueChange(serializeKVPairs(modalValue));
+ onBaseValueChange(deepSerializeKVPairs(modalValue));
setModalOpen(false);
setModalIsBase(false);
return;
@@ -81,7 +81,7 @@ export function FieldProfilePopover({
{
principalType: modalScope.principalType,
principalId: modalScope.principalId,
- value: serializeKVPairs(modalValue),
+ value: deepSerializeKVPairs(modalValue),
},
{
onSuccess: () => {
diff --git a/src/components/configuration/FieldRenderer.test.tsx b/src/components/configuration/FieldRenderer.test.tsx
index 8a23a885..c04123ba 100644
--- a/src/components/configuration/FieldRenderer.test.tsx
+++ b/src/components/configuration/FieldRenderer.test.tsx
@@ -215,6 +215,22 @@ describe('SingleFieldRenderer', () => {
expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument();
expect(screen.getByDisplayValue('Bearer token')).toBeInTheDocument();
});
+
+ it('emits an empty record, not an empty array, when the last key-value row is removed', () => {
+ const field = createField({ key: 'headers', type: 'record' });
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'com_ui_delete com_ui_entry 1' }));
+ expect(onChange).toHaveBeenCalledWith('section.headers', {});
+ });
});
describe('FieldRenderer with imported config values', () => {
diff --git a/src/components/configuration/FieldRenderer.tsx b/src/components/configuration/FieldRenderer.tsx
index bc1e6d0b..ac07de07 100644
--- a/src/components/configuration/FieldRenderer.tsx
+++ b/src/components/configuration/FieldRenderer.tsx
@@ -10,6 +10,7 @@ import {
getControlType,
getEnumOptions,
hasDescendant,
+ kvPairsEditValue,
toKVPair,
isStringLikeItemType,
splitUnionTypes,
@@ -23,6 +24,7 @@ import { ListRecordField } from './fields/ListRecordField';
import { renderCollapsible } from './renderCollapsible';
import { TextareaField } from './fields/TextareaField';
import { KeyValueField } from './fields/KeyValueField';
+import { cn, getSecretPreviewValue } from '@/utils';
import { NumberField } from './fields/NumberField';
import { SecretField } from './fields/SecretField';
import { ToggleField } from './fields/ToggleField';
@@ -32,7 +34,6 @@ import { ListField } from './fields/ListField';
import { CodeField } from './fields/CodeField';
import { ConfigRow } from './ConfigRow';
import { useLocalize } from '@/hooks';
-import { cn, getSecretPreviewValue } from '@/utils';
function formatDefault(value: t.ConfigValue): string | null {
if (value === undefined || value === null) return null;
@@ -477,7 +478,7 @@ export function SingleFieldRenderer({
onChange(path, newPairs)}
+ onChange={(newPairs) => onChange(path, kvPairsEditValue(newPairs))}
disabled={disabled}
valueTypes={field.recordValueKVTypes}
aria-label={fieldLabel}
@@ -1221,7 +1222,7 @@ export function renderInlineField(
onChange(field.key, p)}
+ onChange={(p) => onChange(field.key, kvPairsEditValue(p))}
disabled={disabled}
valueTypes={field.recordValueKVTypes}
aria-label={fieldLabel}
diff --git a/src/components/configuration/ProfileValueModal.test.tsx b/src/components/configuration/ProfileValueModal.test.tsx
new file mode 100644
index 00000000..8d6a7685
--- /dev/null
+++ b/src/components/configuration/ProfileValueModal.test.tsx
@@ -0,0 +1,85 @@
+import { vi, describe, it, expect } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import type * as t from '@/types';
+import { ProfileValueModal, getDefaultValue } from './ProfileValueModal';
+import { createField } from '@/test/fixtures';
+
+vi.mock('@/hooks/useLocalize', () => ({
+ default: () => (key: string) => key,
+ useLocalize: () => (key: string) => key,
+}));
+
+interface ChildrenProps {
+ children?: React.ReactNode;
+}
+interface ButtonProps {
+ label?: string;
+ onClick?: () => void;
+}
+interface IconButtonProps {
+ icon: string;
+ onClick?: () => void;
+ 'aria-label'?: string;
+}
+
+vi.mock('@clickhouse/click-ui', () => ({
+ Icon: () => null,
+ Button: ({ label, onClick }: ButtonProps) => ,
+ IconButton: ({ icon, onClick, ...props }: IconButtonProps) => (
+
+ ),
+ Select: Object.assign(({ children }: ChildrenProps) => {children}
, {
+ Item: ({ children }: ChildrenProps) => {children}
,
+ }),
+ Dialog: Object.assign(({ children }: ChildrenProps) => {children}
, {
+ Content: ({ children }: ChildrenProps) => {children}
,
+ }),
+}));
+
+describe('getDefaultValue', () => {
+ it('returns an empty record for record fields so an untouched save stays object-typed', () => {
+ expect(getDefaultValue('record')).toEqual({});
+ });
+
+ it('returns an empty array for array fields', () => {
+ expect(getDefaultValue('array')).toEqual([]);
+ });
+});
+
+describe('ProfileValueModal — record fields', () => {
+ function renderModal(value: t.ConfigValue, onChange: (v: t.ConfigValue) => void) {
+ return render(
+ {}}
+ onCancel={() => {}}
+ saving={false}
+ scopeName="Base configuration"
+ scopeType="BASE"
+ mode="edit"
+ />,
+ );
+ }
+
+ it('emits an empty record, not an empty array, when the last row is removed', () => {
+ const onChange = vi.fn();
+ renderModal({ Authorization: 'Bearer token' }, onChange);
+ fireEvent.click(screen.getByRole('button', { name: 'com_ui_delete com_ui_entry 1' }));
+ expect(onChange).toHaveBeenCalledWith({});
+ });
+
+ it('keeps in-progress rows as raw pairs while editing', () => {
+ const onChange = vi.fn();
+ renderModal({ Authorization: 'Bearer token' }, onChange);
+ const valueInput = screen.getByLabelText('com_ui_value 1');
+ fireEvent.change(valueInput, { target: { value: 'Bearer {{API_KEY}}' } });
+ fireEvent.blur(valueInput);
+ expect(onChange).toHaveBeenCalledWith([
+ { key: 'Authorization', value: 'Bearer {{API_KEY}}', valueType: 'string' },
+ ]);
+ });
+});
diff --git a/src/components/configuration/ProfileValueModal.tsx b/src/components/configuration/ProfileValueModal.tsx
index 560bbf65..0f8f4c6a 100644
--- a/src/components/configuration/ProfileValueModal.tsx
+++ b/src/components/configuration/ProfileValueModal.tsx
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { PrincipalType } from 'librechat-data-provider';
import { Icon, Button, Dialog } from '@clickhouse/click-ui';
import type * as t from '@/types';
-import { getEnumOptions, getArrayItemType, toKVPair } from './utils';
+import { getEnumOptions, getArrayItemType, kvPairsEditValue, toKVPair } from './utils';
import { KeyValueField } from './fields/KeyValueField';
import { TrashButton } from '@/components/shared';
import { getScopeTypeConfig } from '@/constants';
@@ -208,7 +208,7 @@ function ModalValueControl({
onChange(kvPairsEditValue(p))}
aria-label={localize('com_ui_value')}
/>
);
@@ -318,7 +318,7 @@ export function getDefaultValue(controlType: string, fieldSchema?: t.SchemaField
return opts.length > 0 ? opts[0].value : '';
}
if (controlType === 'array') return [];
- if (controlType === 'record') return [];
+ if (controlType === 'record') return {};
if (controlType === 'object' || controlType === 'code') return {};
return '';
}
diff --git a/src/components/configuration/sections/__tests__/McpServersRenderer.test.tsx b/src/components/configuration/sections/__tests__/McpServersRenderer.test.tsx
index 4fd36638..c3f96915 100644
--- a/src/components/configuration/sections/__tests__/McpServersRenderer.test.tsx
+++ b/src/components/configuration/sections/__tests__/McpServersRenderer.test.tsx
@@ -8,6 +8,7 @@ import {
enumerateLeafPaths,
validateMcpCrossField,
} from '../McpServersRenderer';
+import { applyConfigEdit, buildSavePayload } from '../../utils';
import { createField } from '@/test/fixtures';
vi.mock('@/hooks/useLocalize', () => ({
@@ -1058,3 +1059,60 @@ describe('McpServersRenderer — create then edit then rename preserves nested d
expect(wholeHeadersWrite).toBeUndefined();
});
});
+
+describe('McpServersRenderer — header KV pairs never leak into the save payload (issue #56)', () => {
+ function renderWithHeaders(baseRecord: Record) {
+ const onChange = vi.fn();
+ const fields = [
+ ...fieldsForMcp(),
+ createField({ key: 'headers', type: 'record', isOptional: true }),
+ ];
+ const props: t.FieldRendererProps = {
+ fields,
+ parentValue: baseRecord,
+ parentPath: 'mcpServers',
+ getValue: (path, fallback) => (path === 'mcpServers' ? baseRecord : fallback),
+ onChange,
+ editedValues: {},
+ };
+ return { ...render(), onChange };
+ }
+
+ const baseRecord = {
+ srv: {
+ type: 'streamable-http',
+ url: 'https://example.com/api/mcp',
+ headers: { Authorization: 'Bearer old' },
+ },
+ };
+
+ it('serializes an edited Authorization header to a plain record in the save payload', () => {
+ const { onChange } = renderWithHeaders(baseRecord);
+
+ fireEvent.click(screen.getByText('srv'));
+ const valueInput = screen.getByLabelText('com_ui_value 1');
+ fireEvent.change(valueInput, { target: { value: 'Bearer {{API_KEY}}' } });
+ fireEvent.blur(valueInput);
+
+ const headerEdits = onChange.mock.calls.filter(([p]) => p === 'mcpServers.srv.headers');
+ expect(headerEdits.length).toBeGreaterThan(0);
+ const [path, emitted] = headerEdits[headerEdits.length - 1] as [string, t.ConfigValue];
+
+ const edited = applyConfigEdit({}, path, emitted, {}, new Set(), new Set());
+ const { saves } = buildSavePayload(new Set([path]), edited, new Set());
+ expect(saves).toEqual([
+ { fieldPath: 'mcpServers.srv.headers', value: { Authorization: 'Bearer {{API_KEY}}' } },
+ ]);
+ });
+
+ it('emits an empty record, not an empty array, when the last header row is removed', () => {
+ const { onChange } = renderWithHeaders(baseRecord);
+
+ fireEvent.click(screen.getByText('srv'));
+ fireEvent.click(screen.getByRole('button', { name: 'com_ui_delete com_ui_entry 1' }));
+
+ const headerEdits = onChange.mock.calls.filter(([p]) => p === 'mcpServers.srv.headers');
+ expect(headerEdits.length).toBeGreaterThan(0);
+ expect(headerEdits[headerEdits.length - 1][1]).toEqual({});
+ });
+});
diff --git a/src/components/configuration/utils.test.ts b/src/components/configuration/utils.test.ts
index 469644d4..1965ce3c 100644
--- a/src/components/configuration/utils.test.ts
+++ b/src/components/configuration/utils.test.ts
@@ -9,6 +9,7 @@ import {
mergeIndexedArrayEdits,
buildSavePayload,
applyConfigEdit,
+ kvPairsEditValue,
} from './utils';
import { createField } from '@/test/fixtures';
import { flattenObject } from '@/utils';
@@ -543,3 +544,50 @@ describe('buildSavePayload — masked secrets never reach the backend', () => {
expect(resets).toEqual(['ocr.apiKey']);
});
});
+
+describe('kvPairsEditValue', () => {
+ it('passes non-empty pairs through unchanged for save-time serialization', () => {
+ const pairs: t.KeyValuePair[] = [
+ { key: 'Authorization', value: 'Bearer {{API_KEY}}', valueType: 'string' },
+ ];
+ expect(kvPairsEditValue(pairs)).toBe(pairs);
+ });
+
+ it('converts an emptied pair list to an empty record', () => {
+ expect(kvPairsEditValue([])).toEqual({});
+ });
+});
+
+describe('buildSavePayload — KV pairs serialize to records', () => {
+ it('converts header pairs edits to a plain record in the save payload', () => {
+ const edited: t.FlatConfigMap = {
+ 'mcpServers.srv.headers': [
+ { key: 'Authorization', value: 'Bearer {{API_KEY}}', valueType: 'string' },
+ ],
+ };
+ const { saves } = buildSavePayload(new Set(['mcpServers.srv.headers']), edited, new Set());
+ expect(saves).toEqual([
+ { fieldPath: 'mcpServers.srv.headers', value: { Authorization: 'Bearer {{API_KEY}}' } },
+ ]);
+ });
+
+ it('converts pairs nested inside indexed array entries (azure additional params)', () => {
+ const edited: t.FlatConfigMap = {
+ 'endpoints.azureOpenAI.groups.0': {
+ group: 'my-group',
+ addParams: [{ key: 'reasoning_effort', value: 'high', valueType: 'string' }],
+ },
+ };
+ const { saves } = buildSavePayload(
+ new Set(['endpoints.azureOpenAI.groups.0']),
+ edited,
+ new Set(),
+ );
+ expect(saves).toEqual([
+ {
+ fieldPath: 'endpoints.azureOpenAI.groups.0',
+ value: { group: 'my-group', addParams: { reasoning_effort: 'high' } },
+ },
+ ]);
+ });
+});
diff --git a/src/components/configuration/utils.ts b/src/components/configuration/utils.ts
index d858cecc..d73b18ed 100644
--- a/src/components/configuration/utils.ts
+++ b/src/components/configuration/utils.ts
@@ -34,6 +34,17 @@ export function inferKVType(v: t.ConfigValue): t.KVValueType {
return 'string';
}
+/**
+ * Wraps a KeyValueField edit for storage in edit state. In-progress rows stay
+ * as raw pairs so blank keys survive re-renders until save-time serialization,
+ * but an emptied list must become an empty record immediately: a bare `[]`
+ * cannot be recognized as KV pairs by `serializeKVPairs` and would reach the
+ * backend as an array where the schema expects an object.
+ */
+export function kvPairsEditValue(pairs: t.KeyValuePair[]): t.ConfigValue {
+ return pairs.length === 0 ? {} : pairs;
+}
+
export function toKVPair(k: string, v: t.ConfigValue): t.KeyValuePair {
const valueType = inferKVType(v);
if (valueType === 'json') return { key: k, value: JSON.stringify(v, null, 2), valueType };
From c008220d1993f285b31c384638a17597e1099514 Mon Sep 17 00:00:00 2001
From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com>
Date: Sun, 9 Aug 2026 21:17:17 -0700
Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20Gate=20Profi?=
=?UTF-8?q?le=20Modal=20KV=20Serialization=20on=20Record=20Control=20Type?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Serializing profile modal values shape-based would collapse any array whose elements happen to carry key and value properties into a record, dropping sibling fields. Only the record control edits via KeyValueField pairs, so serialize only that control type through a shared serializeModalValue helper and pass every other control's value through untouched.
---
.../configuration/FieldProfilePopover.tsx | 10 +++++-----
src/components/configuration/utils.test.ts | 18 ++++++++++++++++++
src/components/configuration/utils.ts | 17 ++++++++++++++++-
3 files changed, 39 insertions(+), 6 deletions(-)
diff --git a/src/components/configuration/FieldProfilePopover.tsx b/src/components/configuration/FieldProfilePopover.tsx
index 7f2e589e..bc59b730 100644
--- a/src/components/configuration/FieldProfilePopover.tsx
+++ b/src/components/configuration/FieldProfilePopover.tsx
@@ -5,11 +5,11 @@ import type * as t from '@/types';
import { ProfileValueModal, getDefaultValue } from './ProfileValueModal';
import { DeleteProfileValueModal } from './DeleteProfileValueModal';
import { EditButton, TrashButton } from '@/components/shared';
+import { getControlType, serializeModalValue } from './utils';
import { useProfileMutations, useLocalize } from '@/hooks';
-import { deepSerializeKVPairs, cn } from '@/utils';
import { availableScopesOptions } from '@/server';
import { getScopeTypeConfig } from '@/constants';
-import { getControlType } from './utils';
+import { cn } from '@/utils';
export function FieldProfilePopover({
fieldPath,
@@ -71,7 +71,7 @@ export function FieldProfilePopover({
const handleModalSave = useCallback(() => {
if (modalIsBase && onBaseValueChange) {
- onBaseValueChange(deepSerializeKVPairs(modalValue));
+ onBaseValueChange(serializeModalValue(controlType, modalValue));
setModalOpen(false);
setModalIsBase(false);
return;
@@ -81,7 +81,7 @@ export function FieldProfilePopover({
{
principalType: modalScope.principalType,
principalId: modalScope.principalId,
- value: deepSerializeKVPairs(modalValue),
+ value: serializeModalValue(controlType, modalValue),
},
{
onSuccess: () => {
@@ -94,7 +94,7 @@ export function FieldProfilePopover({
},
},
);
- }, [modalIsBase, modalScope, modalValue, modalMode, saveMutation, onBaseValueChange]);
+ }, [modalIsBase, modalScope, modalValue, modalMode, controlType, saveMutation, onBaseValueChange]);
const handleModalCancel = useCallback(() => {
setModalOpen(false);
diff --git a/src/components/configuration/utils.test.ts b/src/components/configuration/utils.test.ts
index 1965ce3c..6a6e6af3 100644
--- a/src/components/configuration/utils.test.ts
+++ b/src/components/configuration/utils.test.ts
@@ -10,6 +10,7 @@ import {
buildSavePayload,
applyConfigEdit,
kvPairsEditValue,
+ serializeModalValue,
} from './utils';
import { createField } from '@/test/fixtures';
import { flattenObject } from '@/utils';
@@ -591,3 +592,20 @@ describe('buildSavePayload — KV pairs serialize to records', () => {
]);
});
});
+
+describe('serializeModalValue', () => {
+ it('serializes record-control pairs to a plain record', () => {
+ const pairs = [{ key: 'Authorization', value: 'Bearer {{API_KEY}}', valueType: 'string' }];
+ expect(serializeModalValue('record', pairs)).toEqual({ Authorization: 'Bearer {{API_KEY}}' });
+ });
+
+ it('passes an untouched empty record default through unchanged', () => {
+ expect(serializeModalValue('record', {})).toEqual({});
+ });
+
+ it('leaves array-control values untouched even when elements look pair-like', () => {
+ const value = [{ key: 'id', value: 'x', enabled: true }];
+ expect(serializeModalValue('array', value)).toBe(value);
+ expect(serializeModalValue('array-object', value)).toBe(value);
+ });
+});
diff --git a/src/components/configuration/utils.ts b/src/components/configuration/utils.ts
index d73b18ed..3ab8e278 100644
--- a/src/components/configuration/utils.ts
+++ b/src/components/configuration/utils.ts
@@ -1,5 +1,10 @@
import type * as t from '@/types';
-import { deepSerializeKVPairs, secretPathForPreviewPath, stripSecretPreviewValues } from '@/utils';
+import {
+ deepSerializeKVPairs,
+ secretPathForPreviewPath,
+ stripSecretPreviewValues,
+ serializeKVPairs,
+} from '@/utils';
const INDEXED_ARRAY_PATH_RE = /^(.+)\.(\d+)$/;
@@ -45,6 +50,16 @@ export function kvPairsEditValue(pairs: t.KeyValuePair[]): t.ConfigValue {
return pairs.length === 0 ? {} : pairs;
}
+/**
+ * Serializes a profile modal value for save. Only record controls edit via
+ * KeyValueField pairs; every other control type must pass through untouched
+ * so array values whose elements merely look pair-like (objects carrying
+ * `key` and `value` properties) are never collapsed into records.
+ */
+export function serializeModalValue(controlType: t.ControlType, value: t.ConfigValue): t.ConfigValue {
+ return controlType === 'record' ? serializeKVPairs(value) : value;
+}
+
export function toKVPair(k: string, v: t.ConfigValue): t.KeyValuePair {
const valueType = inferKVType(v);
if (valueType === 'json') return { key: k, value: JSON.stringify(v, null, 2), valueType };