💻 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:
Ravi Kumar L 2026-08-18 17:16:58 +02:00 committed by GitHub
parent 6daafda86f
commit da0491d5db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 557 additions and 15 deletions

View file

@ -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,

View file

@ -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', () => {

View file

@ -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

View file

@ -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}
/>
)}

View file

@ -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');

View file

@ -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)}

View file

@ -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[];

View file

@ -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[];

View file

@ -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');

View file

@ -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', () => {

View file

@ -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;

View file

@ -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' });

View file

@ -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",

View 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']);
});
});

View 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;
}

View file

@ -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';