mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
feat(mcp): enhance MCP server dialog with headers, custom variables, and comprehensive tests
- Add HTTP headers section with secret toggle and variable picker - Add custom user variables definition section - Add chat menu and server instructions options in advanced section - Implement form validation and encryption for sensitive data - Add comprehensive unit tests (110 tests) for all new components - Fix useWatch synchronization issues in form components - Raise variable picker z-index to avoid dialog overlap
This commit is contained in:
parent
5a373825a5
commit
0ff3b46ebd
20 changed files with 3019 additions and 16 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1162,6 +1162,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",
|
||||
|
|
|
|||
|
|
@ -83,7 +83,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',
|
||||
|
|
@ -98,6 +98,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',
|
||||
|
|
@ -176,6 +245,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',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
@ -469,7 +508,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> {
|
||||
|
|
@ -502,12 +542,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> {
|
||||
|
|
@ -554,6 +623,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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,6 +158,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 = {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -163,6 +163,12 @@ export const WebSocketOptionsSchema = BaseOptionsSchema.extend({
|
|||
export const SSEOptionsSchema = BaseOptionsSchema.extend({
|
||||
type: z.literal('sse').optional(),
|
||||
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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue