mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-28 21:30:18 +01:00
🎯 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.
This commit is contained in:
parent
40c5804ed6
commit
3ffc0c74bf
9 changed files with 1140 additions and 214 deletions
|
|
@ -1,147 +1,96 @@
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ChevronDown, Clock } from 'lucide-react';
|
import { useFormContext } from 'react-hook-form';
|
||||||
import { Constants } from 'librechat-data-provider';
|
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 * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||||
import {
|
import {
|
||||||
Label,
|
Label,
|
||||||
ESide,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
OGDialog,
|
OGDialog,
|
||||||
Accordion,
|
Accordion,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
TooltipAnchor,
|
TooltipAnchor,
|
||||||
InfoHoverCard,
|
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
OGDialogTrigger,
|
OGDialogTrigger,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
OGDialogTemplate,
|
OGDialogTemplate,
|
||||||
} from '@librechat/client';
|
} from '@librechat/client';
|
||||||
import type { AgentToolOptions } from 'librechat-data-provider';
|
|
||||||
import type { AgentForm, MCPServerInfo } from '~/common';
|
import type { AgentForm, MCPServerInfo } from '~/common';
|
||||||
import {
|
import {
|
||||||
useAgentCapabilities,
|
useAgentCapabilities,
|
||||||
useMCPServerManager,
|
useMCPServerManager,
|
||||||
useGetAgentsConfig,
|
useGetAgentsConfig,
|
||||||
|
useMCPToolOptions,
|
||||||
useRemoveMCPTool,
|
useRemoveMCPTool,
|
||||||
useLocalize,
|
useLocalize,
|
||||||
} from '~/hooks';
|
} from '~/hooks';
|
||||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||||
|
import MCPToolItem from './MCPToolItem';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) {
|
export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { removeTool } = useRemoveMCPTool();
|
const { removeTool } = useRemoveMCPTool();
|
||||||
const { getValues, setValue, control } = useFormContext<AgentForm>();
|
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||||
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
|
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
|
||||||
const { agentsConfig } = useGetAgentsConfig();
|
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 [isFocused, setIsFocused] = useState(false);
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
const [accordionValue, setAccordionValue] = useState<string>('');
|
const [accordionValue, setAccordionValue] = useState<string>('');
|
||||||
|
|
||||||
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) {
|
if (!serverInfo) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentServerName = serverInfo.serverName;
|
const currentServerName = serverInfo.serverName;
|
||||||
|
const tools = serverInfo.tools || [];
|
||||||
|
|
||||||
const getSelectedTools = () => {
|
const getSelectedTools = () => {
|
||||||
if (!serverInfo?.tools) return [];
|
|
||||||
const formTools = getValues('tools') || [];
|
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 updateFormTools = (newSelectedTools: string[]) => {
|
||||||
const currentTools = getValues('tools') || [];
|
const currentTools = getValues('tools') || [];
|
||||||
const otherTools = currentTools.filter(
|
const otherTools = currentTools.filter((t: string) => !tools.some((st) => st.tool_id === t));
|
||||||
(t: string) => !serverInfo?.tools?.some((st) => st.tool_id === t),
|
|
||||||
);
|
|
||||||
setValue('tools', [...otherTools, ...newSelectedTools]);
|
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 selectedTools = getSelectedTools();
|
||||||
const isExpanded = accordionValue === currentServerName;
|
const isExpanded = accordionValue === currentServerName;
|
||||||
|
const allDeferred = areAllToolsDeferred(tools);
|
||||||
|
const allProgrammatic = areAllToolsProgrammatic(tools);
|
||||||
|
|
||||||
const statusIconProps = getServerStatusIconProps(currentServerName);
|
const statusIconProps = getServerStatusIconProps(currentServerName);
|
||||||
const configDialogProps = getConfigDialogProps();
|
const configDialogProps = getConfigDialogProps();
|
||||||
|
|
||||||
const statusIcon = statusIconProps && (
|
const statusIcon = statusIconProps && (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => {
|
onClick={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
className="cursor-pointer rounded p-0.5 hover:bg-surface-secondary"
|
className="cursor-pointer rounded p-0.5 hover:bg-surface-secondary"
|
||||||
>
|
>
|
||||||
<MCPServerStatusIcon {...statusIconProps} />
|
<MCPServerStatusIcon {...statusIconProps} />
|
||||||
|
|
@ -166,14 +115,7 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
|
||||||
<AccordionPrimitive.Header asChild>
|
<AccordionPrimitive.Header asChild>
|
||||||
<div
|
<div
|
||||||
className="flex grow cursor-pointer select-none items-center gap-1 rounded bg-transparent p-0 text-left transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
className="flex grow cursor-pointer select-none items-center gap-1 rounded bg-transparent p-0 text-left transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
||||||
onClick={() =>
|
onClick={() => setAccordionValue((prev) => (prev ? '' : currentServerName))}
|
||||||
setAccordionValue((prev) => {
|
|
||||||
if (prev) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return currentServerName;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{statusIcon && <div className="flex items-center">{statusIcon}</div>}
|
{statusIcon && <div className="flex items-center">{statusIcon}</div>}
|
||||||
|
|
||||||
|
|
@ -213,18 +155,15 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`select-all-${currentServerName}`}
|
id={`select-all-${currentServerName}`}
|
||||||
checked={
|
checked={
|
||||||
selectedTools.length === serverInfo.tools?.length &&
|
selectedTools.length === tools.length && selectedTools.length > 0
|
||||||
selectedTools.length > 0
|
|
||||||
}
|
}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
if (serverInfo.tools) {
|
const newSelectedTools = checked
|
||||||
const newSelectedTools = checked
|
? tools.map((t) => t.tool_id)
|
||||||
? serverInfo.tools.map((t) => t.tool_id)
|
: [
|
||||||
: [
|
`${Constants.mcp_server}${Constants.mcp_delimiter}${currentServerName}`,
|
||||||
`${Constants.mcp_server}${Constants.mcp_delimiter}${currentServerName}`,
|
];
|
||||||
];
|
updateFormTools(newSelectedTools);
|
||||||
updateFormTools(newSelectedTools);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-4 w-4 rounded border border-border-medium transition-all duration-200 hover:border-border-heavy',
|
'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}
|
tabIndex={isExpanded ? 0 : -1}
|
||||||
aria-label={
|
aria-label={
|
||||||
selectedTools.length === serverInfo.tools?.length &&
|
selectedTools.length === tools.length && selectedTools.length > 0
|
||||||
selectedTools.length > 0
|
|
||||||
? localize('com_ui_deselect_all')
|
? localize('com_ui_deselect_all')
|
||||||
: localize('com_ui_select_all')
|
: localize('com_ui_select_all')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Defer All toggle - icon only with tooltip */}
|
|
||||||
{deferredToolsEnabled && (
|
{deferredToolsEnabled && (
|
||||||
<TooltipAnchor
|
<TooltipAnchor
|
||||||
description={
|
description={
|
||||||
areAllToolsDeferred
|
allDeferred
|
||||||
? localize('com_ui_mcp_undefer_all')
|
? localize('com_ui_mcp_undefer_all')
|
||||||
: localize('com_ui_mcp_defer_all')
|
: localize('com_ui_mcp_defer_all')
|
||||||
}
|
}
|
||||||
|
|
@ -261,45 +198,82 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={isExpanded ? 0 : -1}
|
tabIndex={isExpanded ? 0 : -1}
|
||||||
aria-label={
|
aria-label={
|
||||||
areAllToolsDeferred
|
allDeferred
|
||||||
? localize('com_ui_mcp_undefer_all')
|
? localize('com_ui_mcp_undefer_all')
|
||||||
: localize('com_ui_mcp_defer_all')
|
: localize('com_ui_mcp_defer_all')
|
||||||
}
|
}
|
||||||
aria-pressed={areAllToolsDeferred}
|
aria-pressed={allDeferred}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
|
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||||
isExpanded ? 'visible' : 'pointer-events-none invisible',
|
isExpanded ? 'visible' : 'pointer-events-none invisible',
|
||||||
areAllToolsDeferred
|
allDeferred
|
||||||
? 'bg-amber-500/20 text-amber-500 hover:bg-amber-500/30'
|
? 'bg-amber-500/20 text-amber-500 hover:bg-amber-500/30'
|
||||||
: 'text-text-tertiary hover:bg-surface-hover hover:text-text-primary',
|
: 'text-text-tertiary hover:bg-surface-hover hover:text-text-primary',
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleDeferAll();
|
toggleDeferAll(tools);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleDeferAll();
|
toggleDeferAll(tools);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Clock
|
<Clock className={cn('h-4 w-4', allDeferred && 'fill-amber-500/30')} />
|
||||||
className={cn('h-4 w-4', areAllToolsDeferred && 'fill-amber-500/30')}
|
</TooltipAnchor>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{programmaticToolsEnabled && (
|
||||||
|
<TooltipAnchor
|
||||||
|
description={
|
||||||
|
allProgrammatic
|
||||||
|
? localize('com_ui_mcp_unprogrammatic_all')
|
||||||
|
: localize('com_ui_mcp_programmatic_all')
|
||||||
|
}
|
||||||
|
side="top"
|
||||||
|
role="button"
|
||||||
|
tabIndex={isExpanded ? 0 : -1}
|
||||||
|
aria-label={
|
||||||
|
allProgrammatic
|
||||||
|
? localize('com_ui_mcp_unprogrammatic_all')
|
||||||
|
: localize('com_ui_mcp_programmatic_all')
|
||||||
|
}
|
||||||
|
aria-pressed={allProgrammatic}
|
||||||
|
className={cn(
|
||||||
|
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||||
|
isExpanded ? 'visible' : 'pointer-events-none invisible',
|
||||||
|
allProgrammatic
|
||||||
|
? 'bg-violet-500/20 text-violet-500 hover:bg-violet-500/30'
|
||||||
|
: 'text-text-tertiary hover:bg-surface-hover hover:text-text-primary',
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleProgrammaticAll(tools);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleProgrammaticAll(tools);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Code2
|
||||||
|
className={cn('h-4 w-4', allProgrammatic && 'fill-violet-500/30')}
|
||||||
/>
|
/>
|
||||||
</TooltipAnchor>
|
</TooltipAnchor>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Caret button for accordion */}
|
|
||||||
<AccordionPrimitive.Trigger asChild>
|
<AccordionPrimitive.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200 hover:bg-surface-active-alt focus:translate-x-0 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200 hover:bg-surface-active-alt focus:translate-x-0 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||||
isExpanded && 'bg-surface-active-alt',
|
isExpanded && 'bg-surface-active-alt',
|
||||||
|
|
@ -329,10 +303,7 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className="flex h-7 w-7 items-center justify-center rounded transition-colors duration-200 hover:bg-surface-active-alt focus:translate-x-0 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
||||||
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
|
|
||||||
'hover:bg-surface-active-alt focus:translate-x-0 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
aria-label={`Delete ${currentServerName}`}
|
aria-label={`Delete ${currentServerName}`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -352,97 +323,20 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
|
||||||
|
|
||||||
<AccordionContent className="relative ml-1 pt-1 before:absolute before:bottom-2 before:left-0 before:top-0 before:w-0.5 before:bg-border-medium">
|
<AccordionContent className="relative ml-1 pt-1 before:absolute before:bottom-2 before:left-0 before:top-0 before:w-0.5 before:bg-border-medium">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{serverInfo.tools?.map((subTool) => {
|
{tools.map((tool) => (
|
||||||
const isDeferred = deferredToolsEnabled && isToolDeferred(subTool.tool_id);
|
<MCPToolItem
|
||||||
return (
|
key={tool.tool_id}
|
||||||
<label
|
tool={tool}
|
||||||
key={subTool.tool_id}
|
isSelected={selectedTools.includes(tool.tool_id)}
|
||||||
htmlFor={subTool.tool_id}
|
isDeferred={deferredToolsEnabled && isToolDeferred(tool.tool_id)}
|
||||||
className={cn(
|
isProgrammatic={programmaticToolsEnabled && isToolProgrammatic(tool.tool_id)}
|
||||||
'group/item flex cursor-pointer items-center rounded-lg border p-2',
|
deferredToolsEnabled={deferredToolsEnabled}
|
||||||
'ml-2 mr-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
|
programmaticToolsEnabled={programmaticToolsEnabled}
|
||||||
isDeferred
|
onToggleSelect={() => toggleToolSelect(tool.tool_id)}
|
||||||
? 'border-amber-500/50 bg-amber-500/5 hover:bg-amber-500/10'
|
onToggleDefer={() => toggleToolDefer(tool.tool_id)}
|
||||||
: 'border-token-border-light hover:bg-token-surface-secondary',
|
onToggleProgrammatic={() => toggleToolProgrammatic(tool.tool_id)}
|
||||||
)}
|
/>
|
||||||
onClick={(e) => e.stopPropagation()}
|
))}
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={subTool.tool_id}
|
|
||||||
checked={selectedTools.includes(subTool.tool_id)}
|
|
||||||
onCheckedChange={(_checked) => {
|
|
||||||
const newSelectedTools = selectedTools.includes(subTool.tool_id)
|
|
||||||
? selectedTools.filter((t) => t !== subTool.tool_id)
|
|
||||||
: [...selectedTools, subTool.tool_id];
|
|
||||||
updateFormTools(newSelectedTools);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
const checkbox = e.currentTarget as HTMLButtonElement;
|
|
||||||
checkbox.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className={cn(
|
|
||||||
'relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer rounded border border-border-medium transition-[border-color] duration-200 hover:border-border-heavy focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background',
|
|
||||||
)}
|
|
||||||
aria-label={subTool.metadata.name}
|
|
||||||
/>
|
|
||||||
<span className="text-token-text-primary flex-1 select-none">
|
|
||||||
{subTool.metadata.name}
|
|
||||||
</span>
|
|
||||||
<div className="ml-auto flex items-center gap-1">
|
|
||||||
{/* Per-tool defer toggle - icon only */}
|
|
||||||
{deferredToolsEnabled && (
|
|
||||||
<TooltipAnchor
|
|
||||||
description={
|
|
||||||
isDeferred
|
|
||||||
? localize('com_ui_mcp_click_to_undefer')
|
|
||||||
: localize('com_ui_mcp_click_to_defer')
|
|
||||||
}
|
|
||||||
side="top"
|
|
||||||
role="button"
|
|
||||||
aria-label={
|
|
||||||
isDeferred
|
|
||||||
? localize('com_ui_mcp_undefer')
|
|
||||||
: localize('com_ui_mcp_defer_loading')
|
|
||||||
}
|
|
||||||
aria-pressed={isDeferred}
|
|
||||||
className={cn(
|
|
||||||
'flex h-6 w-6 items-center justify-center rounded transition-all duration-200',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
|
||||||
isDeferred
|
|
||||||
? 'bg-amber-500/20 text-amber-500 hover:bg-amber-500/30'
|
|
||||||
: 'text-text-tertiary opacity-0 hover:bg-surface-hover hover:text-text-primary group-focus-within/item:opacity-100 group-hover/item:opacity-100',
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
toggleToolDefer(subTool.tool_id);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleToolDefer(subTool.tool_id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Clock className={cn('h-3.5 w-3.5', isDeferred && 'fill-amber-500/30')} />
|
|
||||||
</TooltipAnchor>
|
|
||||||
)}
|
|
||||||
{subTool.metadata.description && (
|
|
||||||
<InfoHoverCard side={ESide.Left} text={subTool.metadata.description} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
|
||||||
153
client/src/components/SidePanel/Agents/MCPToolItem.tsx
Normal file
153
client/src/components/SidePanel/Agents/MCPToolItem.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Clock, MoreHorizontal, Code2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Checkbox,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
} from '@librechat/client';
|
||||||
|
import type { AgentToolType } from 'librechat-data-provider';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
interface MCPToolItemProps {
|
||||||
|
tool: AgentToolType;
|
||||||
|
isSelected: boolean;
|
||||||
|
isDeferred: boolean;
|
||||||
|
isProgrammatic: boolean;
|
||||||
|
deferredToolsEnabled: boolean;
|
||||||
|
programmaticToolsEnabled: boolean;
|
||||||
|
onToggleSelect: () => void;
|
||||||
|
onToggleDefer: () => void;
|
||||||
|
onToggleProgrammatic: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolItemStyle(isDeferred: boolean, isProgrammatic: boolean): string {
|
||||||
|
if (isDeferred && isProgrammatic) {
|
||||||
|
return 'border-purple-500/50 bg-purple-500/5 hover:bg-purple-500/10';
|
||||||
|
}
|
||||||
|
if (isDeferred) {
|
||||||
|
return 'border-amber-500/50 bg-amber-500/5 hover:bg-amber-500/10';
|
||||||
|
}
|
||||||
|
if (isProgrammatic) {
|
||||||
|
return 'border-violet-500/50 bg-violet-500/5 hover:bg-violet-500/10';
|
||||||
|
}
|
||||||
|
return 'border-token-border-light hover:bg-token-surface-secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MCPToolItem({
|
||||||
|
tool,
|
||||||
|
isSelected,
|
||||||
|
isDeferred,
|
||||||
|
isProgrammatic,
|
||||||
|
deferredToolsEnabled,
|
||||||
|
programmaticToolsEnabled,
|
||||||
|
onToggleSelect,
|
||||||
|
onToggleDefer,
|
||||||
|
onToggleProgrammatic,
|
||||||
|
}: MCPToolItemProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const hasOptions = isDeferred || isProgrammatic;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group/item flex cursor-pointer items-center rounded-lg border p-2',
|
||||||
|
'ml-2 mr-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
|
||||||
|
getToolItemStyle(isDeferred, isProgrammatic),
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={tool.tool_id}
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={onToggleSelect}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
const checkbox = e.currentTarget as HTMLButtonElement;
|
||||||
|
checkbox.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="relative mr-2 inline-flex h-4 w-4 shrink-0 cursor-pointer rounded border border-border-medium transition-[border-color] duration-200 hover:border-border-heavy focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
|
||||||
|
aria-label={tool.metadata.name}
|
||||||
|
/>
|
||||||
|
<span className="text-token-text-primary min-w-0 flex-1 select-none truncate">
|
||||||
|
{tool.metadata.name}
|
||||||
|
</span>
|
||||||
|
<div className="ml-2 flex shrink-0 items-center gap-1.5">
|
||||||
|
{isDeferred && <Clock className="h-3.5 w-3.5 text-amber-500" aria-hidden="true" />}
|
||||||
|
{isProgrammatic && <Code2 className="h-3.5 w-3.5 text-violet-500" aria-hidden="true" />}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex h-6 w-6 items-center justify-center rounded transition-all duration-200',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||||
|
hasOptions
|
||||||
|
? 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'
|
||||||
|
: 'text-text-tertiary opacity-0 hover:bg-surface-hover hover:text-text-primary group-focus-within/item:opacity-100 group-hover/item:opacity-100',
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={localize('com_ui_mcp_tool_options')}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
side="left"
|
||||||
|
className="w-64"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="text-xs font-normal text-text-secondary">
|
||||||
|
{tool.metadata.description || localize('com_ui_mcp_no_description')}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{deferredToolsEnabled && (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={isDeferred}
|
||||||
|
onCheckedChange={onToggleDefer}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-amber-500" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{localize('com_ui_mcp_defer_loading')}</span>
|
||||||
|
<span className="text-xs text-text-tertiary">
|
||||||
|
{localize('com_ui_mcp_click_to_defer')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)}
|
||||||
|
{programmaticToolsEnabled && (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={isProgrammatic}
|
||||||
|
onCheckedChange={onToggleProgrammatic}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Code2 className="h-4 w-4 text-violet-500" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{localize('com_ui_mcp_programmatic')}</span>
|
||||||
|
<span className="text-xs text-text-tertiary">
|
||||||
|
{localize('com_ui_mcp_click_to_programmatic')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ describe('useAgentCapabilities', () => {
|
||||||
expect(result.current.webSearchEnabled).toBe(false);
|
expect(result.current.webSearchEnabled).toBe(false);
|
||||||
expect(result.current.codeEnabled).toBe(false);
|
expect(result.current.codeEnabled).toBe(false);
|
||||||
expect(result.current.deferredToolsEnabled).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', () => {
|
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.toolsEnabled).toBe(false);
|
||||||
expect(result.current.deferredToolsEnabled).toBe(false);
|
expect(result.current.deferredToolsEnabled).toBe(false);
|
||||||
|
expect(result.current.programmaticToolsEnabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for enabled capabilities', () => {
|
it('should return true for enabled capabilities', () => {
|
||||||
|
|
@ -60,6 +62,26 @@ describe('useAgentCapabilities', () => {
|
||||||
expect(result.current.deferredToolsEnabled).toBe(false);
|
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', () => {
|
it('should handle all capabilities being enabled', () => {
|
||||||
const capabilities = [
|
const capabilities = [
|
||||||
AgentCapabilities.tools,
|
AgentCapabilities.tools,
|
||||||
|
|
@ -71,6 +93,7 @@ describe('useAgentCapabilities', () => {
|
||||||
AgentCapabilities.web_search,
|
AgentCapabilities.web_search,
|
||||||
AgentCapabilities.execute_code,
|
AgentCapabilities.execute_code,
|
||||||
AgentCapabilities.deferred_tools,
|
AgentCapabilities.deferred_tools,
|
||||||
|
AgentCapabilities.programmatic_tools,
|
||||||
];
|
];
|
||||||
|
|
||||||
const { result } = renderHook(() => useAgentCapabilities(capabilities));
|
const { result } = renderHook(() => useAgentCapabilities(capabilities));
|
||||||
|
|
@ -84,5 +107,6 @@ describe('useAgentCapabilities', () => {
|
||||||
expect(result.current.webSearchEnabled).toBe(true);
|
expect(result.current.webSearchEnabled).toBe(true);
|
||||||
expect(result.current.codeEnabled).toBe(true);
|
expect(result.current.codeEnabled).toBe(true);
|
||||||
expect(result.current.deferredToolsEnabled).toBe(true);
|
expect(result.current.deferredToolsEnabled).toBe(true);
|
||||||
|
expect(result.current.programmaticToolsEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
656
client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts
Normal file
656
client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,4 +6,5 @@ export { default as useAgentCapabilities } from './useAgentCapabilities';
|
||||||
export { default as useGetAgentsConfig } from './useGetAgentsConfig';
|
export { default as useGetAgentsConfig } from './useGetAgentsConfig';
|
||||||
export { default as useAgentDefaultPermissionLevel } from './useAgentDefaultPermissionLevel';
|
export { default as useAgentDefaultPermissionLevel } from './useAgentDefaultPermissionLevel';
|
||||||
export { default as useAgentToolPermissions } from './useAgentToolPermissions';
|
export { default as useAgentToolPermissions } from './useAgentToolPermissions';
|
||||||
|
export { default as useMCPToolOptions } from './useMCPToolOptions';
|
||||||
export * from './useApplyModelSpecAgents';
|
export * from './useApplyModelSpecAgents';
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ interface AgentCapabilitiesResult {
|
||||||
webSearchEnabled: boolean;
|
webSearchEnabled: boolean;
|
||||||
codeEnabled: boolean;
|
codeEnabled: boolean;
|
||||||
deferredToolsEnabled: boolean;
|
deferredToolsEnabled: boolean;
|
||||||
|
programmaticToolsEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useAgentCapabilities(
|
export default function useAgentCapabilities(
|
||||||
|
|
@ -61,6 +62,11 @@ export default function useAgentCapabilities(
|
||||||
[capabilities],
|
[capabilities],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const programmaticToolsEnabled = useMemo(
|
||||||
|
() => capabilities?.includes(AgentCapabilities.programmatic_tools) ?? false,
|
||||||
|
[capabilities],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ocrEnabled,
|
ocrEnabled,
|
||||||
codeEnabled,
|
codeEnabled,
|
||||||
|
|
@ -71,5 +77,6 @@ export default function useAgentCapabilities(
|
||||||
webSearchEnabled,
|
webSearchEnabled,
|
||||||
fileSearchEnabled,
|
fileSearchEnabled,
|
||||||
deferredToolsEnabled,
|
deferredToolsEnabled,
|
||||||
|
programmaticToolsEnabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
183
client/src/hooks/Agents/useMCPToolOptions.ts
Normal file
183
client/src/hooks/Agents/useMCPToolOptions.ts
Normal file
|
|
@ -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<AgentForm>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1132,7 +1132,12 @@
|
||||||
"com_ui_mcp_undefer": "Undefer",
|
"com_ui_mcp_undefer": "Undefer",
|
||||||
"com_ui_mcp_undefer_all": "Undefer all tools",
|
"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_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_medium": "Medium",
|
||||||
"com_ui_memories": "Memories",
|
"com_ui_memories": "Memories",
|
||||||
"com_ui_memories_allow_create": "Allow creating Memories",
|
"com_ui_memories_allow_create": "Allow creating Memories",
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,7 @@ export enum Capabilities {
|
||||||
|
|
||||||
export enum AgentCapabilities {
|
export enum AgentCapabilities {
|
||||||
hide_sequential_outputs = 'hide_sequential_outputs',
|
hide_sequential_outputs = 'hide_sequential_outputs',
|
||||||
|
programmatic_tools = 'programmatic_tools',
|
||||||
end_after_tools = 'end_after_tools',
|
end_after_tools = 'end_after_tools',
|
||||||
deferred_tools = 'deferred_tools',
|
deferred_tools = 'deferred_tools',
|
||||||
execute_code = 'execute_code',
|
execute_code = 'execute_code',
|
||||||
|
|
@ -262,6 +263,8 @@ export const assistantEndpointSchema = baseEndpointSchema.merge(
|
||||||
export type TAssistantEndpoint = z.infer<typeof assistantEndpointSchema>;
|
export type TAssistantEndpoint = z.infer<typeof assistantEndpointSchema>;
|
||||||
|
|
||||||
export const defaultAgentCapabilities = [
|
export const defaultAgentCapabilities = [
|
||||||
|
// Commented as requires latest Code Interpreter API
|
||||||
|
// AgentCapabilities.programmatic_tools,
|
||||||
AgentCapabilities.deferred_tools,
|
AgentCapabilities.deferred_tools,
|
||||||
AgentCapabilities.execute_code,
|
AgentCapabilities.execute_code,
|
||||||
AgentCapabilities.file_search,
|
AgentCapabilities.file_search,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue