diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/MCPServerForm.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/MCPServerForm.tsx index d4096ea96a..994c924b86 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/MCPServerForm.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/MCPServerForm.tsx @@ -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; @@ -12,6 +16,7 @@ interface MCPServerFormProps { export default function MCPServerForm({ formHook }: MCPServerFormProps) { const { methods, isEditMode, server } = formHook; + const localize = useLocalize(); return ( @@ -22,8 +27,19 @@ export default function MCPServerForm({ formHook }: MCPServerFormProps) { + + + + +
+

{localize('com_ui_mcp_advanced')}

+
+ +
+
+
diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/__tests__/AdvancedSection.test.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/__tests__/AdvancedSection.test.tsx new file mode 100644 index 0000000000..3339e29e66 --- /dev/null +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/__tests__/AdvancedSection.test.tsx @@ -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 = { + 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('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) => 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 + >((props, ref) => ActualReact.createElement('textarea', { ref, ...props })), + }; +}); + +// --------------------------------------------------------------------------- +// Wrapper: provides a real react-hook-form context for the component under test +// --------------------------------------------------------------------------- + +interface WrapperProps { + defaultValues?: Partial; +} + +function Wrapper({ defaultValues = {} }: WrapperProps) { + const methods = useForm({ + 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 ( + + + + ); +} + +// --------------------------------------------------------------------------- +// Tests: chatMenu checkbox +// --------------------------------------------------------------------------- + +describe('AdvancedSection – chat menu checkbox', () => { + it('renders the chat menu checkbox with correct label', () => { + render(); + 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(); + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + }); + + it('renders checkbox unchecked when chatMenu is false', () => { + render(); + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + expect(checkbox.checked).toBe(false); + }); + + it('toggles the checkbox on click', () => { + render(); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByRole('radio', { name: 'Use server-provided' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('shows "Custom" selected when mode is "custom"', () => { + render(); + expect(screen.getByRole('radio', { name: 'Custom' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('does not render the custom textarea when mode is "none"', () => { + render(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('does not render the custom textarea when mode is "server"', () => { + render(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('renders the custom textarea when mode is "custom"', () => { + render(); + 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( + , + ); + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; + expect(textarea.value).toBe('Existing instructions.'); + }); + + it('switches from "none" to "server" when radio is clicked', () => { + render(); + 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(); + 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(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('radio', { name: 'None' })); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/__tests__/CustomUserVarsDefinitionSection.test.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/__tests__/CustomUserVarsDefinitionSection.test.tsx new file mode 100644 index 0000000000..a3d9eb1180 --- /dev/null +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/__tests__/CustomUserVarsDefinitionSection.test.tsx @@ -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 = { + 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('react'); + return { + Input: ActualReact.forwardRef>( + (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 + >((props, ref) => ActualReact.createElement('textarea', { ref, ...props })), + }; +}); + +jest.mock('lucide-react', () => ({ + Plus: () => null, + Trash2: () => null, +})); + +// --------------------------------------------------------------------------- +// Wrapper +// --------------------------------------------------------------------------- + +interface WrapperProps { + defaultValues?: Partial; +} + +function Wrapper({ defaultValues = {} }: WrapperProps) { + const methods = useForm({ + 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 ( + + + + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('CustomUserVarsDefinitionSection – empty state', () => { + it('renders the section heading', () => { + render(); + expect(screen.getByText('User Variables')).toBeInTheDocument(); + }); + + it('renders the section description', () => { + render(); + expect( + screen.getByText('Define variables that users must supply when connecting to this server.'), + ).toBeInTheDocument(); + }); + + it('renders the "Add Variable" button', () => { + render(); + expect(screen.getByRole('button', { name: /Add Variable/i })).toBeInTheDocument(); + }); + + it('shows the empty-state message when no variables exist', () => { + render(); + expect(screen.getByText('No user variables defined.')).toBeInTheDocument(); + }); + + it('does not render any variable entry when empty', () => { + render(); + 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(); + 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(); + 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(); + fireEvent.click(screen.getByRole('button', { name: /Add Variable/i })); + expect(screen.queryByText('No user variables defined.')).not.toBeInTheDocument(); + }); + + it('adds multiple variable entries', () => { + render(); + 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(); + 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(); + 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( + , + ); + expect(screen.getByText('Variable 1')).toBeInTheDocument(); + expect(screen.getByText('Variable 2')).toBeInTheDocument(); + }); + + it('pre-fills key, title, and description inputs with existing values', () => { + render( + , + ); + // 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( + , + ); + 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( + , + ); + expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(2); + }); + + it('removes an entry after clicking its Delete button', () => { + render( + , + ); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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'); + }); +}); diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/__tests__/HeadersSection.test.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/__tests__/HeadersSection.test.tsx new file mode 100644 index 0000000000..963e82f66e --- /dev/null +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/__tests__/HeadersSection.test.tsx @@ -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 = { + 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('react'); + return { + Input: ActualReact.forwardRef>( + (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 + >((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: () => , + LockOpen: () => , + ChevronDown: () => null, +})); + +// --------------------------------------------------------------------------- +// Wrapper +// --------------------------------------------------------------------------- + +interface WrapperProps { + defaultValues?: Partial; + isEditMode?: boolean; +} + +function Wrapper({ defaultValues = {}, isEditMode = false }: WrapperProps) { + const methods = useForm({ + 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 ( + + + + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('HeadersSection – empty state', () => { + it('renders the section heading', () => { + render(); + expect(screen.getByText('HTTP Headers')).toBeInTheDocument(); + }); + + it('renders the "Add Header" button', () => { + render(); + expect(screen.getByRole('button', { name: /Add Header/i })).toBeInTheDocument(); + }); + + it('shows the empty-state message when no headers exist', () => { + render(); + expect(screen.getByText('No headers configured.')).toBeInTheDocument(); + }); + + it('does not render any header row inputs when empty', () => { + render(); + 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(); + 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(); + 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(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + // 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( + , + ); + 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( + , + ); + expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(2); + }); + + it('removes the row after clicking Delete', () => { + render( + , + ); + 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(); + 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( + , + ); + expect(screen.queryByLabelText('Insert variable')).not.toBeInTheDocument(); + }); + + it('shows variable picker for a non-secret header when valid customUserVars exist', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Insert variable' })).toBeInTheDocument(); + }); + + it('does not show variable picker for a secret header even with customUserVars', () => { + render( + , + ); + expect(screen.queryByRole('button', { name: 'Insert variable' })).not.toBeInTheDocument(); + }); + + it('hides variable picker when customUserVar has no key or title (invalid entry)', () => { + render( + , + ); + expect(screen.queryByRole('button', { name: 'Insert variable' })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/__tests__/useMCPServerForm.test.ts b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/__tests__/useMCPServerForm.test.ts new file mode 100644 index 0000000000..949f51de37 --- /dev/null +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/__tests__/useMCPServerForm.test.ts @@ -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) + : {}; + 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 { + 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 = {}): 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' }, + }); + }); +}); diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/hooks/useMCPServerForm.ts b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/hooks/useMCPServerForm.ts index 9522f450df..c7ef7b3db1 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/hooks/useMCPServerForm.ts +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/hooks/useMCPServerForm.ts @@ -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 && diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/AdvancedSection.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/AdvancedSection.tsx new file mode 100644 index 0000000000..d2cbe677e4 --- /dev/null +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/AdvancedSection.tsx @@ -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(); + + const serverInstructionsMode = + useWatch({ + 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 ( +
+ {/* Chat Menu */} +
+ ( + + )} + /> + +
+ + {/* Server Instructions */} +
+ +

+ {localize('com_ui_mcp_server_instructions_description')} +

+ ( + + )} + /> + {serverInstructionsMode === 'custom' && ( +