mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
💻 fix(agents): require Code Interpreter for programmatic MCP tools (#14977)
* fix(agents): require code interpreter for programmatic MCP tools * test(data-provider): fix tool options fixture type * fix(agents): address programmatic tool review feedback * fix(agents): avoid no-op update on version revert
This commit is contained in:
parent
6daafda86f
commit
da0491d5db
16 changed files with 557 additions and 15 deletions
|
|
@ -37,6 +37,7 @@ const {
|
|||
AgentCapabilities,
|
||||
EModelEndpoint,
|
||||
resolveAllowedStatefulCodeEnvironments,
|
||||
removeCodeExecutionCaller,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
|
|
@ -271,6 +272,12 @@ const isSubagentsCapabilityEnabled = (req) => {
|
|||
return capabilities.includes(AgentCapabilities.subagents);
|
||||
};
|
||||
|
||||
const isCodeInterpreterCapabilityEnabled = (req) => {
|
||||
const capabilities = req.config?.endpoints?.[EModelEndpoint.agents]?.capabilities;
|
||||
if (!Array.isArray(capabilities)) return false;
|
||||
return capabilities.includes(AgentCapabilities.execute_code);
|
||||
};
|
||||
|
||||
/** Reject a newly selected stateful workspace scope that the deployment owner
|
||||
* has excluded. Disabled sessions and unrelated edits remain saveable so an
|
||||
* allowlist tightening never silently rewrites or strands an existing agent. */
|
||||
|
|
@ -533,6 +540,13 @@ const createAgentHandler = async (req, res) => {
|
|||
const validatedData = agentCreateSchema.parse(req.body);
|
||||
const { tools = [], ...agentData } = removeNullishValues(validatedData);
|
||||
|
||||
if (
|
||||
(!isCodeInterpreterCapabilityEnabled(req) || !tools.includes(Tools.execute_code)) &&
|
||||
agentData.tool_options != null
|
||||
) {
|
||||
agentData.tool_options = removeCodeExecutionCaller(agentData.tool_options);
|
||||
}
|
||||
|
||||
if (
|
||||
!validateStatefulCodeEnvironment(
|
||||
req,
|
||||
|
|
@ -818,7 +832,12 @@ const updateAgentHandler = async (req, res) => {
|
|||
updateData.stateful_code_sessions !== undefined ||
|
||||
updateData.stateful_code_environment !== undefined;
|
||||
const includesToolsConfiguration = Array.isArray(updateData.tools);
|
||||
if (includesStatefulConfiguration || includesToolsConfiguration) {
|
||||
const includesToolOptionsConfiguration = updateData.tool_options !== undefined;
|
||||
if (
|
||||
includesStatefulConfiguration ||
|
||||
includesToolsConfiguration ||
|
||||
includesToolOptionsConfiguration
|
||||
) {
|
||||
existingAgent = await db.getAgent({ id });
|
||||
if (!existingAgent) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
|
|
@ -851,6 +870,18 @@ const updateAgentHandler = async (req, res) => {
|
|||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (includesToolsConfiguration || includesToolOptionsConfiguration) {
|
||||
const effectiveTools = updateData.tools ?? existingAgent.tools;
|
||||
const effectiveToolOptions = updateData.tool_options ?? existingAgent.tool_options;
|
||||
if (
|
||||
(!isCodeInterpreterCapabilityEnabled(req) ||
|
||||
!effectiveTools?.includes(Tools.execute_code)) &&
|
||||
effectiveToolOptions != null
|
||||
) {
|
||||
updateData.tool_options = removeCodeExecutionCaller(effectiveToolOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updateData.model_parameters && typeof updateData.model_parameters === 'object') {
|
||||
|
|
@ -1265,6 +1296,14 @@ const duplicateAgentHandler = async (req, res) => {
|
|||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(!isCodeInterpreterCapabilityEnabled(req) ||
|
||||
!newAgentData.tools?.includes(Tools.execute_code)) &&
|
||||
newAgentData.tool_options != null
|
||||
) {
|
||||
newAgentData.tool_options = removeCodeExecutionCaller(newAgentData.tool_options);
|
||||
}
|
||||
|
||||
const newAgent = await db.createAgent(newAgentData);
|
||||
|
||||
try {
|
||||
|
|
@ -1757,6 +1796,18 @@ const revertAgentVersionHandler = async (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
const effectiveRevertTools = revertUpdates.tools ?? updatedAgent.tools;
|
||||
const hasCodeExecutionCaller = Object.values(updatedAgent.tool_options ?? {}).some((options) =>
|
||||
options.allowed_callers?.includes('code_execution'),
|
||||
);
|
||||
if (
|
||||
(!isCodeInterpreterCapabilityEnabled(req) ||
|
||||
!effectiveRevertTools?.includes(Tools.execute_code)) &&
|
||||
hasCodeExecutionCaller
|
||||
) {
|
||||
revertUpdates.tool_options = removeCodeExecutionCaller(updatedAgent.tool_options);
|
||||
}
|
||||
|
||||
if (updatedAgent.tool_resources) {
|
||||
const removedCount = await pruneToolResourceFileIdsForAgent({
|
||||
tool_resources: updatedAgent.tool_resources,
|
||||
|
|
|
|||
|
|
@ -182,6 +182,23 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
});
|
||||
|
||||
describe('createAgentHandler', () => {
|
||||
test('removes programmatic tool options when Code Interpreter capability is disabled', async () => {
|
||||
mockReq.body = {
|
||||
name: 'Invalid Programmatic Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: [Tools.execute_code, 'search_mcp_example'],
|
||||
tool_options: {
|
||||
search_mcp_example: { allowed_callers: ['code_execution'] },
|
||||
},
|
||||
};
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
expect(mockRes.json.mock.calls[0][0].tool_options).toEqual({});
|
||||
});
|
||||
|
||||
test('rejects a stateful environment excluded by deployment policy', async () => {
|
||||
mockReq.config = {
|
||||
endpoints: {
|
||||
|
|
@ -824,6 +841,136 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
expect(agentInDb.name).toBe('Updated Agent');
|
||||
});
|
||||
|
||||
test('removes newly added programmatic options when Code Interpreter capability is disabled', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{ tools: [Tools.execute_code, 'search_mcp_example'] },
|
||||
);
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
tool_options: {
|
||||
search_mcp_example: { allowed_callers: ['code_execution'] },
|
||||
},
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json.mock.calls[0][0].tool_options).toEqual({});
|
||||
});
|
||||
|
||||
test('removes programmatic callers when Code Interpreter is disabled', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
tools: [Tools.execute_code, 'search_mcp_example'],
|
||||
tool_options: {
|
||||
search_mcp_example: {
|
||||
allowed_callers: ['code_execution'],
|
||||
defer_loading: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = { tools: ['search_mcp_example'] };
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json.mock.calls[0][0].tool_options).toEqual({
|
||||
search_mcp_example: { defer_loading: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('allows unrelated edits to a legacy inconsistent agent', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
tools: ['search_mcp_example'],
|
||||
tool_options: {
|
||||
search_mcp_example: { allowed_callers: ['code_execution'] },
|
||||
},
|
||||
},
|
||||
);
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = { description: 'Still saveable' };
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json.mock.calls[0][0].description).toBe('Still saveable');
|
||||
});
|
||||
|
||||
test('allows detaching a programmatic tool from a legacy inconsistent agent', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
tools: ['search_mcp_example'],
|
||||
tool_options: {
|
||||
search_mcp_example: { allowed_callers: ['code_execution'] },
|
||||
},
|
||||
},
|
||||
);
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = { tools: [] };
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json.mock.calls[0][0].tools).toEqual([]);
|
||||
expect(mockRes.json.mock.calls[0][0].tool_options).toEqual({});
|
||||
});
|
||||
|
||||
test('allows clearing programmatic options from a legacy inconsistent agent', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
tools: ['search_mcp_example'],
|
||||
tool_options: {
|
||||
search_mcp_example: { allowed_callers: ['code_execution'] },
|
||||
},
|
||||
},
|
||||
);
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = { tool_options: {} };
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json.mock.calls[0][0].tool_options).toEqual({});
|
||||
});
|
||||
|
||||
test('removes all newly submitted programmatic options from a legacy agent', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
tools: ['search_mcp_example', 'lookup_mcp_example'],
|
||||
tool_options: {
|
||||
search_mcp_example: { allowed_callers: ['code_execution'] },
|
||||
},
|
||||
},
|
||||
);
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
tool_options: {
|
||||
search_mcp_example: { allowed_callers: ['code_execution'] },
|
||||
lookup_mcp_example: { allowed_callers: ['code_execution'] },
|
||||
},
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json.mock.calls[0][0].tool_options).toEqual({});
|
||||
});
|
||||
|
||||
test('rejects selecting a stateful environment excluded by deployment policy', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
|
|
@ -1495,6 +1642,83 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
const agentInDb = await Agent.findOne({ id: agent.id }).lean();
|
||||
expect(agentInDb.tool_resources.file_search.file_ids).toEqual([ownedFileId, otherFileId]);
|
||||
});
|
||||
|
||||
test('duplicateAgentHandler removes programmatic options without Code Interpreter', async () => {
|
||||
const sourceAgent = await Agent.create({
|
||||
id: `agent_${uuidv4()}`,
|
||||
name: 'Legacy Programmatic Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: mockReq.user.id,
|
||||
tools: ['search_mcp_example'],
|
||||
tool_options: {
|
||||
search_mcp_example: { allowed_callers: ['code_execution'] },
|
||||
},
|
||||
});
|
||||
const db = require('~/models');
|
||||
jest.spyOn(db, 'getActions').mockResolvedValueOnce([]);
|
||||
mockReq.params.id = sourceAgent.id;
|
||||
|
||||
await duplicateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
expect(mockRes.json.mock.calls[0][0].agent.tool_options).toEqual({});
|
||||
});
|
||||
|
||||
test('revertAgentVersionHandler removes restored programmatic options without Code Interpreter', async () => {
|
||||
const agent = await Agent.create({
|
||||
id: `agent_${uuidv4()}`,
|
||||
name: 'Current Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: mockReq.user.id,
|
||||
versions: [
|
||||
{
|
||||
name: 'Legacy Programmatic Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: ['search_mcp_example'],
|
||||
tool_options: {
|
||||
search_mcp_example: { allowed_callers: ['code_execution'] },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
mockReq.params.id = agent.id;
|
||||
mockReq.body = { version_index: 0 };
|
||||
|
||||
await revertAgentVersionHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
expect(mockRes.json.mock.calls[0][0].tool_options).toEqual({});
|
||||
});
|
||||
|
||||
test('revertAgentVersionHandler does not update unchanged tool options', async () => {
|
||||
const agent = await Agent.create({
|
||||
id: `agent_${uuidv4()}`,
|
||||
name: 'Current Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: mockReq.user.id,
|
||||
versions: [
|
||||
{
|
||||
name: 'Historical Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tool_options: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
const db = require('~/models');
|
||||
const updateAgentSpy = jest.spyOn(db, 'updateAgent');
|
||||
mockReq.params.id = agent.id;
|
||||
mockReq.body = { version_index: 0 };
|
||||
|
||||
await revertAgentVersionHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
expect(updateAgentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mass Assignment Attack Scenarios', () => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ResourceType,
|
||||
EModelEndpoint,
|
||||
PermissionBits,
|
||||
removeCodeExecutionCaller,
|
||||
resolveStatefulCodeEnvironment,
|
||||
isAssistantsEndpoint,
|
||||
} from 'librechat-data-provider';
|
||||
|
|
@ -91,6 +92,8 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
|
|||
* execute_code is disabled so a stale opt-in can't silently reactivate later. */
|
||||
const normalizedStatefulCodeSessions =
|
||||
data.execute_code === true ? stateful_code_sessions : false;
|
||||
const normalizedToolOptions =
|
||||
data.execute_code === true ? tool_options : removeCodeExecutionCaller(tool_options);
|
||||
const normalizedStatefulCodeEnvironment = stateful_code_environment ?? 'user';
|
||||
|
||||
const shouldResetAvatar =
|
||||
|
|
@ -118,7 +121,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
|
|||
recursion_limit,
|
||||
category,
|
||||
support_contact,
|
||||
tool_options,
|
||||
tool_options: normalizedToolOptions,
|
||||
skills,
|
||||
skills_enabled,
|
||||
/** A hidden stale 'agent' scope must not survive disabling memory —
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ interface MCPToolItemProps {
|
|||
intentDisabled: boolean;
|
||||
deferredToolsEnabled: boolean;
|
||||
programmaticToolsEnabled: boolean;
|
||||
programmaticToolsAvailable: boolean;
|
||||
backgroundToolsEnabled: boolean;
|
||||
toolIntentsEnabled: boolean;
|
||||
onToggleSelect: () => void;
|
||||
|
|
@ -44,6 +45,7 @@ export default function MCPToolItem({
|
|||
onToggleIntent,
|
||||
deferredToolsEnabled,
|
||||
programmaticToolsEnabled,
|
||||
programmaticToolsAvailable,
|
||||
backgroundToolsEnabled,
|
||||
toolIntentsEnabled,
|
||||
}: MCPToolItemProps) {
|
||||
|
|
@ -95,8 +97,13 @@ export default function MCPToolItem({
|
|||
icon={Code2}
|
||||
pressed={isProgrammatic}
|
||||
label={localize('com_ui_mcp_programmatic')}
|
||||
tooltip={localize('com_ui_mcp_click_to_programmatic')}
|
||||
tooltip={localize(
|
||||
programmaticToolsAvailable
|
||||
? 'com_ui_mcp_click_to_programmatic'
|
||||
: 'com_ui_mcp_programmatic_requires_code',
|
||||
)}
|
||||
activeClass="text-violet-500"
|
||||
disabled={!programmaticToolsAvailable && !isProgrammatic}
|
||||
onToggle={onToggleProgrammatic}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ const mockInitializeServer = jest.fn();
|
|||
const mockIsConnectionDeferred = jest.fn((): boolean => false);
|
||||
const mockToggleIntentAll = jest.fn();
|
||||
const mockIsToolProgrammaticOnly = jest.fn((_toolId: string): boolean => false);
|
||||
const mockAreAllToolsProgrammatic = jest.fn((): boolean => false);
|
||||
const mockCapabilities = {
|
||||
codeEnabled: false,
|
||||
deferredToolsEnabled: false,
|
||||
programmaticToolsEnabled: false,
|
||||
backgroundToolsEnabled: false,
|
||||
|
|
@ -21,10 +23,19 @@ const mockCapabilities = {
|
|||
|
||||
jest.mock('react-hook-form', () => ({
|
||||
useFormContext: () => ({ control: {}, setValue: mockSetValue, getValues: mockGetValues }),
|
||||
useWatch: ({ name }: { name: string }) =>
|
||||
name === 'tool_options' ? mockGetToolOptions() : mockGetValues(),
|
||||
useWatch: ({ name }: { name: string }) => {
|
||||
if (name === 'tool_options') {
|
||||
return mockGetToolOptions();
|
||||
}
|
||||
if (name === 'execute_code') {
|
||||
return mockCodeInterpreterSelected();
|
||||
}
|
||||
return mockGetValues();
|
||||
},
|
||||
}));
|
||||
|
||||
const mockCodeInterpreterSelected = jest.fn((): boolean => false);
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
useAgentPanelContext: () => ({ mcpServersMap: mockMcpServersMap() }),
|
||||
}));
|
||||
|
|
@ -60,7 +71,7 @@ jest.mock('~/hooks', () => ({
|
|||
toggleToolBackground: jest.fn(),
|
||||
toggleToolIntent: jest.fn(),
|
||||
areAllToolsDeferred: () => false,
|
||||
areAllToolsProgrammatic: () => false,
|
||||
areAllToolsProgrammatic: mockAreAllToolsProgrammatic,
|
||||
areAllToolsBackground: () => false,
|
||||
areAllToolsIntent: () => false,
|
||||
toggleDeferAll: jest.fn(),
|
||||
|
|
@ -164,10 +175,18 @@ describe('McpSection', () => {
|
|||
mockToggleIntentAll.mockClear();
|
||||
mockIsToolProgrammaticOnly.mockReset();
|
||||
mockIsToolProgrammaticOnly.mockReturnValue(false);
|
||||
mockAreAllToolsProgrammatic.mockReset();
|
||||
mockAreAllToolsProgrammatic.mockReturnValue(false);
|
||||
mockGetToolOptions.mockReset();
|
||||
mockGetToolOptions.mockReturnValue(undefined);
|
||||
mockMcpServersMap.mockReset();
|
||||
mockMcpServersMap.mockReturnValue(new Map());
|
||||
mockCodeInterpreterSelected.mockReset();
|
||||
mockCodeInterpreterSelected.mockReturnValue(false);
|
||||
mockCapabilities.codeEnabled = false;
|
||||
mockCapabilities.deferredToolsEnabled = false;
|
||||
mockCapabilities.programmaticToolsEnabled = false;
|
||||
mockCapabilities.backgroundToolsEnabled = false;
|
||||
mockCapabilities.toolIntentsEnabled = false;
|
||||
});
|
||||
|
||||
|
|
@ -405,6 +424,34 @@ describe('McpSection', () => {
|
|||
expect(mockToggleIntentAll).toHaveBeenCalledWith(item.server.tools);
|
||||
});
|
||||
|
||||
test('bulk programmatic toggle requires Code Interpreter to be available and selected', () => {
|
||||
mockCapabilities.programmaticToolsEnabled = true;
|
||||
const { unmount } = render(<McpSection item={item} />);
|
||||
expect(screen.getByRole('button', { name: 'com_ui_mcp_programmatic_all' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
unmount();
|
||||
|
||||
mockCapabilities.codeEnabled = true;
|
||||
mockCodeInterpreterSelected.mockReturnValue(true);
|
||||
render(<McpSection item={item} />);
|
||||
expect(screen.getByRole('button', { name: 'com_ui_mcp_programmatic_all' })).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('bulk programmatic toggle can clear a legacy programmatic configuration', () => {
|
||||
mockCapabilities.programmaticToolsEnabled = true;
|
||||
mockAreAllToolsProgrammatic.mockReturnValue(true);
|
||||
|
||||
render(<McpSection item={item} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'com_ui_mcp_unprogrammatic_all' }),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
test('bulk intent skips programmatic-only tools (label can never reach them)', () => {
|
||||
mockCapabilities.toolIntentsEnabled = true;
|
||||
mockIsToolProgrammaticOnly.mockImplementation((toolId: string) => toolId === 'mcp:srv:a');
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Clock, Code2, Captions, Zap } from 'lucide-react';
|
|||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { Button, Spinner, Checkbox, Skeleton } from '@librechat/client';
|
||||
import {
|
||||
AgentCapabilities,
|
||||
Constants,
|
||||
splitMCPToolKey,
|
||||
normalizeServerName,
|
||||
|
|
@ -83,11 +84,15 @@ export default function McpSection({ item }: Props) {
|
|||
const { mcpServersMap, mcpToolsLoading } = useAgentPanelContext();
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
const {
|
||||
codeEnabled,
|
||||
deferredToolsEnabled,
|
||||
programmaticToolsEnabled,
|
||||
backgroundToolsEnabled,
|
||||
toolIntentsEnabled,
|
||||
} = useAgentCapabilities(agentsConfig?.capabilities);
|
||||
const codeInterpreterSelected = useWatch({ control, name: AgentCapabilities.execute_code });
|
||||
const programmaticToolsAvailable =
|
||||
codeEnabled && programmaticToolsEnabled && codeInterpreterSelected === true;
|
||||
const {
|
||||
isToolDeferred,
|
||||
isToolProgrammatic,
|
||||
|
|
@ -274,6 +279,13 @@ export default function McpSection({ item }: Props) {
|
|||
const allSelected = hasTools && selectedTools.length === tools.length;
|
||||
const allDeferred = areAllToolsDeferred(tools);
|
||||
const allProgrammatic = areAllToolsProgrammatic(tools);
|
||||
const programmaticBulkLabel = localize(
|
||||
allProgrammatic ? 'com_ui_mcp_unprogrammatic_all' : 'com_ui_mcp_programmatic_all',
|
||||
);
|
||||
const programmaticBulkTooltip =
|
||||
programmaticToolsAvailable || allProgrammatic
|
||||
? programmaticBulkLabel
|
||||
: localize('com_ui_mcp_programmatic_requires_code');
|
||||
const allBackground = areAllToolsBackground(tools);
|
||||
/** Programmatic-only tools can never carry an intent label (the backend's
|
||||
* `canInjectIntentParam` skips non-direct tools), so both the bulk toggle
|
||||
|
|
@ -450,12 +462,10 @@ export default function McpSection({ item }: Props) {
|
|||
icon={Code2}
|
||||
size="md"
|
||||
pressed={allProgrammatic}
|
||||
label={localize(
|
||||
allProgrammatic
|
||||
? 'com_ui_mcp_unprogrammatic_all'
|
||||
: 'com_ui_mcp_programmatic_all',
|
||||
)}
|
||||
label={programmaticBulkLabel}
|
||||
activeClass="text-violet-600 dark:text-violet-500"
|
||||
tooltip={programmaticBulkTooltip}
|
||||
disabled={!programmaticToolsAvailable && !allProgrammatic}
|
||||
onToggle={() => toggleProgrammaticAll(tools)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -533,6 +543,7 @@ export default function McpSection({ item }: Props) {
|
|||
intentDisabled={isToolProgrammaticOnly(tool.tool_id)}
|
||||
deferredToolsEnabled={deferredToolsEnabled}
|
||||
programmaticToolsEnabled={programmaticToolsEnabled}
|
||||
programmaticToolsAvailable={programmaticToolsAvailable}
|
||||
backgroundToolsEnabled={backgroundToolsEnabled}
|
||||
toolIntentsEnabled={toolIntentsEnabled}
|
||||
onToggleSelect={() => toggleToolSelect(tool.tool_id)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { AgentCapabilities, removeCodeExecutionCaller } from 'librechat-data-provider';
|
||||
import {
|
||||
Input,
|
||||
OGDialog,
|
||||
|
|
@ -109,6 +110,11 @@ export default function ToolsMarketplaceDialog({
|
|||
switch (patch.type) {
|
||||
case 'builtin':
|
||||
setValue(patch.field as keyof AgentForm, patch.value as never, { shouldDirty: true });
|
||||
if (patch.field === AgentCapabilities.execute_code && patch.value === false) {
|
||||
setValue('tool_options', removeCodeExecutionCaller(getValues('tool_options')), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'tool-add': {
|
||||
const current = (getValues('tools') ?? []) as string[];
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { PermissionTypes, Permissions, AgentCapabilities } from 'librechat-data-provider';
|
||||
import {
|
||||
PermissionTypes,
|
||||
Permissions,
|
||||
AgentCapabilities,
|
||||
removeCodeExecutionCaller,
|
||||
} from 'librechat-data-provider';
|
||||
import {
|
||||
Label,
|
||||
Switch,
|
||||
|
|
@ -153,6 +158,11 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
switch (patch.type) {
|
||||
case 'builtin':
|
||||
setValue(patch.field as keyof AgentForm, patch.value as never, { shouldDirty: true });
|
||||
if (patch.field === AgentCapabilities.execute_code && patch.value === false) {
|
||||
setValue('tool_options', removeCodeExecutionCaller(getValues('tool_options')), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'tool-remove': {
|
||||
const current = (getValues('tools') ?? []) as string[];
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
|||
import ToolsMarketplaceDialog from '../ToolsMarketplaceDialog';
|
||||
|
||||
const mockSetValue = jest.fn();
|
||||
const mockGetValues = jest.fn((): string[] => []);
|
||||
const mockGetValues = jest.fn((_: string): unknown => []);
|
||||
let mockWatchedTools: string[] = [];
|
||||
let mockExecuteCode = false;
|
||||
let mockMcpServersMap = new Map<string, object>();
|
||||
|
||||
jest.mock('react-hook-form', () => ({
|
||||
|
|
@ -17,7 +18,7 @@ jest.mock('react-hook-form', () => ({
|
|||
const map: Record<string, unknown> = {
|
||||
tools: mockWatchedTools,
|
||||
skills: [],
|
||||
execute_code: false,
|
||||
execute_code: mockExecuteCode,
|
||||
web_search: false,
|
||||
file_search: false,
|
||||
artifacts: '',
|
||||
|
|
@ -156,6 +157,7 @@ describe('ToolsMarketplaceDialog', () => {
|
|||
mockGetValues.mockClear();
|
||||
mockGetValues.mockReturnValue([]);
|
||||
mockWatchedTools = [];
|
||||
mockExecuteCode = false;
|
||||
mockMcpServersMap = new Map();
|
||||
mockToggleFavorite.mockClear();
|
||||
mockFavoriteKeys = new Set<string>();
|
||||
|
|
@ -183,6 +185,31 @@ describe('ToolsMarketplaceDialog', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('disabling Code Interpreter clears programmatic MCP callers immediately', () => {
|
||||
mockExecuteCode = true;
|
||||
mockGetValues.mockImplementation((name: string) =>
|
||||
name === 'tool_options'
|
||||
? {
|
||||
search: { allowed_callers: ['code_execution'], defer_loading: true },
|
||||
direct: { allowed_callers: ['direct'] },
|
||||
}
|
||||
: [],
|
||||
);
|
||||
|
||||
render(<ToolsMarketplaceDialog open onOpenChange={jest.fn()} agentId="a1" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /com_ui_run_code/ }));
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith('execute_code', false, { shouldDirty: true });
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{
|
||||
search: { defer_loading: true },
|
||||
direct: { allowed_callers: ['direct'] },
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
test('typing in search input filters the catalog', () => {
|
||||
render(<ToolsMarketplaceDialog open onOpenChange={jest.fn()} agentId="a1" />);
|
||||
const input = screen.getByPlaceholderText('com_ui_tools_marketplace_search');
|
||||
|
|
|
|||
|
|
@ -198,6 +198,37 @@ describe('ToolsSection', () => {
|
|||
expect(screen.queryByTestId('item-dialog')).not.toBeInTheDocument();
|
||||
expect(mockSetValue).toHaveBeenCalledWith('file_search', false, { shouldDirty: true });
|
||||
});
|
||||
|
||||
test('clears programmatic MCP callers when Code Interpreter is removed', () => {
|
||||
mockSelected = [
|
||||
{
|
||||
kind: 'builtin',
|
||||
id: 'execute_code',
|
||||
name: 'Run Code',
|
||||
description: '',
|
||||
iconKey: 'execute_code',
|
||||
},
|
||||
];
|
||||
mockFormValues = {
|
||||
tool_options: {
|
||||
search: { allowed_callers: ['code_execution'], defer_loading: true },
|
||||
direct: { allowed_callers: ['direct'] },
|
||||
},
|
||||
};
|
||||
|
||||
render(<ToolsSection agentId="a" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'remove-execute_code' }));
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith('execute_code', false, { shouldDirty: true });
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{
|
||||
search: { defer_loading: true },
|
||||
direct: { allowed_callers: ['direct'] },
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('use all skills toggle', () => {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,32 @@ describe('composeAgentUpdatePayload', () => {
|
|||
expect(payload.stateful_code_sessions).toBe(false);
|
||||
});
|
||||
|
||||
it('removes programmatic callers when execute_code is disabled', () => {
|
||||
const form = createForm();
|
||||
form.execute_code = false;
|
||||
form.tool_options = {
|
||||
search: { allowed_callers: ['code_execution'], defer_loading: true },
|
||||
};
|
||||
|
||||
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
|
||||
|
||||
expect(payload.tool_options).toEqual({ search: { defer_loading: true } });
|
||||
});
|
||||
|
||||
it('preserves programmatic callers when execute_code is enabled', () => {
|
||||
const form = createForm();
|
||||
form.execute_code = true;
|
||||
form.tool_options = {
|
||||
search: { allowed_callers: ['code_execution'] },
|
||||
};
|
||||
|
||||
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
|
||||
|
||||
expect(payload.tool_options).toEqual({
|
||||
search: { allowed_callers: ['code_execution'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves stateful_code_sessions when execute_code is enabled', () => {
|
||||
const form = createForm();
|
||||
form.execute_code = true;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ function setup(overrides: Partial<React.ComponentProps<typeof MCPToolItem>> = {}
|
|||
intentDisabled: false,
|
||||
deferredToolsEnabled: false,
|
||||
programmaticToolsEnabled: false,
|
||||
programmaticToolsAvailable: false,
|
||||
backgroundToolsEnabled: false,
|
||||
toolIntentsEnabled: false,
|
||||
onToggleSelect: jest.fn(),
|
||||
|
|
@ -108,12 +109,34 @@ describe('MCPToolItem', () => {
|
|||
});
|
||||
|
||||
test('programmatic is an inline button rendered only when enabled', () => {
|
||||
const props = setup({ programmaticToolsEnabled: true });
|
||||
const props = setup({ programmaticToolsEnabled: true, programmaticToolsAvailable: true });
|
||||
const programmaticButton = screen.getByRole('button', { name: 'com_ui_mcp_programmatic' });
|
||||
fireEvent.click(programmaticButton);
|
||||
expect(props.onToggleProgrammatic).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('programmatic is inert until Code Interpreter is selected', () => {
|
||||
const props = setup({ programmaticToolsEnabled: true, programmaticToolsAvailable: false });
|
||||
const programmaticButton = screen.getByRole('button', { name: 'com_ui_mcp_programmatic' });
|
||||
|
||||
expect(programmaticButton).toHaveAttribute('aria-disabled', 'true');
|
||||
fireEvent.click(programmaticButton);
|
||||
expect(props.onToggleProgrammatic).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('an existing programmatic setting can be cleared after Code Interpreter is disabled', () => {
|
||||
const props = setup({
|
||||
programmaticToolsEnabled: true,
|
||||
programmaticToolsAvailable: false,
|
||||
isProgrammatic: true,
|
||||
});
|
||||
const programmaticButton = screen.getByRole('button', { name: 'com_ui_mcp_programmatic' });
|
||||
|
||||
expect(programmaticButton).not.toHaveAttribute('aria-disabled');
|
||||
fireEvent.click(programmaticButton);
|
||||
expect(props.onToggleProgrammatic).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('background is an inline button rendered only when enabled', () => {
|
||||
const props = setup({ backgroundToolsEnabled: true });
|
||||
const backgroundButton = screen.getByRole('button', { name: 'com_ui_mcp_background' });
|
||||
|
|
|
|||
|
|
@ -1505,6 +1505,7 @@
|
|||
"com_ui_mcp_oauth_secret_reentry_required": "OAuth settings changed. Re-enter the client secret to save this MCP server.",
|
||||
"com_ui_mcp_oauth_timeout": "OAuth login timed out for {{0}}",
|
||||
"com_ui_mcp_programmatic": "Programmatic",
|
||||
"com_ui_mcp_programmatic_requires_code": "Enable Code Interpreter before making MCP tools programmatic.",
|
||||
"com_ui_mcp_programmatic_all": "Mark all as programmatic",
|
||||
"com_ui_mcp_reauthentication_required": "MCP server '{{0}}' needs authentication. Reconnect to continue; if that fails, revoke its OAuth access and try again.",
|
||||
"com_ui_mcp_server": "MCP Server",
|
||||
|
|
|
|||
38
packages/data-provider/src/agentToolOptions.spec.ts
Normal file
38
packages/data-provider/src/agentToolOptions.spec.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type { AgentToolOptions } from './types/assistants';
|
||||
import { removeCodeExecutionCaller } from './agentToolOptions';
|
||||
|
||||
describe('removeCodeExecutionCaller', () => {
|
||||
it('removes a programmatic-only entry that has no other options', () => {
|
||||
expect(
|
||||
removeCodeExecutionCaller({
|
||||
search: { allowed_callers: ['code_execution'] },
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('preserves direct calling and unrelated options', () => {
|
||||
expect(
|
||||
removeCodeExecutionCaller({
|
||||
search: {
|
||||
allowed_callers: ['direct', 'code_execution'],
|
||||
defer_loading: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
search: {
|
||||
allowed_callers: ['direct'],
|
||||
defer_loading: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate its input', () => {
|
||||
const input: AgentToolOptions = {
|
||||
search: { allowed_callers: ['code_execution'], run_in_background: true },
|
||||
};
|
||||
|
||||
removeCodeExecutionCaller(input);
|
||||
|
||||
expect(input.search.allowed_callers).toEqual(['code_execution']);
|
||||
});
|
||||
});
|
||||
36
packages/data-provider/src/agentToolOptions.ts
Normal file
36
packages/data-provider/src/agentToolOptions.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { AgentToolOptions, AllowedCaller } from './types/assistants';
|
||||
|
||||
/**
|
||||
* Removes Code Interpreter as an allowed caller without mutating the input.
|
||||
* Tool entries and unrelated options are preserved; an empty entry is removed.
|
||||
*/
|
||||
export function removeCodeExecutionCaller(
|
||||
toolOptions: AgentToolOptions | undefined,
|
||||
): AgentToolOptions | undefined {
|
||||
if (toolOptions == null) {
|
||||
return toolOptions;
|
||||
}
|
||||
|
||||
const normalized: AgentToolOptions = {};
|
||||
for (const [toolName, options] of Object.entries(toolOptions)) {
|
||||
const callers = options.allowed_callers;
|
||||
if (callers?.includes('code_execution') !== true) {
|
||||
normalized[toolName] = options;
|
||||
continue;
|
||||
}
|
||||
|
||||
const allowedCallers = callers.filter(
|
||||
(caller): caller is AllowedCaller => caller !== 'code_execution',
|
||||
);
|
||||
const { allowed_callers: _removed, ...remainingOptions } = options;
|
||||
const nextOptions =
|
||||
allowedCallers.length > 0
|
||||
? { ...remainingOptions, allowed_callers: allowedCallers }
|
||||
: remainingOptions;
|
||||
if (Object.keys(nextOptions).length > 0) {
|
||||
normalized[toolName] = nextOptions;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
|
@ -58,5 +58,6 @@ export { default as createPayload } from './createPayload';
|
|||
/* feedback */
|
||||
export * from './feedback';
|
||||
export * from './parameterSettings';
|
||||
export * from './agentToolOptions';
|
||||
/* code-execution sandbox */
|
||||
export * from './codeEnvRef';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue