diff --git a/client/src/components/SidePanel/Agents/MCPTool.tsx b/client/src/components/SidePanel/Agents/MCPTool.tsx index dc7362b9b6..e9f888b7e5 100644 --- a/client/src/components/SidePanel/Agents/MCPTool.tsx +++ b/client/src/components/SidePanel/Agents/MCPTool.tsx @@ -1,147 +1,96 @@ -import React, { useState, useCallback } from 'react'; -import { ChevronDown, Clock } from 'lucide-react'; +import React, { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { Constants } from 'librechat-data-provider'; -import { useFormContext, useWatch } from 'react-hook-form'; +import { ChevronDown, Clock, Code2 } from 'lucide-react'; import * as AccordionPrimitive from '@radix-ui/react-accordion'; import { Label, - ESide, Checkbox, OGDialog, Accordion, TrashIcon, TooltipAnchor, - InfoHoverCard, AccordionItem, OGDialogTrigger, AccordionContent, OGDialogTemplate, } from '@librechat/client'; -import type { AgentToolOptions } from 'librechat-data-provider'; import type { AgentForm, MCPServerInfo } from '~/common'; import { useAgentCapabilities, useMCPServerManager, useGetAgentsConfig, + useMCPToolOptions, useRemoveMCPTool, useLocalize, } from '~/hooks'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; +import MCPToolItem from './MCPToolItem'; import { cn } from '~/utils'; export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) { const localize = useLocalize(); const { removeTool } = useRemoveMCPTool(); - const { getValues, setValue, control } = useFormContext(); + const { getValues, setValue } = useFormContext(); const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager(); const { agentsConfig } = useGetAgentsConfig(); - const { deferredToolsEnabled } = useAgentCapabilities(agentsConfig?.capabilities); + const { deferredToolsEnabled, programmaticToolsEnabled } = useAgentCapabilities( + agentsConfig?.capabilities, + ); + + const { + isToolDeferred, + isToolProgrammatic, + toggleToolDefer, + toggleToolProgrammatic, + areAllToolsDeferred, + areAllToolsProgrammatic, + toggleDeferAll, + toggleProgrammaticAll, + } = useMCPToolOptions(); const [isFocused, setIsFocused] = useState(false); const [isHovering, setIsHovering] = useState(false); const [accordionValue, setAccordionValue] = useState(''); - const formToolOptions = useWatch({ control, name: 'tool_options' }); - - /** Check if a specific tool has defer_loading enabled */ - const isToolDeferred = useCallback( - (toolId: string): boolean => formToolOptions?.[toolId]?.defer_loading === true, - [formToolOptions], - ); - - /** Toggle defer_loading for a specific tool */ - const toggleToolDefer = useCallback( - (toolId: string) => { - const currentOptions = getValues('tool_options') || {}; - const currentToolOptions = currentOptions[toolId] || {}; - const newDeferred = !currentToolOptions.defer_loading; - - const updatedOptions: AgentToolOptions = { ...currentOptions }; - - if (newDeferred) { - updatedOptions[toolId] = { - ...currentToolOptions, - defer_loading: true, - }; - } else { - const { defer_loading: _, ...restOptions } = currentToolOptions; - if (Object.keys(restOptions).length === 0) { - delete updatedOptions[toolId]; - } else { - updatedOptions[toolId] = restOptions; - } - } - - setValue('tool_options', updatedOptions, { shouldDirty: true }); - }, - [getValues, setValue], - ); - - /** Check if all server tools are deferred */ - const areAllToolsDeferred = - serverInfo?.tools && - serverInfo.tools.length > 0 && - serverInfo.tools.every((tool) => formToolOptions?.[tool.tool_id]?.defer_loading === true); - - /** Toggle defer_loading for all tools from this server */ - const toggleDeferAll = useCallback(() => { - if (!serverInfo?.tools) return; - - const shouldDefer = !areAllToolsDeferred; - const currentOptions = getValues('tool_options') || {}; - const updatedOptions: AgentToolOptions = { ...currentOptions }; - - for (const tool of serverInfo.tools) { - if (shouldDefer) { - updatedOptions[tool.tool_id] = { - ...(updatedOptions[tool.tool_id] || {}), - defer_loading: true, - }; - } else { - if (updatedOptions[tool.tool_id]) { - delete updatedOptions[tool.tool_id].defer_loading; - if (Object.keys(updatedOptions[tool.tool_id]).length === 0) { - delete updatedOptions[tool.tool_id]; - } - } - } - } - - setValue('tool_options', updatedOptions, { shouldDirty: true }); - }, [serverInfo?.tools, getValues, setValue, areAllToolsDeferred]); - if (!serverInfo) { return null; } const currentServerName = serverInfo.serverName; + const tools = serverInfo.tools || []; const getSelectedTools = () => { - if (!serverInfo?.tools) return []; const formTools = getValues('tools') || []; - return serverInfo.tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id); + return tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id); }; const updateFormTools = (newSelectedTools: string[]) => { const currentTools = getValues('tools') || []; - const otherTools = currentTools.filter( - (t: string) => !serverInfo?.tools?.some((st) => st.tool_id === t), - ); + const otherTools = currentTools.filter((t: string) => !tools.some((st) => st.tool_id === t)); setValue('tools', [...otherTools, ...newSelectedTools]); }; + const toggleToolSelect = (toolId: string) => { + const selectedTools = getSelectedTools(); + const newSelectedTools = selectedTools.includes(toolId) + ? selectedTools.filter((t) => t !== toolId) + : [...selectedTools, toolId]; + updateFormTools(newSelectedTools); + }; + const selectedTools = getSelectedTools(); const isExpanded = accordionValue === currentServerName; + const allDeferred = areAllToolsDeferred(tools); + const allProgrammatic = areAllToolsProgrammatic(tools); const statusIconProps = getServerStatusIconProps(currentServerName); const configDialogProps = getConfigDialogProps(); const statusIcon = statusIconProps && (
{ - e.stopPropagation(); - }} + onClick={(e) => e.stopPropagation()} className="cursor-pointer rounded p-0.5 hover:bg-surface-secondary" > @@ -166,14 +115,7 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
- setAccordionValue((prev) => { - if (prev) { - return ''; - } - return currentServerName; - }) - } + onClick={() => setAccordionValue((prev) => (prev ? '' : currentServerName))} > {statusIcon &&
{statusIcon}
} @@ -213,18 +155,15 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) 0 + selectedTools.length === tools.length && selectedTools.length > 0 } onCheckedChange={(checked) => { - if (serverInfo.tools) { - const newSelectedTools = checked - ? serverInfo.tools.map((t) => t.tool_id) - : [ - `${Constants.mcp_server}${Constants.mcp_delimiter}${currentServerName}`, - ]; - updateFormTools(newSelectedTools); - } + const newSelectedTools = checked + ? tools.map((t) => t.tool_id) + : [ + `${Constants.mcp_server}${Constants.mcp_delimiter}${currentServerName}`, + ]; + updateFormTools(newSelectedTools); }} className={cn( 'h-4 w-4 rounded border border-border-medium transition-all duration-200 hover:border-border-heavy', @@ -241,19 +180,17 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) }} tabIndex={isExpanded ? 0 : -1} aria-label={ - selectedTools.length === serverInfo.tools?.length && - selectedTools.length > 0 + selectedTools.length === tools.length && selectedTools.length > 0 ? localize('com_ui_deselect_all') : localize('com_ui_select_all') } />
- {/* Defer All toggle - icon only with tooltip */} {deferredToolsEnabled && ( { e.stopPropagation(); - toggleDeferAll(); + toggleDeferAll(tools); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); - toggleDeferAll(); + toggleDeferAll(tools); } }} > - + + )} + + {programmaticToolsEnabled && ( + { + e.stopPropagation(); + toggleProgrammaticAll(tools); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + toggleProgrammaticAll(tools); + } + }} + > + )}
- {/* Caret button for accordion */} + + e.stopPropagation()} + > + + {tool.metadata.description || localize('com_ui_mcp_no_description')} + + + {deferredToolsEnabled && ( + +
+ +
+ {localize('com_ui_mcp_defer_loading')} + + {localize('com_ui_mcp_click_to_defer')} + +
+
+
+ )} + {programmaticToolsEnabled && ( + +
+ +
+ {localize('com_ui_mcp_programmatic')} + + {localize('com_ui_mcp_click_to_programmatic')} + +
+
+
+ )} +
+ +
+
+ ); +} diff --git a/client/src/hooks/Agents/__tests__/useAgentCapabilities.spec.ts b/client/src/hooks/Agents/__tests__/useAgentCapabilities.spec.ts index d698d51bab..f6ff8dcbab 100644 --- a/client/src/hooks/Agents/__tests__/useAgentCapabilities.spec.ts +++ b/client/src/hooks/Agents/__tests__/useAgentCapabilities.spec.ts @@ -15,6 +15,7 @@ describe('useAgentCapabilities', () => { expect(result.current.webSearchEnabled).toBe(false); expect(result.current.codeEnabled).toBe(false); expect(result.current.deferredToolsEnabled).toBe(false); + expect(result.current.programmaticToolsEnabled).toBe(false); }); it('should return all capabilities as false when capabilities is empty array', () => { @@ -22,6 +23,7 @@ describe('useAgentCapabilities', () => { expect(result.current.toolsEnabled).toBe(false); expect(result.current.deferredToolsEnabled).toBe(false); + expect(result.current.programmaticToolsEnabled).toBe(false); }); it('should return true for enabled capabilities', () => { @@ -60,6 +62,26 @@ describe('useAgentCapabilities', () => { expect(result.current.deferredToolsEnabled).toBe(false); }); + it('should return programmaticToolsEnabled as true when programmatic_tools is in capabilities', () => { + const capabilities = [AgentCapabilities.programmatic_tools]; + + const { result } = renderHook(() => useAgentCapabilities(capabilities)); + + expect(result.current.programmaticToolsEnabled).toBe(true); + }); + + it('should return programmaticToolsEnabled as false when programmatic_tools is not in capabilities', () => { + const capabilities = [ + AgentCapabilities.tools, + AgentCapabilities.actions, + AgentCapabilities.artifacts, + ]; + + const { result } = renderHook(() => useAgentCapabilities(capabilities)); + + expect(result.current.programmaticToolsEnabled).toBe(false); + }); + it('should handle all capabilities being enabled', () => { const capabilities = [ AgentCapabilities.tools, @@ -71,6 +93,7 @@ describe('useAgentCapabilities', () => { AgentCapabilities.web_search, AgentCapabilities.execute_code, AgentCapabilities.deferred_tools, + AgentCapabilities.programmatic_tools, ]; const { result } = renderHook(() => useAgentCapabilities(capabilities)); @@ -84,5 +107,6 @@ describe('useAgentCapabilities', () => { expect(result.current.webSearchEnabled).toBe(true); expect(result.current.codeEnabled).toBe(true); expect(result.current.deferredToolsEnabled).toBe(true); + expect(result.current.programmaticToolsEnabled).toBe(true); }); }); diff --git a/client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts b/client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts new file mode 100644 index 0000000000..caba94016f --- /dev/null +++ b/client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts @@ -0,0 +1,656 @@ +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); + }); + }); +}); diff --git a/client/src/hooks/Agents/index.ts b/client/src/hooks/Agents/index.ts index 3597b0e646..f75d045cc0 100644 --- a/client/src/hooks/Agents/index.ts +++ b/client/src/hooks/Agents/index.ts @@ -6,4 +6,5 @@ export { default as useAgentCapabilities } from './useAgentCapabilities'; export { default as useGetAgentsConfig } from './useGetAgentsConfig'; export { default as useAgentDefaultPermissionLevel } from './useAgentDefaultPermissionLevel'; export { default as useAgentToolPermissions } from './useAgentToolPermissions'; +export { default as useMCPToolOptions } from './useMCPToolOptions'; export * from './useApplyModelSpecAgents'; diff --git a/client/src/hooks/Agents/useAgentCapabilities.ts b/client/src/hooks/Agents/useAgentCapabilities.ts index 777e41fdfb..a0f3de025e 100644 --- a/client/src/hooks/Agents/useAgentCapabilities.ts +++ b/client/src/hooks/Agents/useAgentCapabilities.ts @@ -11,6 +11,7 @@ interface AgentCapabilitiesResult { webSearchEnabled: boolean; codeEnabled: boolean; deferredToolsEnabled: boolean; + programmaticToolsEnabled: boolean; } export default function useAgentCapabilities( @@ -61,6 +62,11 @@ export default function useAgentCapabilities( [capabilities], ); + const programmaticToolsEnabled = useMemo( + () => capabilities?.includes(AgentCapabilities.programmatic_tools) ?? false, + [capabilities], + ); + return { ocrEnabled, codeEnabled, @@ -71,5 +77,6 @@ export default function useAgentCapabilities( webSearchEnabled, fileSearchEnabled, deferredToolsEnabled, + programmaticToolsEnabled, }; } diff --git a/client/src/hooks/Agents/useMCPToolOptions.ts b/client/src/hooks/Agents/useMCPToolOptions.ts new file mode 100644 index 0000000000..68cfb8f91b --- /dev/null +++ b/client/src/hooks/Agents/useMCPToolOptions.ts @@ -0,0 +1,183 @@ +import { useCallback } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; +import type { AgentToolOptions, AllowedCaller, AgentToolType } from 'librechat-data-provider'; +import type { AgentForm } from '~/common'; + +interface UseMCPToolOptionsReturn { + formToolOptions: AgentToolOptions | undefined; + isToolDeferred: (toolId: string) => boolean; + isToolProgrammatic: (toolId: string) => boolean; + toggleToolDefer: (toolId: string) => void; + toggleToolProgrammatic: (toolId: string) => void; + areAllToolsDeferred: (tools: AgentToolType[]) => boolean; + areAllToolsProgrammatic: (tools: AgentToolType[]) => boolean; + toggleDeferAll: (tools: AgentToolType[]) => void; + toggleProgrammaticAll: (tools: AgentToolType[]) => void; +} + +export default function useMCPToolOptions(): UseMCPToolOptionsReturn { + const { getValues, setValue, control } = useFormContext(); + const formToolOptions = useWatch({ control, name: 'tool_options' }); + + const isToolDeferred = useCallback( + (toolId: string): boolean => formToolOptions?.[toolId]?.defer_loading === true, + [formToolOptions], + ); + + const isToolProgrammatic = useCallback( + (toolId: string): boolean => + formToolOptions?.[toolId]?.allowed_callers?.includes('code_execution') === true, + [formToolOptions], + ); + + const toggleToolDefer = useCallback( + (toolId: string) => { + const currentOptions = getValues('tool_options') || {}; + const currentToolOptions = currentOptions[toolId] || {}; + const newDeferred = !currentToolOptions.defer_loading; + + const updatedOptions: AgentToolOptions = { ...currentOptions }; + + if (newDeferred) { + updatedOptions[toolId] = { + ...currentToolOptions, + defer_loading: true, + }; + } else { + const { defer_loading: _, ...restOptions } = currentToolOptions; + if (Object.keys(restOptions).length === 0) { + delete updatedOptions[toolId]; + } else { + updatedOptions[toolId] = restOptions; + } + } + + setValue('tool_options', updatedOptions, { shouldDirty: true }); + }, + [getValues, setValue], + ); + + const toggleToolProgrammatic = useCallback( + (toolId: string) => { + const currentOptions = getValues('tool_options') || {}; + const currentToolOptions = currentOptions[toolId] || {}; + const currentCallers = currentToolOptions.allowed_callers || []; + const isProgrammatic = currentCallers.includes('code_execution'); + + const updatedOptions: AgentToolOptions = { ...currentOptions }; + + if (isProgrammatic) { + const newCallers = currentCallers.filter((c: AllowedCaller) => c !== 'code_execution'); + if (newCallers.length === 0) { + const { allowed_callers: _, ...restOptions } = currentToolOptions; + if (Object.keys(restOptions).length === 0) { + delete updatedOptions[toolId]; + } else { + updatedOptions[toolId] = restOptions; + } + } else { + updatedOptions[toolId] = { + ...currentToolOptions, + allowed_callers: newCallers, + }; + } + } else { + updatedOptions[toolId] = { + ...currentToolOptions, + allowed_callers: ['code_execution'] as AllowedCaller[], + }; + } + + setValue('tool_options', updatedOptions, { shouldDirty: true }); + }, + [getValues, setValue], + ); + + const areAllToolsDeferred = useCallback( + (tools: AgentToolType[]): boolean => + tools.length > 0 && + tools.every((tool) => formToolOptions?.[tool.tool_id]?.defer_loading === true), + [formToolOptions], + ); + + const areAllToolsProgrammatic = useCallback( + (tools: AgentToolType[]): boolean => + tools.length > 0 && + tools.every( + (tool) => + formToolOptions?.[tool.tool_id]?.allowed_callers?.includes('code_execution') === true, + ), + [formToolOptions], + ); + + const toggleDeferAll = useCallback( + (tools: AgentToolType[]) => { + if (tools.length === 0) return; + + const shouldDefer = !areAllToolsDeferred(tools); + const currentOptions = getValues('tool_options') || {}; + const updatedOptions: AgentToolOptions = { ...currentOptions }; + + for (const tool of tools) { + if (shouldDefer) { + updatedOptions[tool.tool_id] = { + ...(updatedOptions[tool.tool_id] || {}), + defer_loading: true, + }; + } else { + if (updatedOptions[tool.tool_id]) { + delete updatedOptions[tool.tool_id].defer_loading; + if (Object.keys(updatedOptions[tool.tool_id]).length === 0) { + delete updatedOptions[tool.tool_id]; + } + } + } + } + + setValue('tool_options', updatedOptions, { shouldDirty: true }); + }, + [getValues, setValue, areAllToolsDeferred], + ); + + const toggleProgrammaticAll = useCallback( + (tools: AgentToolType[]) => { + if (tools.length === 0) return; + + const shouldBeProgrammatic = !areAllToolsProgrammatic(tools); + const currentOptions = getValues('tool_options') || {}; + const updatedOptions: AgentToolOptions = { ...currentOptions }; + + for (const tool of tools) { + const currentToolOptions = updatedOptions[tool.tool_id] || {}; + if (shouldBeProgrammatic) { + updatedOptions[tool.tool_id] = { + ...currentToolOptions, + allowed_callers: ['code_execution'] as AllowedCaller[], + }; + } else { + if (updatedOptions[tool.tool_id]) { + delete updatedOptions[tool.tool_id].allowed_callers; + if (Object.keys(updatedOptions[tool.tool_id]).length === 0) { + delete updatedOptions[tool.tool_id]; + } + } + } + } + + setValue('tool_options', updatedOptions, { shouldDirty: true }); + }, + [getValues, setValue, areAllToolsProgrammatic], + ); + + return { + formToolOptions, + isToolDeferred, + isToolProgrammatic, + toggleToolDefer, + toggleToolProgrammatic, + areAllToolsDeferred, + areAllToolsProgrammatic, + toggleDeferAll, + toggleProgrammaticAll, + }; +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 47ccc04303..0a9c006185 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1132,7 +1132,12 @@ "com_ui_mcp_undefer": "Undefer", "com_ui_mcp_undefer_all": "Undefer all tools", "com_ui_mcp_click_to_defer": "Click to defer - tool will be discoverable via search but not loaded until needed", - "com_ui_mcp_click_to_undefer": "Click to undefer - tool will be loaded immediately", + "com_ui_mcp_tool_options": "Tool Options", + "com_ui_mcp_programmatic": "Programmatic", + "com_ui_mcp_programmatic_all": "Mark all as programmatic", + "com_ui_mcp_unprogrammatic_all": "Unmark all as programmatic", + "com_ui_mcp_click_to_programmatic": "Enable programmatic calling - tool can only be invoked via code execution", + "com_ui_mcp_no_description": "No description available", "com_ui_medium": "Medium", "com_ui_memories": "Memories", "com_ui_memories_allow_create": "Allow creating Memories", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index c609de0e41..53a518b06d 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -176,6 +176,7 @@ export enum Capabilities { export enum AgentCapabilities { hide_sequential_outputs = 'hide_sequential_outputs', + programmatic_tools = 'programmatic_tools', end_after_tools = 'end_after_tools', deferred_tools = 'deferred_tools', execute_code = 'execute_code', @@ -262,6 +263,8 @@ export const assistantEndpointSchema = baseEndpointSchema.merge( export type TAssistantEndpoint = z.infer; export const defaultAgentCapabilities = [ + // Commented as requires latest Code Interpreter API + // AgentCapabilities.programmatic_tools, AgentCapabilities.deferred_tools, AgentCapabilities.execute_code, AgentCapabilities.file_search,