LibreChat/client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts
Danny Avila 3ffc0c74bf
🎯 feat: Add Programmatic Tool Calling UI for MCP Tools (#11604)
* feat: MCP Tool Functionality with Tool Options Management

- Introduced `MCPToolItem` component for better handling of individual tool options, including selection, deferral, and programmatic invocation.
- Added `useMCPToolOptions` hook to manage tool options state, enabling deferred loading and programmatic calling for tools.
- Updated `MCPTool` component to integrate new tool options management, improving user interaction with tool selection and settings.
- Enhanced localization support for new tool options in translation files.

This update streamlines the management of MCP tools, allowing for more flexible configurations and improved user experience.

* feat: MCP Tool UI for Programmatic Tools

- Added support for programmatic tools in the MCPTool and MCPToolItem components, allowing for conditional rendering based on the availability of programmatic capabilities.
- Updated the useAgentCapabilities hook to include programmaticToolsEnabled, enhancing the capability checks for agents.
- Enhanced unit tests for useAgentCapabilities to validate the new programmatic tools functionality.
- Improved localization for programmatic tool descriptions, ensuring clarity in user interactions.

This update improves the flexibility and usability of the MCP Tool, enabling users to leverage programmatic tools effectively.

* fix: Update localization for MCP Tool UI

- Removed outdated descriptions for programmatic tool interactions in the translation file.
- Enhanced clarity in user-facing text for tool options, ensuring accurate representation of functionality.

This update improves the user experience by providing clearer instructions and descriptions for programmatic tools in the MCP Tool UI.

* chore: ESLint fix

* feat: Add unit tests for useMCPToolOptions hook

- Introduced comprehensive tests for the useMCPToolOptions hook, covering functionalities such as tool deferral and programmatic calling.
- Implemented tests for toggling tool options, ensuring correct state management and preservation of existing configurations.
- Enhanced mock implementations for useFormContext and useWatch to facilitate testing scenarios.

This update improves test coverage and reliability for the MCP Tool options management, ensuring robust validation of expected behaviors.

* fix: Adjust gap spacing in MCPToolItem component

- Updated the gap spacing in the MCPToolItem component from 1 to 1.5 for improved layout consistency.
- This change enhances the visual alignment of icons and text within the component, contributing to a better user interface experience.

* fix: Comment out programmatic tools in default agent capabilities

- Commented out the inclusion of programmatic_tools in the defaultAgentCapabilities array, as it requires the latest Code Interpreter API.
- This change ensures compatibility and prevents potential issues until the necessary API updates are integrated.
2026-02-02 14:37:17 +01:00

656 lines
20 KiB
TypeScript

import { renderHook, act } from '@testing-library/react';
import { useFormContext, useWatch } from 'react-hook-form';
import type { AgentToolType } from 'librechat-data-provider';
import useMCPToolOptions from '../useMCPToolOptions';
jest.mock('react-hook-form', () => ({
useFormContext: jest.fn(),
useWatch: jest.fn(),
}));
const mockSetValue = jest.fn();
const mockGetValues = jest.fn();
const createMockTool = (toolId: string): AgentToolType => ({
tool_id: toolId,
metadata: { name: toolId, description: `Description for ${toolId}` },
});
describe('useMCPToolOptions', () => {
beforeEach(() => {
jest.clearAllMocks();
(useFormContext as jest.Mock).mockReturnValue({
getValues: mockGetValues,
setValue: mockSetValue,
control: {},
});
(useWatch as jest.Mock).mockReturnValue(undefined);
mockGetValues.mockReturnValue({});
});
describe('isToolDeferred', () => {
it('should return false when tool_options is undefined', () => {
(useWatch as jest.Mock).mockReturnValue(undefined);
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.isToolDeferred('tool1')).toBe(false);
});
it('should return false when tool has no options', () => {
(useWatch as jest.Mock).mockReturnValue({});
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.isToolDeferred('tool1')).toBe(false);
});
it('should return false when defer_loading is not set', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { allowed_callers: ['direct'] },
});
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.isToolDeferred('tool1')).toBe(false);
});
it('should return true when defer_loading is true', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { defer_loading: true },
});
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.isToolDeferred('tool1')).toBe(true);
});
it('should return false when defer_loading is false', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { defer_loading: false },
});
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.isToolDeferred('tool1')).toBe(false);
});
});
describe('isToolProgrammatic', () => {
it('should return false when tool_options is undefined', () => {
(useWatch as jest.Mock).mockReturnValue(undefined);
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.isToolProgrammatic('tool1')).toBe(false);
});
it('should return false when tool has no options', () => {
(useWatch as jest.Mock).mockReturnValue({});
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.isToolProgrammatic('tool1')).toBe(false);
});
it('should return false when allowed_callers does not include code_execution', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { allowed_callers: ['direct'] },
});
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.isToolProgrammatic('tool1')).toBe(false);
});
it('should return true when allowed_callers includes code_execution', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { allowed_callers: ['code_execution'] },
});
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.isToolProgrammatic('tool1')).toBe(true);
});
it('should return true when allowed_callers includes both direct and code_execution', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { allowed_callers: ['direct', 'code_execution'] },
});
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.isToolProgrammatic('tool1')).toBe(true);
});
});
describe('toggleToolDefer', () => {
it('should enable defer_loading for a tool with no existing options', () => {
mockGetValues.mockReturnValue({});
const { result } = renderHook(() => useMCPToolOptions());
act(() => {
result.current.toggleToolDefer('tool1');
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{ tool1: { defer_loading: true } },
{ shouldDirty: true },
);
});
it('should enable defer_loading while preserving other options', () => {
mockGetValues.mockReturnValue({
tool1: { allowed_callers: ['code_execution'] },
});
const { result } = renderHook(() => useMCPToolOptions());
act(() => {
result.current.toggleToolDefer('tool1');
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{ tool1: { allowed_callers: ['code_execution'], defer_loading: true } },
{ shouldDirty: true },
);
});
it('should disable defer_loading and preserve other options', () => {
mockGetValues.mockReturnValue({
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
});
const { result } = renderHook(() => useMCPToolOptions());
act(() => {
result.current.toggleToolDefer('tool1');
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{ tool1: { allowed_callers: ['code_execution'] } },
{ shouldDirty: true },
);
});
it('should remove tool entry entirely when disabling defer_loading and no other options exist', () => {
mockGetValues.mockReturnValue({
tool1: { defer_loading: true },
});
const { result } = renderHook(() => useMCPToolOptions());
act(() => {
result.current.toggleToolDefer('tool1');
});
expect(mockSetValue).toHaveBeenCalledWith('tool_options', {}, { shouldDirty: true });
});
it('should preserve other tools when toggling', () => {
mockGetValues.mockReturnValue({
tool1: { defer_loading: true },
tool2: { defer_loading: true },
});
const { result } = renderHook(() => useMCPToolOptions());
act(() => {
result.current.toggleToolDefer('tool1');
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{ tool2: { defer_loading: true } },
{ shouldDirty: true },
);
});
});
describe('toggleToolProgrammatic', () => {
it('should enable programmatic calling for a tool with no existing options', () => {
mockGetValues.mockReturnValue({});
const { result } = renderHook(() => useMCPToolOptions());
act(() => {
result.current.toggleToolProgrammatic('tool1');
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{ tool1: { allowed_callers: ['code_execution'] } },
{ shouldDirty: true },
);
});
it('should enable programmatic calling while preserving defer_loading', () => {
mockGetValues.mockReturnValue({
tool1: { defer_loading: true },
});
const { result } = renderHook(() => useMCPToolOptions());
act(() => {
result.current.toggleToolProgrammatic('tool1');
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{ tool1: { defer_loading: true, allowed_callers: ['code_execution'] } },
{ shouldDirty: true },
);
});
it('should disable programmatic calling and preserve defer_loading', () => {
mockGetValues.mockReturnValue({
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
});
const { result } = renderHook(() => useMCPToolOptions());
act(() => {
result.current.toggleToolProgrammatic('tool1');
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{ tool1: { defer_loading: true } },
{ shouldDirty: true },
);
});
it('should remove tool entry entirely when disabling programmatic and no other options exist', () => {
mockGetValues.mockReturnValue({
tool1: { allowed_callers: ['code_execution'] },
});
const { result } = renderHook(() => useMCPToolOptions());
act(() => {
result.current.toggleToolProgrammatic('tool1');
});
expect(mockSetValue).toHaveBeenCalledWith('tool_options', {}, { shouldDirty: true });
});
it('should preserve other tools when toggling', () => {
mockGetValues.mockReturnValue({
tool1: { allowed_callers: ['code_execution'] },
tool2: { defer_loading: true },
});
const { result } = renderHook(() => useMCPToolOptions());
act(() => {
result.current.toggleToolProgrammatic('tool1');
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{ tool2: { defer_loading: true } },
{ shouldDirty: true },
);
});
});
describe('areAllToolsDeferred', () => {
it('should return false for empty tools array', () => {
(useWatch as jest.Mock).mockReturnValue({});
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.areAllToolsDeferred([])).toBe(false);
});
it('should return false when no tools are deferred', () => {
(useWatch as jest.Mock).mockReturnValue({});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
expect(result.current.areAllToolsDeferred(tools)).toBe(false);
});
it('should return false when some tools are deferred', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { defer_loading: true },
});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
expect(result.current.areAllToolsDeferred(tools)).toBe(false);
});
it('should return true when all tools are deferred', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { defer_loading: true },
tool2: { defer_loading: true },
});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
expect(result.current.areAllToolsDeferred(tools)).toBe(true);
});
});
describe('areAllToolsProgrammatic', () => {
it('should return false for empty tools array', () => {
(useWatch as jest.Mock).mockReturnValue({});
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.areAllToolsProgrammatic([])).toBe(false);
});
it('should return false when no tools are programmatic', () => {
(useWatch as jest.Mock).mockReturnValue({});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
expect(result.current.areAllToolsProgrammatic(tools)).toBe(false);
});
it('should return false when some tools are programmatic', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { allowed_callers: ['code_execution'] },
});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
expect(result.current.areAllToolsProgrammatic(tools)).toBe(false);
});
it('should return true when all tools are programmatic', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { allowed_callers: ['code_execution'] },
tool2: { allowed_callers: ['code_execution'] },
});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
expect(result.current.areAllToolsProgrammatic(tools)).toBe(true);
});
});
describe('toggleDeferAll', () => {
it('should do nothing for empty tools array', () => {
const { result } = renderHook(() => useMCPToolOptions());
act(() => {
result.current.toggleDeferAll([]);
});
expect(mockSetValue).not.toHaveBeenCalled();
});
it('should defer all tools when none are deferred', () => {
(useWatch as jest.Mock).mockReturnValue({});
mockGetValues.mockReturnValue({});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
act(() => {
result.current.toggleDeferAll(tools);
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{
tool1: { defer_loading: true },
tool2: { defer_loading: true },
},
{ shouldDirty: true },
);
});
it('should undefer all tools when all are deferred', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { defer_loading: true },
tool2: { defer_loading: true },
});
mockGetValues.mockReturnValue({
tool1: { defer_loading: true },
tool2: { defer_loading: true },
});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
act(() => {
result.current.toggleDeferAll(tools);
});
expect(mockSetValue).toHaveBeenCalledWith('tool_options', {}, { shouldDirty: true });
});
it('should defer all when some are deferred (brings to consistent state)', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { defer_loading: true },
});
mockGetValues.mockReturnValue({
tool1: { defer_loading: true },
});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
act(() => {
result.current.toggleDeferAll(tools);
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{
tool1: { defer_loading: true },
tool2: { defer_loading: true },
},
{ shouldDirty: true },
);
});
it('should preserve other options when deferring', () => {
(useWatch as jest.Mock).mockReturnValue({});
mockGetValues.mockReturnValue({
tool1: { allowed_callers: ['code_execution'] },
});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
act(() => {
result.current.toggleDeferAll(tools);
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{
tool1: { allowed_callers: ['code_execution'], defer_loading: true },
tool2: { defer_loading: true },
},
{ shouldDirty: true },
);
});
it('should preserve other options when undeferring', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
tool2: { defer_loading: true },
});
mockGetValues.mockReturnValue({
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
tool2: { defer_loading: true },
});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
act(() => {
result.current.toggleDeferAll(tools);
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{ tool1: { allowed_callers: ['code_execution'] } },
{ shouldDirty: true },
);
});
});
describe('toggleProgrammaticAll', () => {
it('should do nothing for empty tools array', () => {
const { result } = renderHook(() => useMCPToolOptions());
act(() => {
result.current.toggleProgrammaticAll([]);
});
expect(mockSetValue).not.toHaveBeenCalled();
});
it('should make all tools programmatic when none are', () => {
(useWatch as jest.Mock).mockReturnValue({});
mockGetValues.mockReturnValue({});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
act(() => {
result.current.toggleProgrammaticAll(tools);
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{
tool1: { allowed_callers: ['code_execution'] },
tool2: { allowed_callers: ['code_execution'] },
},
{ shouldDirty: true },
);
});
it('should remove programmatic from all tools when all are programmatic', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { allowed_callers: ['code_execution'] },
tool2: { allowed_callers: ['code_execution'] },
});
mockGetValues.mockReturnValue({
tool1: { allowed_callers: ['code_execution'] },
tool2: { allowed_callers: ['code_execution'] },
});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
act(() => {
result.current.toggleProgrammaticAll(tools);
});
expect(mockSetValue).toHaveBeenCalledWith('tool_options', {}, { shouldDirty: true });
});
it('should make all programmatic when some are (brings to consistent state)', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { allowed_callers: ['code_execution'] },
});
mockGetValues.mockReturnValue({
tool1: { allowed_callers: ['code_execution'] },
});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
act(() => {
result.current.toggleProgrammaticAll(tools);
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{
tool1: { allowed_callers: ['code_execution'] },
tool2: { allowed_callers: ['code_execution'] },
},
{ shouldDirty: true },
);
});
it('should preserve defer_loading when making programmatic', () => {
(useWatch as jest.Mock).mockReturnValue({});
mockGetValues.mockReturnValue({
tool1: { defer_loading: true },
});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
act(() => {
result.current.toggleProgrammaticAll(tools);
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
tool2: { allowed_callers: ['code_execution'] },
},
{ shouldDirty: true },
);
});
it('should preserve defer_loading when removing programmatic', () => {
(useWatch as jest.Mock).mockReturnValue({
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
tool2: { allowed_callers: ['code_execution'] },
});
mockGetValues.mockReturnValue({
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
tool2: { allowed_callers: ['code_execution'] },
});
const { result } = renderHook(() => useMCPToolOptions());
const tools = [createMockTool('tool1'), createMockTool('tool2')];
act(() => {
result.current.toggleProgrammaticAll(tools);
});
expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{ tool1: { defer_loading: true } },
{ shouldDirty: true },
);
});
});
describe('formToolOptions', () => {
it('should return undefined when useWatch returns undefined', () => {
(useWatch as jest.Mock).mockReturnValue(undefined);
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.formToolOptions).toBeUndefined();
});
it('should return the tool options from useWatch', () => {
const toolOptions = {
tool1: { defer_loading: true },
tool2: { allowed_callers: ['code_execution'] },
};
(useWatch as jest.Mock).mockReturnValue(toolOptions);
const { result } = renderHook(() => useMCPToolOptions());
expect(result.current.formToolOptions).toEqual(toolOptions);
});
});
});