This commit is contained in:
Airam Hernández Hernández 2026-04-04 23:44:05 +00:00 committed by GitHub
commit 5f4cd83cae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 3019 additions and 16 deletions

View file

@ -1,10 +1,14 @@
import { FormProvider } from 'react-hook-form';
import type { useMCPServerForm } from './hooks/useMCPServerForm';
import CustomUserVarsDefinitionSection from './sections/CustomUserVarsDefinitionSection';
import ConnectionSection from './sections/ConnectionSection';
import BasicInfoSection from './sections/BasicInfoSection';
import TransportSection from './sections/TransportSection';
import AdvancedSection from './sections/AdvancedSection';
import HeadersSection from './sections/HeadersSection';
import TrustSection from './sections/TrustSection';
import AuthSection from './sections/AuthSection';
import { useLocalize } from '~/hooks';
interface MCPServerFormProps {
formHook: ReturnType<typeof useMCPServerForm>;
@ -12,6 +16,7 @@ interface MCPServerFormProps {
export default function MCPServerForm({ formHook }: MCPServerFormProps) {
const { methods, isEditMode, server } = formHook;
const localize = useLocalize();
return (
<FormProvider {...methods}>
@ -22,8 +27,19 @@ export default function MCPServerForm({ formHook }: MCPServerFormProps) {
<TransportSection />
<HeadersSection isEditMode={isEditMode} />
<AuthSection isEditMode={isEditMode} serverName={server?.serverName} />
<CustomUserVarsDefinitionSection />
<div className="space-y-2">
<p className="text-sm font-medium">{localize('com_ui_mcp_advanced')}</p>
<div className="rounded-lg border border-border-light p-3">
<AdvancedSection />
</div>
</div>
<TrustSection />
</div>
</FormProvider>

View file

@ -0,0 +1,272 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { useForm, FormProvider } from 'react-hook-form';
import AdvancedSection from '../sections/AdvancedSection';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => {
const t: Record<string, string> = {
com_ui_mcp_chat_menu: 'Show in chat menu',
com_ui_mcp_chat_menu_description: 'Display this server in the chat menu for quick access',
com_ui_mcp_server_instructions: 'Server Instructions',
com_ui_mcp_server_instructions_description:
'Controls how server instructions are included in AI prompts',
com_ui_mcp_server_instructions_none: 'None',
com_ui_mcp_server_instructions_server: 'Use server-provided',
com_ui_mcp_server_instructions_custom: 'Custom',
com_ui_mcp_server_instructions_custom_placeholder:
'Enter custom instructions for this server...',
com_ui_no_options: 'No options',
};
return t[key] ?? key;
},
}));
jest.mock('@librechat/client', () => {
const ActualReact = jest.requireActual<typeof import('react')>('react');
return {
Checkbox: ({
id,
checked,
onCheckedChange,
...rest
}: {
id?: string;
checked: boolean;
onCheckedChange: (v: boolean) => void;
[key: string]: unknown;
}) =>
ActualReact.createElement('input', {
id,
type: 'checkbox',
checked,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onCheckedChange(e.target.checked),
...rest,
}),
Label: ({
children,
htmlFor,
...rest
}: {
children: React.ReactNode;
htmlFor?: string;
[key: string]: unknown;
}) => ActualReact.createElement('label', { htmlFor, ...rest }, children),
Radio: ({
options,
value,
onChange,
}: {
options: Array<{ value: string; label: string }>;
value: string;
onChange: (v: string) => void;
[key: string]: unknown;
}) =>
ActualReact.createElement(
'div',
{ role: 'radiogroup' },
options.map((opt) =>
ActualReact.createElement(
'button',
{
key: opt.value,
type: 'button',
role: 'radio',
'aria-checked': value === opt.value,
onClick: () => onChange(opt.value),
},
opt.label,
),
),
),
Textarea: ActualReact.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>((props, ref) => ActualReact.createElement('textarea', { ref, ...props })),
};
});
// ---------------------------------------------------------------------------
// Wrapper: provides a real react-hook-form context for the component under test
// ---------------------------------------------------------------------------
interface WrapperProps {
defaultValues?: Partial<MCPServerFormData>;
}
function Wrapper({ defaultValues = {} }: WrapperProps) {
const methods = useForm<MCPServerFormData>({
defaultValues: {
title: '',
url: '',
type: 'streamable-http',
auth: {
auth_type: 'none' as MCPServerFormData['auth']['auth_type'],
api_key: '',
api_key_source: 'admin',
api_key_authorization_type:
'bearer' as MCPServerFormData['auth']['api_key_authorization_type'],
api_key_custom_header: '',
oauth_client_id: '',
oauth_client_secret: '',
oauth_authorization_url: '',
oauth_token_url: '',
oauth_scope: '',
},
trust: false,
headers: [],
customUserVars: [],
chatMenu: true,
serverInstructionsMode: 'none',
serverInstructionsCustom: '',
...defaultValues,
},
});
return (
<FormProvider {...methods}>
<AdvancedSection />
</FormProvider>
);
}
// ---------------------------------------------------------------------------
// Tests: chatMenu checkbox
// ---------------------------------------------------------------------------
describe('AdvancedSection chat menu checkbox', () => {
it('renders the chat menu checkbox with correct label', () => {
render(<Wrapper />);
expect(screen.getByText('Show in chat menu')).toBeInTheDocument();
expect(
screen.getByText('Display this server in the chat menu for quick access'),
).toBeInTheDocument();
});
it('renders checkbox checked by default (chatMenu: true)', () => {
render(<Wrapper defaultValues={{ chatMenu: true }} />);
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it('renders checkbox unchecked when chatMenu is false', () => {
render(<Wrapper defaultValues={{ chatMenu: false }} />);
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
expect(checkbox.checked).toBe(false);
});
it('toggles the checkbox on click', () => {
render(<Wrapper defaultValues={{ chatMenu: true }} />);
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.change(checkbox, { target: { checked: false } });
expect(checkbox.checked).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Tests: serverInstructions radio group
// ---------------------------------------------------------------------------
describe('AdvancedSection server instructions radio', () => {
it('renders the server instructions heading and description', () => {
render(<Wrapper />);
expect(screen.getByText('Server Instructions')).toBeInTheDocument();
expect(
screen.getByText('Controls how server instructions are included in AI prompts'),
).toBeInTheDocument();
});
it('renders all three radio options', () => {
render(<Wrapper />);
expect(screen.getByRole('radio', { name: 'None' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Use server-provided' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Custom' })).toBeInTheDocument();
});
it('has "None" selected by default', () => {
render(<Wrapper />);
const noneBtn = screen.getByRole('radio', { name: 'None' });
expect(noneBtn).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('radio', { name: 'Use server-provided' })).toHaveAttribute(
'aria-checked',
'false',
);
expect(screen.getByRole('radio', { name: 'Custom' })).toHaveAttribute('aria-checked', 'false');
});
it('shows "Use server-provided" selected when mode is "server"', () => {
render(<Wrapper defaultValues={{ serverInstructionsMode: 'server' }} />);
expect(screen.getByRole('radio', { name: 'Use server-provided' })).toHaveAttribute(
'aria-checked',
'true',
);
});
it('shows "Custom" selected when mode is "custom"', () => {
render(<Wrapper defaultValues={{ serverInstructionsMode: 'custom' }} />);
expect(screen.getByRole('radio', { name: 'Custom' })).toHaveAttribute('aria-checked', 'true');
});
it('does not render the custom textarea when mode is "none"', () => {
render(<Wrapper defaultValues={{ serverInstructionsMode: 'none' }} />);
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
it('does not render the custom textarea when mode is "server"', () => {
render(<Wrapper defaultValues={{ serverInstructionsMode: 'server' }} />);
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
it('renders the custom textarea when mode is "custom"', () => {
render(<Wrapper defaultValues={{ serverInstructionsMode: 'custom' }} />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByRole('textbox')).toHaveAttribute(
'placeholder',
'Enter custom instructions for this server...',
);
});
it('populates custom textarea with existing text', () => {
render(
<Wrapper
defaultValues={{
serverInstructionsMode: 'custom',
serverInstructionsCustom: 'Existing instructions.',
}}
/>,
);
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
expect(textarea.value).toBe('Existing instructions.');
});
it('switches from "none" to "server" when radio is clicked', () => {
render(<Wrapper />);
const serverBtn = screen.getByRole('radio', { name: 'Use server-provided' });
fireEvent.click(serverBtn);
expect(serverBtn).toHaveAttribute('aria-checked', 'true');
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
it('shows textarea after clicking the "Custom" radio', () => {
render(<Wrapper />);
const customBtn = screen.getByRole('radio', { name: 'Custom' });
fireEvent.click(customBtn);
expect(customBtn).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('hides textarea after switching away from "Custom"', () => {
render(<Wrapper defaultValues={{ serverInstructionsMode: 'custom' }} />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
fireEvent.click(screen.getByRole('radio', { name: 'None' }));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,340 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { useForm, FormProvider } from 'react-hook-form';
import CustomUserVarsDefinitionSection from '../sections/CustomUserVarsDefinitionSection';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => {
const t: Record<string, string> = {
com_ui_mcp_custom_user_vars_definition: 'User Variables',
com_ui_mcp_custom_user_vars_definition_description:
'Define variables that users must supply when connecting to this server.',
com_ui_mcp_add_variable: 'Add Variable',
com_ui_mcp_no_custom_vars: 'No user variables defined.',
com_ui_mcp_variable: 'Variable',
com_ui_mcp_variable_key: 'Key',
com_ui_mcp_variable_key_placeholder: 'e.g. API_KEY',
com_ui_mcp_variable_key_invalid:
'Key must start with a letter and contain only letters, digits, and underscores',
com_ui_mcp_variable_title: 'Label',
com_ui_mcp_variable_title_placeholder: 'e.g. API Key',
com_ui_mcp_variable_description_placeholder: 'e.g. Your API key for this service',
com_ui_description: 'Description',
com_ui_optional: '(optional)',
com_ui_field_required: 'This field is required',
com_ui_delete: 'Delete',
};
return t[key] ?? key;
},
}));
jest.mock('~/utils', () => ({
cn: (...classes: (string | undefined | null | boolean)[]) => classes.filter(Boolean).join(' '),
}));
jest.mock('@librechat/client', () => {
const ActualReact = jest.requireActual<typeof import('react')>('react');
return {
Input: ActualReact.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => ActualReact.createElement('input', { ref, ...props }),
),
Label: ({
children,
htmlFor,
...rest
}: {
children: React.ReactNode;
htmlFor?: string;
[key: string]: unknown;
}) => ActualReact.createElement('label', { htmlFor, ...rest }, children),
Button: ({
children,
onClick,
type,
...rest
}: {
children: React.ReactNode;
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
[key: string]: unknown;
}) =>
ActualReact.createElement('button', { onClick, type: type ?? 'button', ...rest }, children),
Textarea: ActualReact.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>((props, ref) => ActualReact.createElement('textarea', { ref, ...props })),
};
});
jest.mock('lucide-react', () => ({
Plus: () => null,
Trash2: () => null,
}));
// ---------------------------------------------------------------------------
// Wrapper
// ---------------------------------------------------------------------------
interface WrapperProps {
defaultValues?: Partial<MCPServerFormData>;
}
function Wrapper({ defaultValues = {} }: WrapperProps) {
const methods = useForm<MCPServerFormData>({
defaultValues: {
title: '',
url: '',
type: 'streamable-http',
auth: {
auth_type: 'none' as MCPServerFormData['auth']['auth_type'],
api_key: '',
api_key_source: 'admin',
api_key_authorization_type:
'bearer' as MCPServerFormData['auth']['api_key_authorization_type'],
api_key_custom_header: '',
oauth_client_id: '',
oauth_client_secret: '',
oauth_authorization_url: '',
oauth_token_url: '',
oauth_scope: '',
},
trust: false,
headers: [],
customUserVars: [],
chatMenu: true,
serverInstructionsMode: 'none',
serverInstructionsCustom: '',
...defaultValues,
},
});
return (
<FormProvider {...methods}>
<CustomUserVarsDefinitionSection />
</FormProvider>
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('CustomUserVarsDefinitionSection empty state', () => {
it('renders the section heading', () => {
render(<Wrapper />);
expect(screen.getByText('User Variables')).toBeInTheDocument();
});
it('renders the section description', () => {
render(<Wrapper />);
expect(
screen.getByText('Define variables that users must supply when connecting to this server.'),
).toBeInTheDocument();
});
it('renders the "Add Variable" button', () => {
render(<Wrapper />);
expect(screen.getByRole('button', { name: /Add Variable/i })).toBeInTheDocument();
});
it('shows the empty-state message when no variables exist', () => {
render(<Wrapper />);
expect(screen.getByText('No user variables defined.')).toBeInTheDocument();
});
it('does not render any variable entry when empty', () => {
render(<Wrapper />);
expect(screen.queryByText('Variable 1')).not.toBeInTheDocument();
});
});
describe('CustomUserVarsDefinitionSection adding variables', () => {
it('adds a variable entry with Key and Label inputs after clicking "Add Variable"', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
expect(screen.getByLabelText(/^Key/)).toBeInTheDocument();
expect(screen.getByLabelText(/^Label/)).toBeInTheDocument();
});
it('shows variable counter label after adding', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
expect(screen.getByText('Variable 1')).toBeInTheDocument();
});
it('hides the empty-state message after adding a variable', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
expect(screen.queryByText('No user variables defined.')).not.toBeInTheDocument();
});
it('adds multiple variable entries', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
expect(screen.getByText('Variable 1')).toBeInTheDocument();
expect(screen.getByText('Variable 2')).toBeInTheDocument();
});
it('renders a Description textarea for each variable entry', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
expect(screen.getByPlaceholderText('e.g. Your API key for this service')).toBeInTheDocument();
});
it('renders Description with "(optional)" label', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
expect(screen.getByText('(optional)')).toBeInTheDocument();
});
});
describe('CustomUserVarsDefinitionSection pre-populated entries', () => {
it('renders pre-existing variable entries from defaultValues', () => {
render(
<Wrapper
defaultValues={{
customUserVars: [
{ key: 'API_KEY', title: 'API Key', description: 'Your API key' },
{ key: 'INDEX', title: 'Index Name', description: '' },
],
}}
/>,
);
expect(screen.getByText('Variable 1')).toBeInTheDocument();
expect(screen.getByText('Variable 2')).toBeInTheDocument();
});
it('pre-fills key, title, and description inputs with existing values', () => {
render(
<Wrapper
defaultValues={{
customUserVars: [
{ key: 'TOKEN', title: 'Auth Token', description: 'Bearer token for auth' },
],
}}
/>,
);
// Verify the inputs are rendered and registered under the correct field-array paths.
const keyInput = screen.getByPlaceholderText('e.g. API_KEY');
const titleInput = screen.getByPlaceholderText('e.g. API Key');
const descTextarea = screen.getByPlaceholderText('e.g. Your API key for this service');
expect(keyInput).toHaveAttribute('name', 'customUserVars.0.key');
expect(titleInput).toHaveAttribute('name', 'customUserVars.0.title');
expect(descTextarea).toHaveAttribute('name', 'customUserVars.0.description');
});
it('allows empty description (optional field)', () => {
render(
<Wrapper
defaultValues={{
customUserVars: [{ key: 'MY_KEY', title: 'My Key', description: '' }],
}}
/>,
);
const descTextarea = screen.getByPlaceholderText(
'e.g. Your API key for this service',
) as HTMLTextAreaElement;
expect(descTextarea.value).toBe('');
});
});
describe('CustomUserVarsDefinitionSection removing entries', () => {
it('renders a Delete button for each entry', () => {
render(
<Wrapper
defaultValues={{
customUserVars: [
{ key: 'A', title: 'Var A', description: '' },
{ key: 'B', title: 'Var B', description: '' },
],
}}
/>,
);
expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(2);
});
it('removes an entry after clicking its Delete button', () => {
render(
<Wrapper
defaultValues={{
customUserVars: [
{ key: 'REMOVE_ME', title: 'Remove', description: '' },
{ key: 'KEEP_ME', title: 'Keep', description: '' },
],
}}
/>,
);
expect(screen.getByText('Variable 2')).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
expect(screen.queryByText('Variable 2')).not.toBeInTheDocument();
expect(screen.getByText('Variable 1')).toBeInTheDocument();
});
it('shows empty-state message after removing the last entry', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
expect(screen.queryByText('No user variables defined.')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(screen.getByText('No user variables defined.')).toBeInTheDocument();
});
});
describe('CustomUserVarsDefinitionSection key field', () => {
it('renders the Key input with monospace class hint (font-mono via className)', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
const keyInput = screen.getByLabelText(/^Key/) as HTMLInputElement;
expect(keyInput).toBeInTheDocument();
expect(keyInput.className).toMatch(/font-mono/);
});
it('has the Key field placeholder text', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
expect(screen.getByPlaceholderText('e.g. API_KEY')).toBeInTheDocument();
});
it('marks Key as required with aria-hidden asterisk', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
// The asterisk is rendered as aria-hidden="true"
const asterisks = document.querySelectorAll('[aria-hidden="true"]');
const asterisk = Array.from(asterisks).find((el) => el.textContent === '*');
expect(asterisk).toBeTruthy();
});
});
describe('CustomUserVarsDefinitionSection title (Label) field', () => {
it('has the Label placeholder text', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
expect(screen.getByPlaceholderText('e.g. API Key')).toBeInTheDocument();
});
});
describe('CustomUserVarsDefinitionSection description field', () => {
it('renders a textarea for the description', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
const textarea = screen.getByPlaceholderText('e.g. Your API key for this service');
expect(textarea.tagName).toBe('TEXTAREA');
});
it('allows typing in the description textarea', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Variable/i }));
const textarea = screen.getByPlaceholderText(
'e.g. Your API key for this service',
) as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: 'Some helpful description' } });
expect(textarea.value).toBe('Some helpful description');
});
});

View file

@ -0,0 +1,391 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { useForm, FormProvider } from 'react-hook-form';
import HeadersSection from '../sections/HeadersSection';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => {
const t: Record<string, string> = {
com_ui_mcp_headers: 'HTTP Headers',
com_ui_mcp_add_header: 'Add Header',
com_ui_mcp_no_headers: 'No headers configured.',
com_ui_mcp_header_key: 'Header key',
com_ui_mcp_header_key_placeholder: 'e.g. Authorization',
com_ui_mcp_header_value: 'Header value',
com_ui_mcp_header_value_placeholder: 'e.g. Bearer my-token',
com_ui_mcp_header_value_secret_placeholder: '••••••••',
com_ui_mcp_mark_secret: 'Mark as secret',
com_ui_mcp_mark_not_secret: 'Mark as not secret',
com_ui_mcp_insert_variable: 'Insert variable',
com_ui_mcp_header_env_var_not_allowed: 'Environment variables are not allowed',
com_ui_field_required: 'This field is required',
com_ui_delete: 'Delete',
};
return t[key] ?? key;
},
}));
jest.mock('~/utils', () => ({
cn: (...classes: (string | undefined | null | boolean)[]) => classes.filter(Boolean).join(' '),
}));
jest.mock('@librechat/client', () => {
const ActualReact = jest.requireActual<typeof import('react')>('react');
return {
Input: ActualReact.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => ActualReact.createElement('input', { ref, ...props }),
),
Label: ({
children,
htmlFor,
...rest
}: {
children: React.ReactNode;
htmlFor?: string;
[key: string]: unknown;
}) => ActualReact.createElement('label', { htmlFor, ...rest }, children),
Button: ({
children,
onClick,
type,
...rest
}: {
children: React.ReactNode;
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
[key: string]: unknown;
}) =>
ActualReact.createElement('button', { onClick, type: type ?? 'button', ...rest }, children),
SecretInput: ActualReact.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>((props, ref) =>
ActualReact.createElement('input', {
ref,
type: 'password',
'data-testid': 'secret-input',
...props,
}),
),
DropdownMenu: ({ children }: { children: React.ReactNode }) =>
ActualReact.createElement('div', { 'data-testid': 'dropdown-menu' }, children),
DropdownMenuTrigger: ({
children,
_asChild,
}: {
children: React.ReactNode;
_asChild?: boolean;
}) => ActualReact.createElement('div', { 'data-testid': 'dropdown-trigger' }, children),
DropdownMenuContent: ({ children }: { children: React.ReactNode; [key: string]: unknown }) =>
ActualReact.createElement('div', { 'data-testid': 'dropdown-content' }, children),
DropdownMenuItem: ({
children,
onSelect,
}: {
children: React.ReactNode;
onSelect?: () => void;
}) =>
ActualReact.createElement(
'div',
{ 'data-testid': 'dropdown-item', onClick: onSelect, role: 'menuitem' },
children,
),
};
});
// lucide-react icons
jest.mock('lucide-react', () => ({
Plus: () => null,
Trash2: () => null,
Lock: () => <span data-testid="lock-icon" />,
LockOpen: () => <span data-testid="lock-open-icon" />,
ChevronDown: () => null,
}));
// ---------------------------------------------------------------------------
// Wrapper
// ---------------------------------------------------------------------------
interface WrapperProps {
defaultValues?: Partial<MCPServerFormData>;
isEditMode?: boolean;
}
function Wrapper({ defaultValues = {}, isEditMode = false }: WrapperProps) {
const methods = useForm<MCPServerFormData>({
defaultValues: {
title: '',
url: '',
type: 'streamable-http',
auth: {
auth_type: 'none' as MCPServerFormData['auth']['auth_type'],
api_key: '',
api_key_source: 'admin',
api_key_authorization_type:
'bearer' as MCPServerFormData['auth']['api_key_authorization_type'],
api_key_custom_header: '',
oauth_client_id: '',
oauth_client_secret: '',
oauth_authorization_url: '',
oauth_token_url: '',
oauth_scope: '',
},
trust: false,
headers: [],
customUserVars: [],
chatMenu: true,
serverInstructionsMode: 'none',
serverInstructionsCustom: '',
...defaultValues,
},
});
return (
<FormProvider {...methods}>
<HeadersSection isEditMode={isEditMode} />
</FormProvider>
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('HeadersSection empty state', () => {
it('renders the section heading', () => {
render(<Wrapper />);
expect(screen.getByText('HTTP Headers')).toBeInTheDocument();
});
it('renders the "Add Header" button', () => {
render(<Wrapper />);
expect(screen.getByRole('button', { name: /Add Header/i })).toBeInTheDocument();
});
it('shows the empty-state message when no headers exist', () => {
render(<Wrapper />);
expect(screen.getByText('No headers configured.')).toBeInTheDocument();
});
it('does not render any header row inputs when empty', () => {
render(<Wrapper />);
expect(screen.queryByLabelText('Header key')).not.toBeInTheDocument();
});
});
describe('HeadersSection adding rows', () => {
it('adds a row with key and value inputs after clicking "Add Header"', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Header/i }));
expect(screen.getByLabelText('Header key')).toBeInTheDocument();
expect(screen.getByLabelText('Header value')).toBeInTheDocument();
});
it('hides the empty-state message after adding a row', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Header/i }));
expect(screen.queryByText('No headers configured.')).not.toBeInTheDocument();
});
it('adds multiple rows with separate inputs', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Header/i }));
fireEvent.click(screen.getByRole('button', { name: /Add Header/i }));
expect(screen.getAllByLabelText('Header key')).toHaveLength(2);
expect(screen.getAllByLabelText('Header value')).toHaveLength(2);
});
});
describe('HeadersSection pre-populated rows', () => {
it('renders rows for headers passed as defaultValues', () => {
render(
<Wrapper
defaultValues={{
headers: [
{ key: 'X-Custom', value: 'my-value', isSecret: false },
{ key: 'Authorization', value: '', isSecret: true },
],
}}
/>,
);
const keyInputs = screen.getAllByLabelText('Header key');
expect(keyInputs).toHaveLength(2);
// Verify each input is registered under the correct field-array path.
expect(keyInputs[0]).toHaveAttribute('name', 'headers.0.key');
expect(keyInputs[1]).toHaveAttribute('name', 'headers.1.key');
});
});
describe('HeadersSection secret toggle', () => {
it('renders the "Mark as secret" button (LockOpen icon) when isSecret is false', () => {
render(
<Wrapper defaultValues={{ headers: [{ key: 'X-Test', value: 'value', isSecret: false }] }} />,
);
expect(screen.getByRole('button', { name: 'Mark as secret' })).toBeInTheDocument();
expect(screen.getByTestId('lock-open-icon')).toBeInTheDocument();
});
it('renders a plain text input for a non-secret header', () => {
render(
<Wrapper defaultValues={{ headers: [{ key: 'X-Public', value: 'pub', isSecret: false }] }} />,
);
const valueInput = screen.getByLabelText('Header value') as HTMLInputElement;
expect(valueInput.type).not.toBe('password');
expect(valueInput).not.toHaveAttribute('data-testid', 'secret-input');
});
it('renders the "Mark as not secret" button (Lock icon) after toggling to secret', () => {
render(
<Wrapper defaultValues={{ headers: [{ key: 'X-Test', value: '', isSecret: false }] }} />,
);
fireEvent.click(screen.getByRole('button', { name: 'Mark as secret' }));
expect(screen.getByRole('button', { name: 'Mark as not secret' })).toBeInTheDocument();
expect(screen.getByTestId('lock-icon')).toBeInTheDocument();
});
it('renders a password input (SecretInput) after toggling to secret', () => {
render(
<Wrapper defaultValues={{ headers: [{ key: 'X-Test', value: '', isSecret: false }] }} />,
);
fireEvent.click(screen.getByRole('button', { name: 'Mark as secret' }));
expect(screen.getByTestId('secret-input')).toBeInTheDocument();
const secretInput = screen.getByTestId('secret-input') as HTMLInputElement;
expect(secretInput.type).toBe('password');
});
it('has aria-pressed="true" on the secret toggle after toggling to secret', () => {
render(
<Wrapper defaultValues={{ headers: [{ key: 'X-Test', value: '', isSecret: false }] }} />,
);
fireEvent.click(screen.getByRole('button', { name: 'Mark as secret' }));
const btn = screen.getByRole('button', { name: 'Mark as not secret' });
expect(btn).toHaveAttribute('aria-pressed', 'true');
});
it('has aria-pressed="false" on the secret toggle when isSecret is false', () => {
render(
<Wrapper defaultValues={{ headers: [{ key: 'X-Public', value: 'val', isSecret: false }] }} />,
);
const btn = screen.getByRole('button', { name: 'Mark as secret' });
expect(btn).toHaveAttribute('aria-pressed', 'false');
});
it('switches to secret input after clicking the secret toggle', () => {
render(
<Wrapper defaultValues={{ headers: [{ key: 'X-Token', value: 'abc', isSecret: false }] }} />,
);
// Initially a regular input
expect(screen.queryByTestId('secret-input')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Mark as secret' }));
// After toggle — now a password input
expect(screen.getByTestId('secret-input')).toBeInTheDocument();
});
it('switches from secret back to plain input after clicking the toggle again', () => {
render(
<Wrapper
defaultValues={{ headers: [{ key: 'Authorization', value: '', isSecret: true }] }}
/>,
);
expect(screen.getByTestId('secret-input')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Mark as not secret' }));
expect(screen.queryByTestId('secret-input')).not.toBeInTheDocument();
});
});
describe('HeadersSection removing rows', () => {
it('renders a Delete button for each row', () => {
render(
<Wrapper
defaultValues={{
headers: [
{ key: 'X-A', value: 'a', isSecret: false },
{ key: 'X-B', value: 'b', isSecret: false },
],
}}
/>,
);
expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(2);
});
it('removes the row after clicking Delete', () => {
render(
<Wrapper
defaultValues={{
headers: [
{ key: 'X-Remove', value: 'gone', isSecret: false },
{ key: 'X-Keep', value: 'stay', isSecret: false },
],
}}
/>,
);
expect(screen.getAllByLabelText('Header key')).toHaveLength(2);
fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
expect(screen.getAllByLabelText('Header key')).toHaveLength(1);
});
it('shows empty-state message again after removing the last row', () => {
render(<Wrapper />);
fireEvent.click(screen.getByRole('button', { name: /Add Header/i }));
expect(screen.queryByText('No headers configured.')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(screen.getByText('No headers configured.')).toBeInTheDocument();
});
});
describe('HeadersSection variable picker', () => {
it('does not show variable picker for a non-secret header when no customUserVars exist', () => {
render(
<Wrapper
defaultValues={{
headers: [{ key: 'X-Test', value: '', isSecret: false }],
customUserVars: [],
}}
/>,
);
expect(screen.queryByLabelText('Insert variable')).not.toBeInTheDocument();
});
it('shows variable picker for a non-secret header when valid customUserVars exist', () => {
render(
<Wrapper
defaultValues={{
headers: [{ key: 'X-Index', value: '', isSecret: false }],
customUserVars: [{ key: 'MY_VAR', title: 'My Variable', description: '' }],
}}
/>,
);
expect(screen.getByRole('button', { name: 'Insert variable' })).toBeInTheDocument();
});
it('does not show variable picker for a secret header even with customUserVars', () => {
render(
<Wrapper
defaultValues={{
headers: [{ key: 'X-Secret', value: '', isSecret: true }],
customUserVars: [{ key: 'MY_VAR', title: 'My Variable', description: '' }],
}}
/>,
);
expect(screen.queryByRole('button', { name: 'Insert variable' })).not.toBeInTheDocument();
});
it('hides variable picker when customUserVar has no key or title (invalid entry)', () => {
render(
<Wrapper
defaultValues={{
headers: [{ key: 'X-Index', value: '', isSecret: false }],
customUserVars: [{ key: '', title: '', description: '' }],
}}
/>,
);
expect(screen.queryByRole('button', { name: 'Insert variable' })).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,654 @@
/**
* Unit tests for useMCPServerForm focused on the chatMenu / serverInstructions
* defaultValues derivation and config-building logic introduced in the Advanced section.
*
* These tests exercise pure TypeScript logic directly without mounting React, keeping
* them fast and dependency-free.
*/
import type { MCPOptions } from 'librechat-data-provider';
import type { MCPServerDefinition } from '~/hooks';
// ---------------------------------------------------------------------------
// Import shared helpers from production code
// ---------------------------------------------------------------------------
import { buildHeaders, buildCustomUserVars } from '../utils/formHelpers';
import { AuthTypeEnum, AuthorizationTypeEnum } from '../hooks/useMCPServerForm';
import type { MCPServerFormData, ServerInstructionsMode } from '../hooks/useMCPServerForm';
/**
* Mirrors the defaultValues derivation for an existing server so we can test it
* without mounting the full hook (which requires React context).
*/
function deriveDefaultValues(server: MCPServerDefinition): MCPServerFormData {
let authType = AuthTypeEnum.None;
if (server.config.oauth) {
authType = AuthTypeEnum.OAuth;
} else if ('apiKey' in server.config && server.config.apiKey) {
authType = AuthTypeEnum.ServiceHttp;
}
const apiKeyConfig = 'apiKey' in server.config ? server.config.apiKey : undefined;
const headersConfig =
'headers' in server.config && server.config.headers
? (server.config.headers as Record<string, string>)
: {};
const customUserVarsConfig = server.config.customUserVars ?? {};
const rawSecretHeaderKeys =
'secretHeaderKeys' in server.config
? (server.config.secretHeaderKeys as string[] | undefined)
: undefined;
const secretHeaderKeysSet = new Set(rawSecretHeaderKeys ?? []);
const si = server.config.serverInstructions;
let serverInstructionsMode: ServerInstructionsMode = 'none';
if (typeof si === 'string') {
// Normalize case-insensitive "true"/"false" strings from YAML configs
const normalized = si.toLowerCase().trim();
if (normalized === 'true') {
serverInstructionsMode = 'server';
} else if (normalized === 'false' || normalized === '') {
serverInstructionsMode = 'none';
} else {
serverInstructionsMode = 'custom';
}
} else if (si === true) {
serverInstructionsMode = 'server';
}
return {
title: server.config.title || '',
description: server.config.description || '',
url: 'url' in server.config ? (server.config as { url: string }).url : '',
type: (server.config.type as 'streamable-http' | 'sse') || 'streamable-http',
icon: server.config.iconPath || '',
auth: {
auth_type: authType,
api_key: '',
api_key_source: (apiKeyConfig?.source as 'admin' | 'user') || 'admin',
api_key_authorization_type:
(apiKeyConfig?.authorization_type as AuthorizationTypeEnum) || AuthorizationTypeEnum.Bearer,
api_key_custom_header: apiKeyConfig?.custom_header || '',
oauth_client_id: server.config.oauth?.client_id || '',
oauth_client_secret: '',
oauth_authorization_url: server.config.oauth?.authorization_url || '',
oauth_token_url: server.config.oauth?.token_url || '',
oauth_scope: server.config.oauth?.scope || '',
server_id: server.serverName,
},
trust: true,
headers: Object.entries(headersConfig).map(([key, value]) => ({
key,
value,
isSecret: secretHeaderKeysSet.has(key),
})),
customUserVars: Object.entries(customUserVarsConfig).map(([key, cfg]) => ({
key,
title: cfg.title,
description: cfg.description,
})),
chatMenu: server.config.chatMenu !== false,
serverInstructionsMode,
serverInstructionsCustom: typeof si === 'string' ? si : '',
};
}
/**
* Mirrors the config-building snippet from onSubmit so we can test the output
* payload without the full React + react-hook-form stack.
*/
function buildConfig(formData: MCPServerFormData): Record<string, unknown> {
return {
type: formData.type,
url: formData.url,
title: formData.title,
...(formData.description && { description: formData.description }),
...(formData.icon && { iconPath: formData.icon }),
...(!formData.chatMenu && { chatMenu: false }),
...(formData.serverInstructionsMode === 'server' && { serverInstructions: true }),
...(formData.serverInstructionsMode === 'custom' &&
formData.serverInstructionsCustom.trim() && {
serverInstructions: formData.serverInstructionsCustom.trim(),
}),
};
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeServer(overrides: Partial<MCPOptions> = {}): MCPServerDefinition {
const base: MCPOptions = {
type: 'sse',
url: 'https://mcp.example.com/sse',
title: 'Test Server',
...overrides,
} as MCPOptions;
return {
serverName: 'test-server',
config: base,
effectivePermissions: 7,
};
}
// ---------------------------------------------------------------------------
// Tests: deriving defaultValues from an existing server
// ---------------------------------------------------------------------------
describe('deriveDefaultValues chatMenu', () => {
it('defaults chatMenu to true when the field is absent', () => {
const server = makeServer();
const defaults = deriveDefaultValues(server);
expect(defaults.chatMenu).toBe(true);
});
it('keeps chatMenu true when config has chatMenu: true', () => {
const server = makeServer({ chatMenu: true });
const defaults = deriveDefaultValues(server);
expect(defaults.chatMenu).toBe(true);
});
it('sets chatMenu to false when config has chatMenu: false', () => {
const server = makeServer({ chatMenu: false });
const defaults = deriveDefaultValues(server);
expect(defaults.chatMenu).toBe(false);
});
});
describe('deriveDefaultValues serverInstructions', () => {
it('sets serverInstructionsMode to "none" when serverInstructions is absent', () => {
const server = makeServer();
const defaults = deriveDefaultValues(server);
expect(defaults.serverInstructionsMode).toBe('none');
expect(defaults.serverInstructionsCustom).toBe('');
});
it('sets serverInstructionsMode to "server" when serverInstructions is true', () => {
const server = makeServer({ serverInstructions: true });
const defaults = deriveDefaultValues(server);
expect(defaults.serverInstructionsMode).toBe('server');
expect(defaults.serverInstructionsCustom).toBe('');
});
it('sets serverInstructionsMode to "none" when serverInstructions is false', () => {
const server = makeServer({ serverInstructions: false });
const defaults = deriveDefaultValues(server);
expect(defaults.serverInstructionsMode).toBe('none');
expect(defaults.serverInstructionsCustom).toBe('');
});
it('sets serverInstructionsMode to "custom" and populates custom text', () => {
const server = makeServer({ serverInstructions: 'Use English only.' });
const defaults = deriveDefaultValues(server);
expect(defaults.serverInstructionsMode).toBe('custom');
expect(defaults.serverInstructionsCustom).toBe('Use English only.');
});
it('treats a non-empty string serverInstructions as custom mode', () => {
const server = makeServer({ serverInstructions: 'Multi\nline\ninstructions.' });
const defaults = deriveDefaultValues(server);
expect(defaults.serverInstructionsMode).toBe('custom');
expect(defaults.serverInstructionsCustom).toBe('Multi\nline\ninstructions.');
});
});
// ---------------------------------------------------------------------------
// Tests: buildConfig chatMenu payload
// ---------------------------------------------------------------------------
describe('buildConfig chatMenu', () => {
const baseFormData: MCPServerFormData = {
title: 'My Server',
url: 'https://mcp.example.com/sse',
type: 'sse',
auth: {
auth_type: AuthTypeEnum.None,
api_key: '',
api_key_source: 'admin',
api_key_authorization_type: AuthorizationTypeEnum.Bearer,
api_key_custom_header: '',
oauth_client_id: '',
oauth_client_secret: '',
oauth_authorization_url: '',
oauth_token_url: '',
oauth_scope: '',
},
trust: true,
headers: [],
customUserVars: [],
chatMenu: true,
serverInstructionsMode: 'none',
serverInstructionsCustom: '',
};
it('omits chatMenu from payload when checked (default/true)', () => {
const config = buildConfig({ ...baseFormData, chatMenu: true });
expect(config.chatMenu).toBeUndefined();
});
it('includes chatMenu: false in payload when unchecked', () => {
const config = buildConfig({ ...baseFormData, chatMenu: false });
expect(config.chatMenu).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Tests: buildConfig serverInstructions payload
// ---------------------------------------------------------------------------
describe('buildConfig serverInstructions', () => {
const baseFormData: MCPServerFormData = {
title: 'My Server',
url: 'https://mcp.example.com/sse',
type: 'sse',
auth: {
auth_type: AuthTypeEnum.None,
api_key: '',
api_key_source: 'admin',
api_key_authorization_type: AuthorizationTypeEnum.Bearer,
api_key_custom_header: '',
oauth_client_id: '',
oauth_client_secret: '',
oauth_authorization_url: '',
oauth_token_url: '',
oauth_scope: '',
},
trust: true,
headers: [],
customUserVars: [],
chatMenu: true,
serverInstructionsMode: 'none',
serverInstructionsCustom: '',
};
it('omits serverInstructions from payload when mode is "none"', () => {
const config = buildConfig({ ...baseFormData, serverInstructionsMode: 'none' });
expect(config.serverInstructions).toBeUndefined();
});
it('sends serverInstructions: true when mode is "server"', () => {
const config = buildConfig({ ...baseFormData, serverInstructionsMode: 'server' });
expect(config.serverInstructions).toBe(true);
});
it('sends custom string when mode is "custom" and text is non-empty', () => {
const config = buildConfig({
...baseFormData,
serverInstructionsMode: 'custom',
serverInstructionsCustom: 'Respond briefly.',
});
expect(config.serverInstructions).toBe('Respond briefly.');
});
it('trims whitespace from custom instructions before sending', () => {
const config = buildConfig({
...baseFormData,
serverInstructionsMode: 'custom',
serverInstructionsCustom: ' Trimmed text. ',
});
expect(config.serverInstructions).toBe('Trimmed text.');
});
it('omits serverInstructions when mode is "custom" but text is blank', () => {
const config = buildConfig({
...baseFormData,
serverInstructionsMode: 'custom',
serverInstructionsCustom: ' ',
});
expect(config.serverInstructions).toBeUndefined();
});
it('omits serverInstructions when mode is "custom" but text is empty string', () => {
const config = buildConfig({
...baseFormData,
serverInstructionsMode: 'custom',
serverInstructionsCustom: '',
});
expect(config.serverInstructions).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Tests: combined chatMenu + serverInstructions scenarios
// ---------------------------------------------------------------------------
describe('buildConfig combined chatMenu and serverInstructions', () => {
it('sends both chatMenu: false and serverInstructions: true together', () => {
const formData: MCPServerFormData = {
title: 'Server',
url: 'https://mcp.example.com/sse',
type: 'sse',
auth: {
auth_type: AuthTypeEnum.None,
api_key: '',
api_key_source: 'admin',
api_key_authorization_type: AuthorizationTypeEnum.Bearer,
api_key_custom_header: '',
oauth_client_id: '',
oauth_client_secret: '',
oauth_authorization_url: '',
oauth_token_url: '',
oauth_scope: '',
},
trust: true,
headers: [],
customUserVars: [],
chatMenu: false,
serverInstructionsMode: 'server',
serverInstructionsCustom: '',
};
const config = buildConfig(formData);
expect(config.chatMenu).toBe(false);
expect(config.serverInstructions).toBe(true);
});
it('sends chatMenu: false and custom serverInstructions string', () => {
const formData: MCPServerFormData = {
title: 'Hidden Server',
url: 'https://mcp.example.com/sse',
type: 'sse',
auth: {
auth_type: AuthTypeEnum.None,
api_key: '',
api_key_source: 'admin',
api_key_authorization_type: AuthorizationTypeEnum.Bearer,
api_key_custom_header: '',
oauth_client_id: '',
oauth_client_secret: '',
oauth_authorization_url: '',
oauth_token_url: '',
oauth_scope: '',
},
trust: true,
headers: [],
customUserVars: [],
chatMenu: false,
serverInstructionsMode: 'custom',
serverInstructionsCustom: 'Custom instructions here.',
};
const config = buildConfig(formData);
expect(config.chatMenu).toBe(false);
expect(config.serverInstructions).toBe('Custom instructions here.');
});
it('omits both chatMenu and serverInstructions when defaults are used', () => {
const formData: MCPServerFormData = {
title: 'Default Server',
url: 'https://mcp.example.com/sse',
type: 'sse',
auth: {
auth_type: AuthTypeEnum.None,
api_key: '',
api_key_source: 'admin',
api_key_authorization_type: AuthorizationTypeEnum.Bearer,
api_key_custom_header: '',
oauth_client_id: '',
oauth_client_secret: '',
oauth_authorization_url: '',
oauth_token_url: '',
oauth_scope: '',
},
trust: true,
headers: [],
customUserVars: [],
chatMenu: true,
serverInstructionsMode: 'none',
serverInstructionsCustom: '',
};
const config = buildConfig(formData);
expect(config.chatMenu).toBeUndefined();
expect(config.serverInstructions).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Tests: deriveDefaultValues headers round-trip
// ---------------------------------------------------------------------------
describe('deriveDefaultValues headers', () => {
it('produces an empty array when no headers are present', () => {
const server = makeServer();
const defaults = deriveDefaultValues(server);
expect(defaults.headers).toEqual([]);
});
it('maps each header entry to { key, value, isSecret: false } by default', () => {
const server = makeServer({
headers: { 'X-Custom': 'my-value', 'X-Other': 'other' } as unknown as MCPOptions['headers'],
secretHeaderKeys: [] as unknown as MCPOptions['secretHeaderKeys'],
});
const defaults = deriveDefaultValues(server);
expect(defaults.headers).toEqual(
expect.arrayContaining([
{ key: 'X-Custom', value: 'my-value', isSecret: false },
{ key: 'X-Other', value: 'other', isSecret: false },
]),
);
expect(defaults.headers).toHaveLength(2);
});
it('marks a header as isSecret: true when its key is in secretHeaderKeys', () => {
const server = makeServer({
headers: { Authorization: '', 'X-Public': 'pub' } as unknown as MCPOptions['headers'],
secretHeaderKeys: ['Authorization'] as unknown as MCPOptions['secretHeaderKeys'],
});
const defaults = deriveDefaultValues(server);
const authHeader = defaults.headers.find((h) => h.key === 'Authorization');
const pubHeader = defaults.headers.find((h) => h.key === 'X-Public');
expect(authHeader?.isSecret).toBe(true);
expect(pubHeader?.isSecret).toBe(false);
});
it('marks multiple headers as secret when all are in secretHeaderKeys', () => {
const server = makeServer({
headers: { 'X-Secret-A': '', 'X-Secret-B': '' } as unknown as MCPOptions['headers'],
secretHeaderKeys: ['X-Secret-A', 'X-Secret-B'] as unknown as MCPOptions['secretHeaderKeys'],
});
const defaults = deriveDefaultValues(server);
expect(defaults.headers.every((h) => h.isSecret)).toBe(true);
});
it('treats secretHeaderKeys as empty set when field is absent (YAML server)', () => {
const server = makeServer({
headers: { 'X-Custom': 'value' } as unknown as MCPOptions['headers'],
// secretHeaderKeys deliberately omitted
});
const defaults = deriveDefaultValues(server);
const entry = defaults.headers.find((h) => h.key === 'X-Custom');
expect(entry?.isSecret).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Tests: deriveDefaultValues customUserVars round-trip
// ---------------------------------------------------------------------------
describe('deriveDefaultValues customUserVars', () => {
it('produces an empty array when customUserVars is absent', () => {
const server = makeServer();
const defaults = deriveDefaultValues(server);
expect(defaults.customUserVars).toEqual([]);
});
it('maps each customUserVars entry to { key, title, description }', () => {
const server = makeServer({
customUserVars: {
API_KEY: { title: 'API Key', description: 'Your API key' },
INDEX: { title: 'Index Name', description: '' },
},
});
const defaults = deriveDefaultValues(server);
expect(defaults.customUserVars).toEqual(
expect.arrayContaining([
{ key: 'API_KEY', title: 'API Key', description: 'Your API key' },
{ key: 'INDEX', title: 'Index Name', description: '' },
]),
);
expect(defaults.customUserVars).toHaveLength(2);
});
it('preserves an empty description string', () => {
const server = makeServer({
customUserVars: { TOKEN: { title: 'Token', description: '' } },
});
const defaults = deriveDefaultValues(server);
expect(defaults.customUserVars[0].description).toBe('');
});
});
// ---------------------------------------------------------------------------
// Tests: buildHeaders
// ---------------------------------------------------------------------------
describe('buildHeaders', () => {
it('returns empty object when headers array is empty', () => {
expect(buildHeaders([])).toEqual({});
});
it('builds a plain headers map for non-secret entries', () => {
const result = buildHeaders([
{ key: 'X-Custom', value: 'my-value', isSecret: false },
{ key: 'X-Other', value: 'other-value', isSecret: false },
]);
expect(result.headers).toEqual({ 'X-Custom': 'my-value', 'X-Other': 'other-value' });
expect(result.secretHeaderKeys).toEqual([]);
});
it('includes the header key in secretHeaderKeys for secret entries', () => {
const result = buildHeaders([{ key: 'Authorization', value: 'Bearer token', isSecret: true }]);
expect(result.headers).toEqual({ Authorization: 'Bearer token' });
expect(result.secretHeaderKeys).toEqual(['Authorization']);
});
it('keeps secret headers with empty value (preserved masked value in edit mode)', () => {
const result = buildHeaders([{ key: 'X-Secret', value: '', isSecret: true }], true);
expect(result.headers).toEqual({ 'X-Secret': '' });
expect(result.secretHeaderKeys).toEqual(['X-Secret']);
});
it('skips non-secret headers with blank values', () => {
const result = buildHeaders([
{ key: 'X-Empty', value: ' ', isSecret: false },
{ key: 'X-Present', value: 'ok', isSecret: false },
]);
expect(result.headers).toEqual({ 'X-Present': 'ok' });
expect(result.secretHeaderKeys).toEqual([]);
});
it('skips entries with blank keys', () => {
const result = buildHeaders([
{ key: ' ', value: 'some-value', isSecret: false },
{ key: 'X-Real', value: 'val', isSecret: false },
]);
expect(result.headers).toEqual({ 'X-Real': 'val' });
});
it('trims whitespace from keys and values', () => {
const result = buildHeaders([{ key: ' X-Padded ', value: ' padded ', isSecret: false }]);
expect(result.headers).toEqual({ 'X-Padded': 'padded' });
});
it('returns empty object when all entries have blank keys', () => {
const result = buildHeaders([{ key: '', value: 'value', isSecret: false }]);
expect(result).toEqual({});
});
it('returns empty object when all non-secret entries have blank values', () => {
const result = buildHeaders([{ key: 'X-Empty', value: '', isSecret: false }]);
expect(result).toEqual({});
});
it('mixes secret and non-secret headers, builds correct secretHeaderKeys', () => {
const result = buildHeaders([
{ key: 'X-Api-Key', value: 'secret-value', isSecret: true },
{ key: 'X-Index', value: 'my-index', isSecret: false },
{ key: 'X-Token', value: 'tok', isSecret: true },
]);
expect(result.headers).toEqual({
'X-Api-Key': 'secret-value',
'X-Index': 'my-index',
'X-Token': 'tok',
});
expect(result.secretHeaderKeys).toEqual(['X-Api-Key', 'X-Token']);
});
it('always emits secretHeaderKeys (even as empty array) when headers are present', () => {
const result = buildHeaders([{ key: 'X-Public', value: 'pub', isSecret: false }]);
expect(result.secretHeaderKeys).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Tests: buildCustomUserVars
// ---------------------------------------------------------------------------
describe('buildCustomUserVars', () => {
it('returns undefined when array is empty', () => {
expect(buildCustomUserVars([])).toBeUndefined();
});
it('builds a map with title and description', () => {
const result = buildCustomUserVars([
{ key: 'API_KEY', title: 'API Key', description: 'Your API key' },
]);
expect(result).toEqual({ API_KEY: { title: 'API Key', description: 'Your API key' } });
});
it('preserves empty description string', () => {
const result = buildCustomUserVars([{ key: 'TOKEN', title: 'Token', description: '' }]);
expect(result).toEqual({ TOKEN: { title: 'Token', description: '' } });
});
it('trims whitespace from key, title, and description', () => {
const result = buildCustomUserVars([
{ key: ' MY_VAR ', title: ' My Var ', description: ' desc ' },
]);
expect(result).toEqual({ MY_VAR: { title: 'My Var', description: 'desc' } });
});
it('skips entries with blank keys', () => {
const result = buildCustomUserVars([
{ key: '', title: 'Should be skipped', description: '' },
{ key: 'VALID', title: 'Valid Var', description: '' },
]);
expect(result).toEqual({ VALID: { title: 'Valid Var', description: '' } });
});
it('skips entries with blank titles', () => {
const result = buildCustomUserVars([
{ key: 'MY_KEY', title: '', description: 'some desc' },
{ key: 'OTHER', title: 'Other', description: '' },
]);
expect(result).toEqual({ OTHER: { title: 'Other', description: '' } });
});
it('skips entries with whitespace-only key', () => {
const result = buildCustomUserVars([{ key: ' ', title: 'Title', description: '' }]);
expect(result).toBeUndefined();
});
it('skips entries with whitespace-only title', () => {
const result = buildCustomUserVars([{ key: 'MY_KEY', title: ' ', description: '' }]);
expect(result).toBeUndefined();
});
it('returns undefined when all entries are invalid', () => {
const result = buildCustomUserVars([
{ key: '', title: '', description: '' },
{ key: ' ', title: ' ', description: '' },
]);
expect(result).toBeUndefined();
});
it('handles multiple valid entries', () => {
const result = buildCustomUserVars([
{ key: 'API_KEY', title: 'API Key', description: 'Key for auth' },
{ key: 'INDEX', title: 'Index Name', description: '' },
{ key: 'TOP_K', title: 'Top K', description: 'Number of results' },
]);
expect(result).toEqual({
API_KEY: { title: 'API Key', description: 'Key for auth' },
INDEX: { title: 'Index Name', description: '' },
TOP_K: { title: 'Top K', description: 'Number of results' },
});
});
});

View file

@ -9,6 +9,7 @@ import {
import { useToastContext } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { extractServerNameFromUrl, isValidUrl, normalizeUrl } from '../utils/urlUtils';
import { buildHeaders, buildCustomUserVars } from '../utils/formHelpers';
import type { MCPServerDefinition } from '~/hooks';
// Auth type enum
@ -40,6 +41,20 @@ export interface AuthConfig {
server_id?: string;
}
export interface HeaderEntry {
key: string;
value: string;
isSecret: boolean;
}
export interface CustomUserVarEntry {
key: string;
title: string;
description: string;
}
export type ServerInstructionsMode = 'none' | 'server' | 'custom';
// Form data interface
export interface MCPServerFormData {
title: string;
@ -49,6 +64,11 @@ export interface MCPServerFormData {
type: 'streamable-http' | 'sse';
auth: AuthConfig;
trust: boolean;
headers: HeaderEntry[];
customUserVars: CustomUserVarEntry[];
chatMenu: boolean;
serverInstructionsMode: ServerInstructionsMode;
serverInstructionsCustom: string;
}
interface UseMCPServerFormProps {
@ -85,6 +105,29 @@ export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFor
const apiKeyConfig = 'apiKey' in server.config ? server.config.apiKey : undefined;
const headersConfig =
'headers' in server.config && server.config.headers ? server.config.headers : {};
const customUserVarsConfig = server.config.customUserVars ?? {};
const rawSecretHeaderKeys =
'secretHeaderKeys' in server.config ? server.config.secretHeaderKeys : undefined;
const secretHeaderKeysSet = new Set(rawSecretHeaderKeys ?? []);
const si = server.config.serverInstructions;
let serverInstructionsMode: ServerInstructionsMode = 'none';
if (typeof si === 'string') {
// Normalize case-insensitive "true"/"false" strings from YAML configs
const normalized = si.toLowerCase().trim();
if (normalized === 'true') {
serverInstructionsMode = 'server';
} else if (normalized === 'false' || normalized === '') {
serverInstructionsMode = 'none';
} else {
serverInstructionsMode = 'custom';
}
} else if (si === true) {
serverInstructionsMode = 'server';
}
return {
title: server.config.title || '',
description: server.config.description || '',
@ -107,6 +150,20 @@ export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFor
server_id: server.serverName,
},
trust: true, // Pre-checked for existing servers
headers: Object.entries(headersConfig).map(([key, value]) => ({
key,
value,
isSecret: secretHeaderKeysSet.has(key),
})),
customUserVars: Object.entries(customUserVarsConfig).map(([key, cfg]) => ({
key,
title: cfg.title,
description: cfg.description,
})),
chatMenu: server.config.chatMenu !== false,
serverInstructionsMode,
serverInstructionsCustom:
serverInstructionsMode === 'custom' && typeof si === 'string' ? si : '',
};
}
@ -129,6 +186,11 @@ export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFor
oauth_scope: '',
},
trust: false,
headers: [],
customUserVars: [],
chatMenu: true,
serverInstructionsMode: 'none' as ServerInstructionsMode,
serverInstructionsCustom: '',
};
}, [server]);
@ -142,7 +204,6 @@ export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFor
// Watch URL for auto-fill
const watchedUrl = watch('url');
const watchedTitle = watch('title');
// Auto-fill title from URL when title is empty
const handleUrlChange = useCallback(
@ -181,8 +242,27 @@ export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFor
title: formData.title,
...(formData.description && { description: formData.description }),
...(formData.icon && { iconPath: formData.icon }),
...(!formData.chatMenu && { chatMenu: false }),
...(formData.serverInstructionsMode === 'server' && { serverInstructions: true }),
...(formData.serverInstructionsMode === 'custom' &&
formData.serverInstructionsCustom.trim() && {
serverInstructions: formData.serverInstructionsCustom.trim(),
}),
};
// Add HTTP headers using extracted helper
const headersResult = buildHeaders(formData.headers, isEditMode);
if (headersResult.headers) {
config.headers = headersResult.headers;
config.secretHeaderKeys = headersResult.secretHeaderKeys;
}
// Add custom user variable definitions using extracted helper
const customUserVarsMap = buildCustomUserVars(formData.customUserVars);
if (customUserVarsMap) {
config.customUserVars = customUserVarsMap;
}
// Add OAuth configuration
if (
formData.auth.auth_type === AuthTypeEnum.OAuth &&

View file

@ -0,0 +1,86 @@
import { useMemo } from 'react';
import { useFormContext, Controller, useWatch } from 'react-hook-form';
import { Checkbox, Label, Radio, Textarea } from '@librechat/client';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
import { useLocalize } from '~/hooks';
export default function AdvancedSection() {
const localize = useLocalize();
const { control, register } = useFormContext<MCPServerFormData>();
const serverInstructionsMode =
useWatch<MCPServerFormData, 'serverInstructionsMode'>({
control,
name: 'serverInstructionsMode',
}) ?? 'none';
const instructionOptions = useMemo(
() => [
{ value: 'none', label: localize('com_ui_mcp_server_instructions_none') },
{ value: 'server', label: localize('com_ui_mcp_server_instructions_server') },
{ value: 'custom', label: localize('com_ui_mcp_server_instructions_custom') },
],
[localize],
);
return (
<div className="space-y-3">
{/* Chat Menu */}
<div className="flex items-start gap-3">
<Controller
name="chatMenu"
control={control}
render={({ field }) => (
<Checkbox
id="chat-menu"
checked={field.value}
onCheckedChange={field.onChange}
aria-labelledby="chat-menu-label"
aria-describedby="chat-menu-description"
className="mt-0.5"
/>
)}
/>
<Label htmlFor="chat-menu" className="flex cursor-pointer flex-col gap-0.5">
<span id="chat-menu-label" className="text-sm font-medium text-text-primary">
{localize('com_ui_mcp_chat_menu')}
</span>
<span id="chat-menu-description" className="text-xs font-normal text-text-secondary">
{localize('com_ui_mcp_chat_menu_description')}
</span>
</Label>
</div>
{/* Server Instructions */}
<div className="space-y-1.5">
<Label id="server-instructions-label" className="text-sm font-medium">
{localize('com_ui_mcp_server_instructions')}
</Label>
<p id="server-instructions-description" className="text-xs text-text-secondary">
{localize('com_ui_mcp_server_instructions_description')}
</p>
<Controller
name="serverInstructionsMode"
control={control}
render={({ field }) => (
<Radio
options={instructionOptions}
value={field.value}
onChange={field.onChange}
fullWidth
aria-labelledby="server-instructions-label"
/>
)}
/>
{serverInstructionsMode === 'custom' && (
<Textarea
placeholder={localize('com_ui_mcp_server_instructions_custom_placeholder')}
aria-label={localize('com_ui_mcp_server_instructions_custom')}
{...register('serverInstructionsCustom')}
className="mt-1 text-sm"
/>
)}
</div>
</div>
);
}

View file

@ -16,6 +16,7 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
const localize = useLocalize();
const { showToast } = useToastContext();
const {
control,
register,
setValue,
formState: { errors },
@ -24,14 +25,17 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
const [isCopying, setIsCopying] = useState(false);
const authType = useWatch<MCPServerFormData, 'auth.auth_type'>({
control,
name: 'auth.auth_type',
}) as AuthTypeEnum;
const apiKeySource = useWatch<MCPServerFormData, 'auth.api_key_source'>({
control,
name: 'auth.api_key_source',
}) as 'admin' | 'user';
const authorizationType = useWatch<MCPServerFormData, 'auth.api_key_authorization_type'>({
control,
name: 'auth.api_key_authorization_type',
}) as AuthorizationTypeEnum;

View file

@ -0,0 +1,149 @@
import { Plus, Trash2 } from 'lucide-react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Input, Label, Button, Textarea } from '@librechat/client';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const VARIABLE_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
export default function CustomUserVarsDefinitionSection() {
const localize = useLocalize();
const {
register,
control,
formState: { errors },
} = useFormContext<MCPServerFormData>();
const { fields, append, remove } = useFieldArray({ control, name: 'customUserVars' });
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{localize('com_ui_mcp_custom_user_vars_definition')}
</Label>
<p className="text-xs text-text-secondary">
{localize('com_ui_mcp_custom_user_vars_definition_description')}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: '', title: '', description: '' })}
className="h-7 shrink-0 gap-1 px-2 text-xs"
>
<Plus className="size-3" aria-hidden="true" />
{localize('com_ui_mcp_add_variable')}
</Button>
</div>
{fields.length === 0 ? (
<p className="rounded-lg border border-dashed border-border-light px-3 py-2 text-center text-xs text-text-secondary">
{localize('com_ui_mcp_no_custom_vars')}
</p>
) : (
<div className="space-y-3 rounded-lg border border-border-light p-3">
{fields.map((field, index) => (
<div key={field.id} className="space-y-2 rounded border border-border-light p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-text-secondary">
{localize('com_ui_mcp_variable')} {index + 1}
</span>
<button
type="button"
onClick={() => remove(index)}
className="flex size-6 items-center justify-center rounded text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-destructive"
aria-label={localize('com_ui_delete')}
>
<Trash2 className="size-3.5" aria-hidden="true" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor={`customUserVars.${index}.key`} className="text-xs font-medium">
{localize('com_ui_mcp_variable_key')}{' '}
<span aria-hidden="true" className="text-text-secondary">
*
</span>
<span className="sr-only">{localize('com_ui_field_required')}</span>
</Label>
<Input
id={`customUserVars.${index}.key`}
placeholder={localize('com_ui_mcp_variable_key_placeholder')}
aria-invalid={errors.customUserVars?.[index]?.key ? 'true' : 'false'}
{...register(`customUserVars.${index}.key`, {
required: localize('com_ui_field_required'),
pattern: {
value: VARIABLE_KEY_PATTERN,
message: localize('com_ui_mcp_variable_key_invalid'),
},
})}
className={cn(
'font-mono text-xs',
errors.customUserVars?.[index]?.key && 'border-border-destructive',
)}
/>
{errors.customUserVars?.[index]?.key && (
<p role="alert" className="text-xs text-text-destructive">
{errors.customUserVars[index].key?.message}
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor={`customUserVars.${index}.title`} className="text-xs font-medium">
{localize('com_ui_mcp_variable_title')}{' '}
<span aria-hidden="true" className="text-text-secondary">
*
</span>
<span className="sr-only">{localize('com_ui_field_required')}</span>
</Label>
<Input
id={`customUserVars.${index}.title`}
placeholder={localize('com_ui_mcp_variable_title_placeholder')}
aria-invalid={errors.customUserVars?.[index]?.title ? 'true' : 'false'}
{...register(`customUserVars.${index}.title`, {
required: localize('com_ui_field_required'),
validate: (value) =>
value.trim().length > 0 || localize('com_ui_field_required'),
})}
className={cn(
'text-xs',
errors.customUserVars?.[index]?.title && 'border-border-destructive',
)}
/>
{errors.customUserVars?.[index]?.title && (
<p role="alert" className="text-xs text-text-destructive">
{errors.customUserVars[index].title?.message}
</p>
)}
</div>
</div>
<div className="space-y-1">
<Label
htmlFor={`customUserVars.${index}.description`}
className="text-xs font-medium"
>
{localize('com_ui_description')}{' '}
<span className="text-xs text-text-secondary">{localize('com_ui_optional')}</span>
</Label>
<Textarea
id={`customUserVars.${index}.description`}
placeholder={localize('com_ui_mcp_variable_description_placeholder')}
rows={2}
{...register(`customUserVars.${index}.description`)}
className="resize-none text-xs"
/>
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,314 @@
import { useState, useRef, useEffect } from 'react';
import { Plus, Trash2, Lock, LockOpen, ChevronDown } from 'lucide-react';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import {
Input,
Label,
Button,
SecretInput,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@librechat/client';
import type { MCPServerFormData, CustomUserVarEntry } from '../hooks/useMCPServerForm';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const ENV_VAR_PATTERN = /\$\{[^}]+\}/;
interface HeaderRowProps {
index: number;
onRemove: () => void;
availableVars: CustomUserVarEntry[];
isEditMode: boolean;
initialHeaderKeys: Set<string>;
initialSecretHeaderKeys: Set<string>;
}
function HeaderRow({
index,
onRemove,
availableVars,
isEditMode,
initialHeaderKeys,
initialSecretHeaderKeys,
}: HeaderRowProps) {
const localize = useLocalize();
const {
control,
register,
setValue,
getValues,
formState: { errors },
} = useFormContext<MCPServerFormData>();
const [showVarMenu, setShowVarMenu] = useState(false);
const isSecret = useWatch<MCPServerFormData, `headers.${number}.isSecret`>({
control,
name: `headers.${index}.isSecret`,
});
const headerKey =
useWatch<MCPServerFormData, `headers.${number}.key`>({
control,
name: `headers.${index}.key`,
}) ?? '';
// In edit mode, only allow blank values for headers that were initially secret.
// New secret headers (including ones that were previously non-secret) must have a value.
const isInitiallySecretHeader =
initialSecretHeaderKeys.has(headerKey.trim()) && initialHeaderKeys.has(headerKey.trim());
const isNewSecretHeader = isSecret && isEditMode && !isInitiallySecretHeader;
const insertVariable = (varKey: string) => {
const current = getValues(`headers.${index}.value`) ?? '';
setValue(`headers.${index}.value`, `${current}{{${varKey}}}`, {
shouldDirty: true,
shouldValidate: true,
});
setShowVarMenu(false);
};
const toggleSecret = () => {
setValue(`headers.${index}.isSecret`, !isSecret, { shouldDirty: true });
if (isSecret) {
// Switching from secret → non-secret: clear the masked/empty value so user enters new one
setValue(`headers.${index}.value`, '', { shouldDirty: true });
}
};
return (
<div className="flex items-start gap-1.5">
{/* Key input */}
<div className="w-2/5 space-y-1">
<Input
placeholder={localize('com_ui_mcp_header_key_placeholder')}
aria-label={localize('com_ui_mcp_header_key')}
aria-invalid={errors.headers?.[index]?.key ? 'true' : 'false'}
{...register(`headers.${index}.key`, {
required: localize('com_ui_field_required'),
validate: (value) => value.trim().length > 0 || localize('com_ui_field_required'),
})}
className={cn('text-xs', errors.headers?.[index]?.key && 'border-border-destructive')}
/>
{errors.headers?.[index]?.key && (
<p role="alert" className="text-xs text-text-destructive">
{errors.headers[index].key?.message}
</p>
)}
</div>
{/* Value input (regular or secret) + optional variable picker */}
<div className="flex min-w-0 flex-1 items-start gap-1">
<div className="flex-1 space-y-1">
{isSecret ? (
<SecretInput
placeholder={
isEditMode && !isNewSecretHeader
? localize('com_ui_mcp_header_value_secret_placeholder')
: localize('com_ui_mcp_header_value_placeholder')
}
aria-label={localize('com_ui_mcp_header_value')}
aria-invalid={errors.headers?.[index]?.value ? 'true' : 'false'}
{...register(`headers.${index}.value`, {
required: (!isEditMode || isNewSecretHeader) && localize('com_ui_field_required'),
validate: (v) => {
// Reject whitespace-only values
if (v && v.trim().length === 0) {
return localize('com_ui_field_required');
}
// Reject env var patterns
if (ENV_VAR_PATTERN.test(v)) {
return localize('com_ui_mcp_header_env_var_not_allowed');
}
return true;
},
})}
className={cn(
'text-xs',
errors.headers?.[index]?.value && 'border-border-destructive',
)}
/>
) : (
<Input
placeholder={localize('com_ui_mcp_header_value_placeholder')}
aria-label={localize('com_ui_mcp_header_value')}
aria-invalid={errors.headers?.[index]?.value ? 'true' : 'false'}
{...register(`headers.${index}.value`, {
required: localize('com_ui_field_required'),
validate: (v) => {
// Reject whitespace-only values
if (v && v.trim().length === 0) {
return localize('com_ui_field_required');
}
// Reject env var patterns
if (ENV_VAR_PATTERN.test(v)) {
return localize('com_ui_mcp_header_env_var_not_allowed');
}
return true;
},
})}
className={cn(
'text-xs',
errors.headers?.[index]?.value && 'border-border-destructive',
)}
/>
)}
{errors.headers?.[index]?.value && (
<p role="alert" className="text-xs text-text-destructive">
{errors.headers[index].value?.message}
</p>
)}
</div>
{/* Variable picker — only for non-secret headers with available vars */}
{!isSecret && availableVars.length > 0 && (
<DropdownMenu open={showVarMenu} onOpenChange={setShowVarMenu}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="mt-0.5 flex h-9 shrink-0 items-center gap-0.5 rounded border border-border-light px-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
aria-label={localize('com_ui_mcp_insert_variable')}
title={localize('com_ui_mcp_insert_variable')}
>
<span className="max-w-[3.5rem] truncate font-mono leading-none">{'{{…}}'}</span>
<ChevronDown className="size-3 shrink-0" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[160] min-w-[10rem]">
{availableVars.map(({ key, title }) => (
<DropdownMenuItem
key={key}
onSelect={() => insertVariable(key)}
className="cursor-pointer gap-2 text-xs"
>
<span className="font-mono text-text-secondary">{`{{${key}}}`}</span>
<span className="text-text-primary">{title}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Secret toggle */}
<button
type="button"
onClick={toggleSecret}
className={cn(
'mt-0.5 flex size-8 shrink-0 items-center justify-center rounded border transition-colors',
isSecret
? 'border-amber-300 bg-amber-50 text-amber-600 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-400 dark:hover:bg-amber-900'
: 'border-border-light text-text-secondary hover:bg-surface-hover hover:text-text-primary',
)}
aria-label={
isSecret ? localize('com_ui_mcp_mark_not_secret') : localize('com_ui_mcp_mark_secret')
}
aria-pressed={!!isSecret}
title={
isSecret ? localize('com_ui_mcp_mark_not_secret') : localize('com_ui_mcp_mark_secret')
}
>
{isSecret ? (
<Lock className="size-3.5" aria-hidden="true" />
) : (
<LockOpen className="size-3.5" aria-hidden="true" />
)}
</button>
{/* Delete */}
<button
type="button"
onClick={onRemove}
className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-destructive"
aria-label={localize('com_ui_delete')}
>
<Trash2 className="size-3.5" aria-hidden="true" />
</button>
</div>
);
}
interface HeadersSectionProps {
isEditMode: boolean;
}
export default function HeadersSection({ isEditMode }: HeadersSectionProps) {
const localize = useLocalize();
const { control } = useFormContext<MCPServerFormData>();
const { fields, append, remove } = useFieldArray({ control, name: 'headers' });
// Track initial header keys to distinguish existing headers from newly added ones
const initialHeaderKeysRef = useRef<Set<string>>(new Set());
// Track which headers were initially secret (not just which existed)
const initialSecretHeaderKeysRef = useRef<Set<string>>(new Set());
// Capture initial header keys and secret status when the field array is first populated
useEffect(() => {
if (initialHeaderKeysRef.current.size === 0 && fields.length > 0) {
const keys = new Set<string>();
const secretKeys = new Set<string>();
fields.forEach((f) => {
const key = ((f as { key?: string }).key ?? '').trim();
const isSecret = !!(f as { isSecret?: boolean }).isSecret;
if (key) {
keys.add(key);
if (isSecret) {
secretKeys.add(key);
}
}
});
initialHeaderKeysRef.current = keys;
initialSecretHeaderKeysRef.current = secretKeys;
}
}, [fields]);
const availableVars =
useWatch<MCPServerFormData, 'customUserVars'>({
control,
name: 'customUserVars',
}) ?? [];
const validVars = (availableVars ?? []).filter((v) => v.key.trim() && v.title.trim());
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">{localize('com_ui_mcp_headers')}</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: '', value: '', isSecret: false })}
className="h-7 gap-1 px-2 text-xs"
>
<Plus className="size-3" aria-hidden="true" />
{localize('com_ui_mcp_add_header')}
</Button>
</div>
{fields.length === 0 ? (
<p className="rounded-lg border border-dashed border-border-light px-3 py-2 text-center text-xs text-text-secondary">
{localize('com_ui_mcp_no_headers')}
</p>
) : (
<div className="space-y-2 rounded-lg border border-border-light p-3">
{fields.map((field, index) => (
<HeaderRow
key={field.id}
index={index}
onRemove={() => remove(index)}
availableVars={validVars}
isEditMode={isEditMode}
initialHeaderKeys={initialHeaderKeysRef.current}
initialSecretHeaderKeys={initialSecretHeaderKeysRef.current}
/>
))}
</div>
)}
</div>
);
}

View file

@ -6,9 +6,10 @@ import type { MCPServerFormData } from '../hooks/useMCPServerForm';
export default function TransportSection() {
const localize = useLocalize();
const { setValue } = useFormContext<MCPServerFormData>();
const { control, setValue } = useFormContext<MCPServerFormData>();
const transportType = useWatch<MCPServerFormData, 'type'>({
control,
name: 'type',
});

View file

@ -0,0 +1,73 @@
/**
* Pure utility functions for building MCP server config payloads.
* These are extracted to be shared between the hook and tests to ensure
* test logic matches production logic exactly.
*/
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
/**
* Builds the headers map and secretHeaderKeys array from form data.
* @param headers - Array of header entries from the form
* @param isEditMode - Whether the form is in edit mode (allows blank secret values)
* @returns Object with headers map and secretHeaderKeys array, or empty object if no valid headers
*/
export function buildHeaders(
headers: MCPServerFormData['headers'],
isEditMode = false,
): {
headers?: Record<string, string>;
secretHeaderKeys?: string[];
} {
if (headers.length === 0) {
return {};
}
const headersMap: Record<string, string> = {};
const secretHeaderKeysList: string[] = [];
for (const { key, value, isSecret } of headers) {
const trimmedKey = key.trim();
const trimmedValue = value.trim();
if (!trimmedKey) {
continue;
}
// For non-secret headers, skip blank values (no point sending an empty header).
// For secret headers, only allow blank values in edit mode (to keep existing secrets).
if (!trimmedValue && (!isSecret || !isEditMode)) {
continue;
}
headersMap[trimmedKey] = trimmedValue;
if (isSecret) {
secretHeaderKeysList.push(trimmedKey);
}
}
if (Object.keys(headersMap).length === 0) {
return {};
}
// Deduplicate to avoid inconsistent payloads if form has duplicate keys
return { headers: headersMap, secretHeaderKeys: Array.from(new Set(secretHeaderKeysList)) };
}
/**
* Builds the customUserVars map from form data.
* @param vars - Array of custom user variable entries from the form
* @returns Record mapping variable keys to their title/description, or undefined if none
*/
export function buildCustomUserVars(
vars: MCPServerFormData['customUserVars'],
): Record<string, { title: string; description: string }> | undefined {
if (vars.length === 0) {
return undefined;
}
const map: Record<string, { title: string; description: string }> = {};
for (const { key, title, description } of vars) {
const trimmedKey = key.trim();
const trimmedTitle = title.trim();
if (trimmedKey && trimmedTitle) {
map[trimmedKey] = {
title: trimmedTitle,
description: description.trim(),
};
}
}
return Object.keys(map).length > 0 ? map : undefined;
}

View file

@ -1165,6 +1165,38 @@
"com_ui_mcp_unprogrammatic_all": "Unmark all as programmatic",
"com_ui_mcp_update_var": "Update {{0}}",
"com_ui_mcp_url": "MCP Server URL",
"com_ui_mcp_headers": "HTTP Headers",
"com_ui_mcp_add_header": "Add header",
"com_ui_mcp_no_headers": "No headers configured",
"com_ui_mcp_header_key": "Header name",
"com_ui_mcp_header_key_placeholder": "X-Custom-Header",
"com_ui_mcp_header_value": "Header value",
"com_ui_mcp_header_value_placeholder": "value or {{MY_VAR}}",
"com_ui_mcp_header_value_secret_placeholder": "Leave blank to keep existing value",
"com_ui_mcp_header_env_var_not_allowed": "Environment variable references (${...}) are not allowed in header values",
"com_ui_mcp_mark_secret": "Mark as secret — value will be encrypted",
"com_ui_mcp_mark_not_secret": "Remove secret flag",
"com_ui_mcp_insert_variable": "Insert custom variable",
"com_ui_mcp_custom_user_vars_definition": "Custom User Variables",
"com_ui_mcp_custom_user_vars_definition_description": "Define variables that users must configure before using this server",
"com_ui_mcp_add_variable": "Add variable",
"com_ui_mcp_no_custom_vars": "No custom variables defined",
"com_ui_mcp_variable": "Variable",
"com_ui_mcp_variable_key": "Variable key",
"com_ui_mcp_variable_key_placeholder": "MY_VARIABLE",
"com_ui_mcp_variable_key_invalid": "Variable key must start with a letter and contain only letters, numbers, and underscores",
"com_ui_mcp_variable_title": "Display title",
"com_ui_mcp_variable_title_placeholder": "My Variable",
"com_ui_mcp_variable_description_placeholder": "Describe what value the user should enter",
"com_ui_mcp_advanced": "Advanced",
"com_ui_mcp_chat_menu": "Show in chat menu",
"com_ui_mcp_chat_menu_description": "Display this server in the chat menu for quick access",
"com_ui_mcp_server_instructions": "Server Instructions",
"com_ui_mcp_server_instructions_description": "Controls how server instructions are included in AI prompts",
"com_ui_mcp_server_instructions_none": "None",
"com_ui_mcp_server_instructions_server": "Use server-provided",
"com_ui_mcp_server_instructions_custom": "Custom",
"com_ui_mcp_server_instructions_custom_placeholder": "Enter custom instructions for this server...",
"com_ui_medium": "Medium",
"com_ui_memories": "Memories",
"com_ui_memories_allow_create": "Allow creating Memories",

View file

@ -132,7 +132,7 @@ describe('redactServerSecrets', () => {
expect(redacted.oauth?.client_id).toBe('cid');
});
it('should exclude headers from SSE configs', () => {
it('should exclude headers from YAML/non-UI configs (secretHeaderKeys undefined)', () => {
const config: ParsedServerConfig = {
type: 'sse',
url: 'https://example.com/mcp',
@ -147,6 +147,75 @@ describe('redactServerSecrets', () => {
expect(redacted.title).toBe('SSE Server');
});
it('should expose non-secret headers for UI-created servers (secretHeaderKeys defined)', () => {
const config: ParsedServerConfig = {
type: 'sse',
url: 'https://example.com/mcp',
title: 'SSE Server',
dbId: '507f1f77bcf86cd799439011',
secretHeaderKeys: [],
};
(config as ParsedServerConfig & { headers: Record<string, string> }).headers = {
'X-Index-Name': 'my-index',
'X-Top': '{{INDEX_TOP}}',
};
const redacted = redactServerSecrets(config);
expect(
(redacted as Record<string, unknown> & { headers: Record<string, string> }).headers,
).toEqual({ 'X-Index-Name': 'my-index', 'X-Top': '{{INDEX_TOP}}' });
expect(redacted.secretHeaderKeys).toEqual([]);
});
it('should mask secret header values as empty string and expose non-secret values', () => {
const config: ParsedServerConfig = {
type: 'streamable-http',
url: 'https://example.com/mcp',
dbId: '507f1f77bcf86cd799439012',
secretHeaderKeys: ['X-Secret-Token'],
};
(config as ParsedServerConfig & { headers: Record<string, string> }).headers = {
'X-Secret-Token': 'super-secret-value',
'X-Public-Header': 'public-value',
};
const redacted = redactServerSecrets(config);
const headers = (redacted as Record<string, unknown> & { headers: Record<string, string> })
.headers;
expect(headers['X-Secret-Token']).toBe('');
expect(headers['X-Public-Header']).toBe('public-value');
expect(redacted.secretHeaderKeys).toEqual(['X-Secret-Token']);
});
it('should expose secretHeaderKeys: [] for UI servers with all non-secret headers', () => {
const config: ParsedServerConfig = {
type: 'sse',
url: 'https://example.com/mcp',
dbId: '507f1f77bcf86cd799439013',
secretHeaderKeys: [],
};
(config as ParsedServerConfig & { headers: Record<string, string> }).headers = {
'X-Custom': 'value',
};
const redacted = redactServerSecrets(config);
expect(redacted.secretHeaderKeys).toEqual([]);
});
it('should exclude headers from YAML configs even with secretHeaderKeys present', () => {
const config: ParsedServerConfig = {
type: 'sse',
url: 'https://example.com/mcp',
title: 'YAML Server',
secretHeaderKeys: ['X-Api-Key'],
};
(config as ParsedServerConfig & { headers: Record<string, string> }).headers = {
'X-Api-Key': '${API_KEY_ENV}',
'X-Index': '${INDEX_NAME}',
};
const redacted = redactServerSecrets(config);
expect((redacted as Record<string, unknown>).headers).toBeUndefined();
expect((redacted as Record<string, unknown>).secretHeaderKeys).toBeUndefined();
expect(redacted.title).toBe('YAML Server');
});
it('should exclude env from stdio configs', () => {
const config: ParsedServerConfig = {
type: 'stdio',
@ -225,6 +294,56 @@ describe('redactServerSecrets', () => {
expect(redacted.customUserVars).toEqual(config.customUserVars);
});
it('should preserve serverInstructions: true', () => {
const config: ParsedServerConfig = {
type: 'sse',
url: 'https://example.com/mcp',
serverInstructions: true,
};
const redacted = redactServerSecrets(config);
expect(redacted.serverInstructions).toBe(true);
});
it('should preserve serverInstructions as a custom string', () => {
const config: ParsedServerConfig = {
type: 'sse',
url: 'https://example.com/mcp',
serverInstructions: 'Always respond concisely.',
};
const redacted = redactServerSecrets(config);
expect(redacted.serverInstructions).toBe('Always respond concisely.');
});
it('should omit serverInstructions when not set', () => {
const config: ParsedServerConfig = {
type: 'sse',
url: 'https://example.com/mcp',
};
const redacted = redactServerSecrets(config);
expect(redacted.serverInstructions).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(redacted, 'serverInstructions')).toBe(false);
});
it('should preserve chatMenu: false', () => {
const config: ParsedServerConfig = {
type: 'sse',
url: 'https://example.com/mcp',
chatMenu: false,
};
const redacted = redactServerSecrets(config);
expect(redacted.chatMenu).toBe(false);
});
it('should omit chatMenu when not set', () => {
const config: ParsedServerConfig = {
type: 'sse',
url: 'https://example.com/mcp',
};
const redacted = redactServerSecrets(config);
expect(redacted.chatMenu).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(redacted, 'chatMenu')).toBe(false);
});
it('should pass URLs through unchanged', () => {
const config: ParsedServerConfig = {
type: 'sse',

View file

@ -507,6 +507,185 @@ describe('ServerConfigsDB', () => {
expect(retrievedWithHeaders?.headers?.['X-My-Api-Key']).toBe('{{MCP_API_KEY}}');
expect(retrievedWithHeaders?.headers?.Authorization).toBeUndefined();
});
it('should encrypt secret header values when saving to database', async () => {
const config: ParsedServerConfig & {
headers?: Record<string, string>;
secretHeaderKeys?: string[];
} = {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Secret Header Encryption Test',
headers: {
'X-Public-Header': 'public-value',
'X-Secret-Token': 'super-secret-token',
},
secretHeaderKeys: ['X-Secret-Token'],
};
const created = await serverConfigsDB.add('temp-name', config, userId);
// Verify the secret header value is encrypted in DB (not plaintext)
const MCPServer = mongoose.models.MCPServer;
const server = await MCPServer.findOne({ serverName: created.serverName });
const dbConfig = server?.config as ParsedServerConfig & { headers?: Record<string, string> };
expect(dbConfig?.headers?.['X-Secret-Token']).not.toBe('super-secret-token');
expect(dbConfig?.headers?.['X-Public-Header']).toBe('public-value');
expect(dbConfig?.secretHeaderKeys).toEqual(['X-Secret-Token']);
// Verify the secret is decrypted when accessed via get()
const retrieved = (await serverConfigsDB.get(
created.serverName,
userId,
)) as ParsedServerConfig & {
headers?: Record<string, string>;
};
expect(retrieved?.headers?.['X-Secret-Token']).toBe('super-secret-token');
expect(retrieved?.headers?.['X-Public-Header']).toBe('public-value');
});
it('should preserve secret header values when not provided in update', async () => {
const config: ParsedServerConfig & {
headers?: Record<string, string>;
secretHeaderKeys?: string[];
} = {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Secret Header Preserve Test',
headers: {
'X-Public-Header': 'public-value',
'X-Secret-Token': 'original-secret',
},
secretHeaderKeys: ['X-Secret-Token'],
};
const created = await serverConfigsDB.add('temp-name', config, userId);
// Update without providing the secret header value (empty string means "preserve existing")
const updatedConfig: ParsedServerConfig & {
headers?: Record<string, string>;
secretHeaderKeys?: string[];
} = {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Secret Header Preserve Test',
description: 'Updated description',
headers: {
'X-Public-Header': 'updated-public-value',
'X-Secret-Token': '', // Empty means preserve existing
},
secretHeaderKeys: ['X-Secret-Token'],
};
await serverConfigsDB.update(created.serverName, updatedConfig, userId);
// Verify the secret is still encrypted in DB (preserved, not plaintext)
const MCPServer = mongoose.models.MCPServer;
const server = await MCPServer.findOne({ serverName: created.serverName });
const dbConfig = server?.config as ParsedServerConfig & { headers?: Record<string, string> };
expect(dbConfig?.headers?.['X-Secret-Token']).not.toBe('original-secret');
expect(dbConfig?.headers?.['X-Public-Header']).toBe('updated-public-value');
// Verify the secret is decrypted when accessed via get()
const retrieved = (await serverConfigsDB.get(
created.serverName,
userId,
)) as ParsedServerConfig & {
headers?: Record<string, string>;
};
expect(retrieved?.headers?.['X-Secret-Token']).toBe('original-secret');
expect(retrieved?.headers?.['X-Public-Header']).toBe('updated-public-value');
expect(retrieved?.description).toBe('Updated description');
});
it('should allow updating secret header value when explicitly provided', async () => {
const config: ParsedServerConfig & {
headers?: Record<string, string>;
secretHeaderKeys?: string[];
} = {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Secret Header Update Test',
headers: {
'X-Secret-Token': 'old-secret',
},
secretHeaderKeys: ['X-Secret-Token'],
};
const created = await serverConfigsDB.add('temp-name', config, userId);
// Update with new secret value
const updatedConfig: ParsedServerConfig & {
headers?: Record<string, string>;
secretHeaderKeys?: string[];
} = {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Secret Header Update Test',
headers: {
'X-Secret-Token': 'new-secret',
},
secretHeaderKeys: ['X-Secret-Token'],
};
await serverConfigsDB.update(created.serverName, updatedConfig, userId);
// Verify the secret is encrypted in DB (not plaintext)
const MCPServer = mongoose.models.MCPServer;
const server = await MCPServer.findOne({ serverName: created.serverName });
const dbConfig = server?.config as ParsedServerConfig & { headers?: Record<string, string> };
expect(dbConfig?.headers?.['X-Secret-Token']).not.toBe('new-secret');
// Verify the secret is decrypted to the new value when accessed via get()
const retrieved = (await serverConfigsDB.get(
created.serverName,
userId,
)) as ParsedServerConfig & {
headers?: Record<string, string>;
};
expect(retrieved?.headers?.['X-Secret-Token']).toBe('new-secret');
});
it('should handle multiple secret headers with mixed preservation', async () => {
const config: ParsedServerConfig & {
headers?: Record<string, string>;
secretHeaderKeys?: string[];
} = {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Multiple Secret Headers Test',
headers: {
'X-Secret-One': 'secret-one',
'X-Secret-Two': 'secret-two',
'X-Public': 'public',
},
secretHeaderKeys: ['X-Secret-One', 'X-Secret-Two'],
};
const created = await serverConfigsDB.add('temp-name', config, userId);
// Update: preserve X-Secret-One (empty), update X-Secret-Two, update X-Public
const updatedConfig: ParsedServerConfig & {
headers?: Record<string, string>;
secretHeaderKeys?: string[];
} = {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Multiple Secret Headers Test',
headers: {
'X-Secret-One': '', // Preserve existing
'X-Secret-Two': 'new-secret-two', // Update
'X-Public': 'updated-public',
},
secretHeaderKeys: ['X-Secret-One', 'X-Secret-Two'],
};
await serverConfigsDB.update(created.serverName, updatedConfig, userId);
// Verify via get()
const retrieved = (await serverConfigsDB.get(
created.serverName,
userId,
)) as ParsedServerConfig & {
headers?: Record<string, string>;
};
expect(retrieved?.headers?.['X-Secret-One']).toBe('secret-one'); // Preserved
expect(retrieved?.headers?.['X-Secret-Two']).toBe('new-secret-two'); // Updated
expect(retrieved?.headers?.['X-Public']).toBe('updated-public');
});
});
describe('credential placeholder sanitization', () => {

View file

@ -217,6 +217,45 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
};
}
// Retain existing encrypted values for secret headers that were submitted with blank values.
// A blank value means "don't change this secret header" (same pattern as oauth.client_secret).
// Only preserve when:
// 1. The key is explicitly present with an empty string (not omitted entirely)
// 2. The key was already secret in the existing config (to avoid copying plaintext as encrypted)
const newSecretKeys = configToSave.secretHeaderKeys;
if (newSecretKeys?.length && existingServer?.config) {
const existingHeaders = (
existingServer.config as ParsedServerConfig & { headers?: Record<string, string> }
).headers;
const existingSecretKeys = new Set(
(existingServer.config as ParsedServerConfig & { secretHeaderKeys?: string[] })
.secretHeaderKeys ?? [],
);
const currentHeaders = (
configToSave as ParsedServerConfig & { headers?: Record<string, string> }
).headers;
if (existingHeaders && currentHeaders) {
const retainedHeaders = { ...currentHeaders };
for (const secretKey of newSecretKeys) {
// Check if the header key exists in the payload with an explicitly blank string value.
// If the key is omitted entirely, allow removal; only preserve on blank string (masked).
// Also verify this key was already secret to avoid treating plaintext as encrypted.
if (
Object.prototype.hasOwnProperty.call(retainedHeaders, secretKey) &&
retainedHeaders[secretKey] === '' &&
existingSecretKeys.has(secretKey)
) {
const existingValue = existingHeaders[secretKey];
if (existingValue) {
retainedHeaders[secretKey] = existingValue;
}
}
}
(configToSave as ParsedServerConfig & { headers?: Record<string, string> }).headers =
retainedHeaders;
}
}
await this._dbMethods.updateMCPServer(serverName, { config: configToSave });
}
@ -489,7 +528,8 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
/**
* Encrypts sensitive fields in config before database storage.
* Encrypts oauth.client_secret and apiKey.key (when source === 'admin').
* Encrypts oauth.client_secret, apiKey.key (when source === 'admin'),
* and header values listed in secretHeaderKeys.
* Throws on failure to prevent storing plaintext secrets.
*/
private async encryptConfig(config: ParsedServerConfig): Promise<ParsedServerConfig> {
@ -522,12 +562,41 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
}
}
const secretHeaderKeys = result.secretHeaderKeys;
if (secretHeaderKeys?.length) {
const resultWithHeaders = result as ParsedServerConfig & { headers?: Record<string, string> };
if (resultWithHeaders.headers) {
const encryptedHeaders = { ...resultWithHeaders.headers };
// Deduplicate to avoid double-encryption
const uniqueSecretKeys = Array.from(new Set(secretHeaderKeys));
for (const secretKey of uniqueSecretKeys) {
const val = encryptedHeaders[secretKey];
if (val) {
try {
encryptedHeaders[secretKey] = await encryptV2(val);
} catch (error) {
logger.error(
`[ServerConfigsDB.encryptConfig] Failed to encrypt header "${secretKey}"`,
error,
);
throw new Error('Failed to encrypt MCP server configuration');
}
}
}
resultWithHeaders.headers = encryptedHeaders;
// Normalize secretHeaderKeys to the deduped list for consistency
result.secretHeaderKeys = uniqueSecretKeys;
result = resultWithHeaders as ParsedServerConfig;
}
}
return result;
}
/**
* Decrypts sensitive fields in config after database retrieval.
* Decrypts oauth.client_secret and apiKey.key (when source === 'admin').
* Decrypts oauth.client_secret, apiKey.key (when source === 'admin'),
* and header values listed in secretHeaderKeys.
* Returns config without secret on failure (graceful degradation).
*/
private async decryptConfig(config: ParsedServerConfig): Promise<ParsedServerConfig> {
@ -574,6 +643,34 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
}
}
const secretHeaderKeys = result.secretHeaderKeys;
if (secretHeaderKeys?.length) {
const resultWithHeaders = result as ParsedServerConfig & { headers?: Record<string, string> };
if (resultWithHeaders.headers) {
const decryptedHeaders = { ...resultWithHeaders.headers };
// Deduplicate to avoid double-decryption
const uniqueSecretKeys = Array.from(new Set(secretHeaderKeys));
for (const secretKey of uniqueSecretKeys) {
const val = decryptedHeaders[secretKey];
if (val) {
try {
decryptedHeaders[secretKey] = await decryptV2(val);
} catch (error) {
logger.warn(
`[ServerConfigsDB.decryptConfig] Failed to decrypt header "${secretKey}", returning empty value`,
error,
);
decryptedHeaders[secretKey] = '';
}
}
}
resultWithHeaders.headers = decryptedHeaders;
// Normalize secretHeaderKeys to the deduped list for consistency
result.secretHeaderKeys = uniqueSecretKeys;
result = resultWithHeaders as ParsedServerConfig;
}
}
return result;
}
}

View file

@ -168,6 +168,12 @@ export type ParsedServerConfig = MCPOptions & {
consumeOnly?: boolean;
/** True when inspection failed at startup; the server is known but not fully initialized */
inspectionFailed?: boolean;
/**
* Keys in `headers` whose values are encrypted at rest for DB-stored server configs
* (and masked in API responses); YAML-defined configs remain plaintext on disk and
* are not exposed via API responses.
*/
secretHeaderKeys?: string[];
};
export type AddServerResult = {

View file

@ -61,6 +61,38 @@ export function redactServerSecrets(config: ParsedServerConfig): Partial<ParsedS
safe.oauth = safeOAuth;
}
//
// Only DB-backed configs should have headers round-trip through the API. YAML configs
// (even those with secretHeaderKeys via SSEOptionsSchema/StreamableHTTPOptionsSchema)
// are excluded to prevent exposing env-resolved header values in API responses.
const rawHeaders = (config as ParsedServerConfig & { headers?: Record<string, string> }).headers;
const hasHeaders = !!rawHeaders && Object.keys(rawHeaders).length > 0;
const isDbBacked = config.dbId !== undefined;
const isLegacyDbHeaders = isDbBacked && hasHeaders && config.secretHeaderKeys === undefined;
// Treat as "UI-managed" only if DB-backed AND has headers or secretHeaderKeys.
const isUiManaged = isDbBacked && (config.secretHeaderKeys !== undefined || hasHeaders);
if (isUiManaged && rawHeaders) {
const maskedHeaders: Record<string, string> = {};
if (isLegacyDbHeaders) {
// Legacy DB configs with headers but no secretHeaderKeys are treated as non-secret
// headers to avoid data loss on edit/save round-trips. Pass headers through unchanged
// and intentionally do NOT emit secretHeaderKeys so the client doesn't treat them as secrets.
for (const [k, v] of Object.entries(rawHeaders)) {
maskedHeaders[k] = v;
}
(safe as ParsedServerConfig & { headers?: Record<string, string> }).headers = maskedHeaders;
} else {
// Modern configs with secretHeaderKeys: mask secret values, pass through non-secrets.
const secretKeys = new Set(config.secretHeaderKeys ?? []);
for (const [k, v] of Object.entries(rawHeaders)) {
maskedHeaders[k] = secretKeys.has(k) ? '' : v;
}
(safe as ParsedServerConfig & { headers?: Record<string, string> }).headers = maskedHeaders;
safe.secretHeaderKeys = config.secretHeaderKeys ?? [];
}
}
return Object.fromEntries(
Object.entries(safe).filter(([, v]) => v !== undefined),
) as Partial<ParsedServerConfig>;

View file

@ -145,3 +145,116 @@ describe('MCPServerUserInputSchema', () => {
});
});
});
describe('MCPServerUserInputSchema chatMenu and serverInstructions', () => {
const validBase = { type: 'sse', url: 'https://mcp-server.com/sse' } as const;
describe('chatMenu', () => {
it('should accept chatMenu: true', () => {
const result = MCPServerUserInputSchema.safeParse({ ...validBase, chatMenu: true });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.chatMenu).toBe(true);
}
});
it('should accept chatMenu: false', () => {
const result = MCPServerUserInputSchema.safeParse({ ...validBase, chatMenu: false });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.chatMenu).toBe(false);
}
});
it('should accept omitted chatMenu (optional)', () => {
const result = MCPServerUserInputSchema.safeParse(validBase);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.chatMenu).toBeUndefined();
}
});
it('should reject non-boolean chatMenu', () => {
const result = MCPServerUserInputSchema.safeParse({ ...validBase, chatMenu: 'yes' });
expect(result.success).toBe(false);
});
});
describe('serverInstructions', () => {
it('should accept serverInstructions: true', () => {
const result = MCPServerUserInputSchema.safeParse({
...validBase,
serverInstructions: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.serverInstructions).toBe(true);
}
});
it('should accept serverInstructions: false', () => {
const result = MCPServerUserInputSchema.safeParse({
...validBase,
serverInstructions: false,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.serverInstructions).toBe(false);
}
});
it('should accept serverInstructions as a custom string', () => {
const result = MCPServerUserInputSchema.safeParse({
...validBase,
serverInstructions: 'Always respond in English.',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.serverInstructions).toBe('Always respond in English.');
}
});
it('should accept omitted serverInstructions (optional)', () => {
const result = MCPServerUserInputSchema.safeParse(validBase);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.serverInstructions).toBeUndefined();
}
});
it('should reject a numeric serverInstructions value', () => {
const result = MCPServerUserInputSchema.safeParse({
...validBase,
serverInstructions: 42,
});
expect(result.success).toBe(false);
});
it('should accept both chatMenu and serverInstructions together', () => {
const result = MCPServerUserInputSchema.safeParse({
...validBase,
chatMenu: false,
serverInstructions: 'Use caution.',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.chatMenu).toBe(false);
expect(result.data.serverInstructions).toBe('Use caution.');
}
});
it('should accept chatMenu and serverInstructions for streamable-http transport', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/mcp',
chatMenu: true,
serverInstructions: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.chatMenu).toBe(true);
expect(result.data.serverInstructions).toBe(true);
}
});
});
});

View file

@ -163,6 +163,12 @@ export const WebSocketOptionsSchema = BaseOptionsSchema.extend({
export const SSEOptionsSchema = BaseOptionsSchema.extend({
type: z.literal('sse').default('sse'),
headers: z.record(z.string(), z.string()).optional(),
/**
* Keys in `headers` whose values are encrypted at rest for DB-stored server configs
* (and masked in API responses); YAML-defined configs remain plaintext on disk and
* are not exposed via API responses.
*/
secretHeaderKeys: z.array(z.string()).optional(),
url: z
.string()
.transform((val: string) => extractEnvVariable(val))
@ -181,6 +187,12 @@ export const SSEOptionsSchema = BaseOptionsSchema.extend({
export const StreamableHTTPOptionsSchema = BaseOptionsSchema.extend({
type: z.union([z.literal('streamable-http'), z.literal('http')]),
headers: z.record(z.string(), z.string()).optional(),
/**
* Keys in `headers` whose values are encrypted at rest for DB-stored server configs
* (and masked in API responses); YAML-defined configs remain plaintext on disk and
* are not exposed via API responses.
*/
secretHeaderKeys: z.array(z.string()).optional(),
url: z
.string()
.transform((val: string) => extractEnvVariable(val))
@ -216,10 +228,7 @@ const omitServerManagedFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T
timeout: true,
sseReadTimeout: true,
initTimeout: true,
chatMenu: true,
serverInstructions: true,
requiresOAuth: true,
customUserVars: true,
oauth_headers: true,
});
@ -242,8 +251,9 @@ const userUrlSchema = (protocolCheck: (val: string) => boolean, message: string)
/**
* MCP Server configuration that comes from UI/API input only.
* Omits server-managed fields like startup, timeout, customUserVars, etc.
* Allows: title, description, url, iconPath, oauth (user credentials)
* Omits server-managed fields like startup, timeout, oauth_headers, etc.
* Allows user-controlled fields such as: title, description, url, iconPath, headers,
* customUserVars, oauth (user credentials), chatMenu, serverInstructions, secretHeaderKeys.
*
* SECURITY: Stdio transport is intentionally excluded from user input.
* Stdio allows arbitrary command execution and should only be configured
@ -256,16 +266,51 @@ const userUrlSchema = (protocolCheck: (val: string) => boolean, message: string)
* Protocol checks use positive allowlists (http(s) / ws(s)) to block
* file://, ftp://, javascript:, and other non-network schemes.
*/
// Helper to validate secretHeaderKeys is subset of headers
// Uses 'unknown' to work with Zod's omit/extend type transformations
const validateSecretHeaderKeys = (data: unknown) => {
if (
typeof data === 'object' &&
data !== null &&
'secretHeaderKeys' in data &&
Array.isArray(data.secretHeaderKeys) &&
data.secretHeaderKeys.length > 0
) {
if (
!('headers' in data) ||
typeof data.headers !== 'object' ||
data.headers === null ||
Object.keys(data.headers).length === 0
) {
return false;
}
const headerKeys = new Set(Object.keys(data.headers));
return data.secretHeaderKeys.every((key: unknown) => {
return typeof key === 'string' && headerKeys.has(key);
});
}
return true;
};
export const MCPServerUserInputSchema = z.union([
omitServerManagedFields(WebSocketOptionsSchema).extend({
url: userUrlSchema(isWsProtocol, 'WebSocket URL must use ws:// or wss://'),
}),
omitServerManagedFields(SSEOptionsSchema).extend({
url: userUrlSchema(isHttpProtocol, 'SSE URL must use http:// or https://'),
}),
omitServerManagedFields(StreamableHTTPOptionsSchema).extend({
url: userUrlSchema(isHttpProtocol, 'Streamable HTTP URL must use http:// or https://'),
}),
omitServerManagedFields(SSEOptionsSchema)
.extend({
url: userUrlSchema(isHttpProtocol, 'SSE URL must use http:// or https://'),
})
.refine(validateSecretHeaderKeys, {
message: 'secretHeaderKeys must be a subset of header keys',
}),
omitServerManagedFields(StreamableHTTPOptionsSchema)
.extend({
url: userUrlSchema(isHttpProtocol, 'Streamable HTTP URL must use http:// or https://'),
})
.refine(validateSecretHeaderKeys, {
message: 'secretHeaderKeys must be a subset of header keys',
}),
]);
export type MCPServerUserInput = z.infer<typeof MCPServerUserInputSchema>;