From 131c93fb6bfbabad0e7d5c99146598da82c672f3 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 14:23:50 +0000 Subject: [PATCH 01/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20feat:=20Add=20use?= =?UTF-8?q?r=5Ftoggle=20Contract=20for=20Agent=20Tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved agents can mark a built-in tool or a whole MCP server as switchable by the chat user, with an on/off starting state. Adds the option type, the helpers that read the switchable set and apply a chat's switch state (only ever removing tools), and validation of the field on agent save. --- packages/api/src/agents/validation.spec.ts | 21 ++++ packages/api/src/agents/validation.ts | 16 ++- .../src/agentToolOptions.spec.ts | 107 +++++++++++++++++- .../data-provider/src/agentToolOptions.ts | 90 +++++++++++++++ packages/data-provider/src/types/tools.ts | 5 + 5 files changed, 237 insertions(+), 2 deletions(-) diff --git a/packages/api/src/agents/validation.spec.ts b/packages/api/src/agents/validation.spec.ts index 8b3a75637e1..c2b5bcedb59 100644 --- a/packages/api/src/agents/validation.spec.ts +++ b/packages/api/src/agents/validation.spec.ts @@ -452,3 +452,24 @@ describe('validateAgentModel', () => { expect(logViolation).toHaveBeenCalledTimes(1); }); }); + +describe('tool_options user_toggle', () => { + const base = { provider: 'openAI', model: 'gpt-4o-mini', tools: ['web_search'] }; + + it.each(['on', 'off'])('keeps %s on create and update', (user_toggle) => { + const tool_options = { web_search: { user_toggle } }; + expect(agentCreateSchema.parse({ ...base, tool_options }).tool_options).toEqual(tool_options); + expect(agentUpdateSchema.parse({ tool_options }).tool_options).toEqual(tool_options); + }); + + it('rejects any other value', () => { + const tool_options = { web_search: { user_toggle: 'auto' } }; + expect(agentCreateSchema.safeParse({ ...base, tool_options }).success).toBe(false); + expect(agentUpdateSchema.safeParse({ tool_options }).success).toBe(false); + }); + + it('treats a missing toggle as locked', () => { + const parsed = agentCreateSchema.parse({ ...base, tool_options: { web_search: {} } }); + expect(parsed.tool_options?.web_search.user_toggle).toBeUndefined(); + }); +}); diff --git a/packages/api/src/agents/validation.ts b/packages/api/src/agents/validation.ts index 49ada6e2121..b7a03c7db31 100644 --- a/packages/api/src/agents/validation.ts +++ b/packages/api/src/agents/validation.ts @@ -149,13 +149,14 @@ export const graphEdgeSchema: z.ZodObject< .transform((v) => (v === '' ? undefined : v)), }); -/** Per-tool options schema (defer_loading, allowed_callers, run_in_background, describe_intent) */ +/** Per-tool options schema (defer_loading, allowed_callers, run_in_background, describe_intent, user_toggle) */ export const toolOptionsSchema: z.ZodObject< { defer_loading: z.ZodOptional; allowed_callers: z.ZodOptional, 'many'>>; run_in_background: z.ZodOptional; describe_intent: z.ZodOptional; + user_toggle: z.ZodOptional>; }, 'strip' > = z.object({ @@ -163,6 +164,7 @@ export const toolOptionsSchema: z.ZodObject< allowed_callers: z.array(z.enum(['direct', 'code_execution'])).optional(), run_in_background: z.boolean().optional(), describe_intent: z.boolean().optional(), + user_toggle: z.enum(['on', 'off']).optional(), }); /** Agent tool options - map of tool_id to tool options */ @@ -175,6 +177,7 @@ export const agentToolOptionsSchema: z.ZodOptional< allowed_callers: z.ZodOptional, 'many'>>; run_in_background: z.ZodOptional; describe_intent: z.ZodOptional; + user_toggle: z.ZodOptional>; }, 'strip', z.ZodTypeAny, @@ -183,12 +186,14 @@ export const agentToolOptionsSchema: z.ZodOptional< allowed_callers?: ('direct' | 'code_execution')[] | undefined; run_in_background?: boolean | undefined; describe_intent?: boolean | undefined; + user_toggle?: 'on' | 'off' | undefined; }, { defer_loading?: boolean | undefined; allowed_callers?: ('direct' | 'code_execution')[] | undefined; run_in_background?: boolean | undefined; describe_intent?: boolean | undefined; + user_toggle?: 'on' | 'off' | undefined; } > > @@ -525,6 +530,7 @@ export const agentBaseSchema: z.ZodObject< >; run_in_background: z.ZodOptional; describe_intent: z.ZodOptional; + user_toggle: z.ZodOptional>; }, 'strip', z.ZodTypeAny, @@ -533,12 +539,14 @@ export const agentBaseSchema: z.ZodObject< allowed_callers?: ('direct' | 'code_execution')[] | undefined; run_in_background?: boolean | undefined; describe_intent?: boolean | undefined; + user_toggle?: 'on' | 'off' | undefined; }, { defer_loading?: boolean | undefined; allowed_callers?: ('direct' | 'code_execution')[] | undefined; run_in_background?: boolean | undefined; describe_intent?: boolean | undefined; + user_toggle?: 'on' | 'off' | undefined; } > > @@ -704,6 +712,7 @@ export const agentCreateSchema: z.ZodObject< >; run_in_background: z.ZodOptional; describe_intent: z.ZodOptional; + user_toggle: z.ZodOptional>; }, 'strip', z.ZodTypeAny, @@ -712,12 +721,14 @@ export const agentCreateSchema: z.ZodObject< allowed_callers?: ('direct' | 'code_execution')[] | undefined; run_in_background?: boolean | undefined; describe_intent?: boolean | undefined; + user_toggle?: 'on' | 'off' | undefined; }, { defer_loading?: boolean | undefined; allowed_callers?: ('direct' | 'code_execution')[] | undefined; run_in_background?: boolean | undefined; describe_intent?: boolean | undefined; + user_toggle?: 'on' | 'off' | undefined; } > > @@ -841,6 +852,7 @@ export const agentUpdateSchema: z.ZodObject< >; run_in_background: z.ZodOptional; describe_intent: z.ZodOptional; + user_toggle: z.ZodOptional>; }, 'strip', z.ZodTypeAny, @@ -849,12 +861,14 @@ export const agentUpdateSchema: z.ZodObject< allowed_callers?: ('direct' | 'code_execution')[] | undefined; run_in_background?: boolean | undefined; describe_intent?: boolean | undefined; + user_toggle?: 'on' | 'off' | undefined; }, { defer_loading?: boolean | undefined; allowed_callers?: ('direct' | 'code_execution')[] | undefined; run_in_background?: boolean | undefined; describe_intent?: boolean | undefined; + user_toggle?: 'on' | 'off' | undefined; } > > diff --git a/packages/data-provider/src/agentToolOptions.spec.ts b/packages/data-provider/src/agentToolOptions.spec.ts index 70ac60238f9..1dc6abcde8d 100644 --- a/packages/data-provider/src/agentToolOptions.spec.ts +++ b/packages/data-provider/src/agentToolOptions.spec.ts @@ -1,5 +1,11 @@ import type { AgentToolOptions } from './types/tools'; -import { normalizeActionToolName, removeCodeExecutionCaller } from './agentToolOptions'; +import { + mcpServerToggleKey, + getAgentToolSwitches, + applyAgentToolSwitches, + normalizeActionToolName, + removeCodeExecutionCaller, +} from './agentToolOptions'; describe('normalizeActionToolName', () => { it('normalizes only the encoded action domain', () => { @@ -51,3 +57,102 @@ describe('removeCodeExecutionCaller', () => { expect(input.search.allowed_callers).toEqual(['code_execution']); }); }); + +describe('mcpServerToggleKey', () => { + it('builds the server-level placeholder key', () => { + expect(mcpServerToggleKey('alpha')).toBe('sys__server__sys_mcp_alpha'); + }); +}); + +describe('getAgentToolSwitches', () => { + it('reports only attached tools that carry a user_toggle', () => { + expect( + getAgentToolSwitches({ + tools: ['web_search', 'execute_code', 'search_mcp_alpha', 'sys__all__sys_mcp_beta'], + tool_options: { + web_search: { user_toggle: 'on' }, + execute_code: { user_toggle: 'off' }, + file_search: { user_toggle: 'on' }, + [mcpServerToggleKey('alpha')]: { user_toggle: 'off' }, + [mcpServerToggleKey('beta')]: { defer_loading: true }, + [mcpServerToggleKey('absent')]: { user_toggle: 'on' }, + }, + }), + ).toEqual({ + builtins: { web_search: true, execute_code: false }, + mcp: { alpha: false }, + }); + }); + + it('is empty without tool_options', () => { + expect(getAgentToolSwitches({ tools: ['web_search'] })).toEqual({ builtins: {}, mcp: {} }); + }); +}); + +describe('applyAgentToolSwitches', () => { + const agent = { + tools: [ + 'web_search', + 'execute_code', + 'file_search', + 'a_mcp_alpha', + 'b_mcp_alpha', + 'c_mcp_beta', + 'plain', + ], + tool_options: { + web_search: { user_toggle: 'on' as const }, + execute_code: { user_toggle: 'off' as const }, + [mcpServerToggleKey('alpha')]: { user_toggle: 'on' as const }, + }, + }; + + it('uses the defaults without a request', () => { + expect(applyAgentToolSwitches(agent, null)).toEqual({ + tools: ['web_search', 'file_search', 'a_mcp_alpha', 'b_mcp_alpha', 'c_mcp_beta', 'plain'], + mcp: ['alpha', 'beta'], + }); + }); + + it('honours requested booleans and server lists, dropping every tool of an off server', () => { + expect( + applyAgentToolSwitches(agent, { web_search: false, execute_code: true, mcp: [] }), + ).toEqual({ + tools: ['execute_code', 'file_search', 'c_mcp_beta', 'plain'], + mcp: ['beta'], + }); + }); + + it('ignores non-boolean values and a non-array mcp', () => { + const requested = { web_search: 'no', mcp: 'alpha' } as unknown as Parameters< + typeof applyAgentToolSwitches + >[1]; + expect(applyAgentToolSwitches(agent, requested).tools).toEqual([ + 'web_search', + 'file_search', + 'a_mcp_alpha', + 'b_mcp_alpha', + 'c_mcp_beta', + 'plain', + ]); + }); + + it('never adds a locked tool or an unattached one', () => { + const result = applyAgentToolSwitches( + { tools: ['file_search'], tool_options: {} }, + { web_search: true, execute_code: true, mcp: ['alpha'] }, + ); + expect(result).toEqual({ tools: ['file_search'], mcp: [] }); + }); + + it('drops the server placeholder and wildcard tokens with the server', () => { + const result = applyAgentToolSwitches( + { + tools: ['sys__server__sys_mcp_alpha', 'sys__all__sys_mcp_alpha'], + tool_options: { [mcpServerToggleKey('alpha')]: { user_toggle: 'on' } }, + }, + { mcp: [] }, + ); + expect(result.tools).toEqual([]); + }); +}); diff --git a/packages/data-provider/src/agentToolOptions.ts b/packages/data-provider/src/agentToolOptions.ts index 8de99fd47bb..1968306516f 100644 --- a/packages/data-provider/src/agentToolOptions.ts +++ b/packages/data-provider/src/agentToolOptions.ts @@ -1,10 +1,14 @@ +import { Constants } from './config'; import { + Tools, actionDelimiter, actionDomainSeparator, isActionTool, type AgentToolOptions, type AllowedCaller, } from './types/tools'; +import type { TEphemeralAgent } from './types'; +import type { Agent } from './types/agents'; const actionDomainSeparatorRegex = new RegExp(actionDomainSeparator, 'g'); @@ -56,3 +60,89 @@ export function removeCodeExecutionCaller( return normalized; } + +export const switchableBuiltinTools = [ + Tools.web_search, + Tools.execute_code, + Tools.file_search, +] as const; + +export type SwitchableBuiltinTool = (typeof switchableBuiltinTools)[number]; + +/** `tool_options` key for a whole MCP server (`sys__server__sys_mcp_`). */ +export function mcpServerToggleKey(serverName: string): string { + return `${Constants.mcp_server}${Constants.mcp_delimiter}${serverName}`; +} + +export interface AgentToolSwitches { + /** Switchable built-ins attached to the agent; value = starts on. */ + builtins: Partial>; + /** Switchable attached MCP servers by name; value = starts on. */ + mcp: Record; +} + +type SwitchableAgent = Pick; + +function isServerTool(tool: string, serverName: string): boolean { + const suffix = `${Constants.mcp_delimiter}${serverName}`; + return tool.endsWith(suffix) || tool === `${Constants.mcp_prefix}${serverName}`; +} + +function attachedServerNames(tools: string[]): string[] { + const names = new Set(); + for (const tool of tools) { + const index = tool.lastIndexOf(Constants.mcp_delimiter); + if (index >= 0) { + names.add(tool.slice(index + Constants.mcp_delimiter.length)); + } + } + return Array.from(names); +} + +/** The creator's switchable set and defaults. Locked or unattached tools never appear. */ +export function getAgentToolSwitches(agent: SwitchableAgent): AgentToolSwitches { + const tools = agent.tools ?? []; + const options = agent.tool_options ?? {}; + const switches: AgentToolSwitches = { builtins: {}, mcp: {} }; + for (const tool of switchableBuiltinTools) { + const toggle = options[tool]?.user_toggle; + if (toggle != null && tools.includes(tool)) { + switches.builtins[tool] = toggle === 'on'; + } + } + for (const serverName of attachedServerNames(tools)) { + const toggle = options[mcpServerToggleKey(serverName)]?.user_toggle; + if (toggle != null) { + switches.mcp[serverName] = toggle === 'on'; + } + } + return switches; +} + +/** The agent's tools after applying the chat's untrusted switch state. Only ever removes. */ +export function applyAgentToolSwitches( + agent: SwitchableAgent, + requested: TEphemeralAgent | null | undefined, +): { tools: string[]; mcp: string[] } { + const tools = agent.tools ?? []; + const { builtins, mcp } = getAgentToolSwitches(agent); + const dropped = new Set(); + for (const tool of switchableBuiltinTools) { + const requestedValue = requested?.[tool]; + const defaultValue = builtins[tool]; + const isOn = typeof requestedValue === 'boolean' ? requestedValue : defaultValue; + if (defaultValue != null && !isOn) { + dropped.add(tool); + } + } + const requestedServers = Array.isArray(requested?.mcp) ? requested.mcp : undefined; + const offServers = Object.entries(mcp) + .filter( + ([name, isDefaultOn]) => !(requestedServers ? requestedServers.includes(name) : isDefaultOn), + ) + .map(([name]) => name); + const kept = tools.filter( + (tool) => !dropped.has(tool) && !offServers.some((name) => isServerTool(tool, name)), + ); + return { tools: kept, mcp: attachedServerNames(kept) }; +} diff --git a/packages/data-provider/src/types/tools.ts b/packages/data-provider/src/types/tools.ts index 26db147d12e..dbbda766144 100644 --- a/packages/data-provider/src/types/tools.ts +++ b/packages/data-provider/src/types/tools.ts @@ -77,6 +77,9 @@ export interface FileSearchResource { */ export type AllowedCaller = 'direct' | 'code_execution'; +/** Switchable by the chat user; the value is the state a new chat starts in. */ +export type UserToolToggle = 'on' | 'off'; + /** * Per-tool configuration options stored at the agent level. * Keyed by tool_id (e.g., "search_mcp_github"). @@ -109,6 +112,8 @@ export type ToolOptions = { * @default false */ describe_intent?: boolean; + /** Chat-user switch for this tool on a saved agent. Absent = locked. */ + user_toggle?: UserToolToggle; }; /** From e3bd2c99fe0643e21550524a766a397203f290b0 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 14:31:09 +0000 Subject: [PATCH 02/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20feat:=20Filter=20?= =?UTF-8?q?Saved=20Agent=20Tools=20by=20User=20Switches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/services/Endpoints/agents/build.js | 1 + .../api/src/agents/__tests__/load.spec.ts | 68 +++++++++++++++++++ packages/api/src/agents/load.ts | 6 +- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/api/server/services/Endpoints/agents/build.js b/api/server/services/Endpoints/agents/build.js index 7c5ce99434c..98057108c0b 100644 --- a/api/server/services/Endpoints/agents/build.js +++ b/api/server/services/Endpoints/agents/build.js @@ -20,6 +20,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => { agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID, endpoint, model_parameters, + userToolSwitches: req.body?.ephemeralAgent, }).catch((error) => { logger.error(`[/agents/:${agent_id}] Error retrieving agent during build options step`, error); return undefined; diff --git a/packages/api/src/agents/__tests__/load.spec.ts b/packages/api/src/agents/__tests__/load.spec.ts index 18d199cedad..92a0f930f61 100644 --- a/packages/api/src/agents/__tests__/load.spec.ts +++ b/packages/api/src/agents/__tests__/load.spec.ts @@ -1101,6 +1101,74 @@ describe('loadAgent', () => { } }); + describe('user tool switches', () => { + const createSwitchableAgent = async () => { + const agentId = `agent_${uuidv4()}`; + await createAgent({ + id: agentId, + name: 'Switchable Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['web_search', 'execute_code', 'search_mcp_docs', 'read_mcp_files'], + tool_options: { + web_search: { user_toggle: 'on' }, + execute_code: { user_toggle: 'off' }, + sys__server__sys_mcp_docs: { user_toggle: 'on' }, + }, + }); + return agentId; + }; + + const load = (agentId: string, userToolSwitches?: LoadAgentParams['userToolSwitches']) => + loadAgent( + { + req: { user: { id: 'user123' } }, + agent_id: agentId, + endpoint: 'agents', + userToolSwitches, + }, + deps, + ); + + test('keeps creator defaults when the chat sends no switches', async () => { + const agentId = await createSwitchableAgent(); + const result = await load(agentId); + expect(result!.tools).toEqual(['web_search', 'search_mcp_docs', 'read_mcp_files']); + }); + + test('drops a built-in and an MCP server the chat switched off', async () => { + const agentId = await createSwitchableAgent(); + const result = await load(agentId, { web_search: false, mcp: [] }); + expect(result!.tools).toEqual(['read_mcp_files']); + }); + + test('keeps a default-off built-in the chat switched on, without adding unattached tools', async () => { + const agentId = await createSwitchableAgent(); + const result = await load(agentId, { execute_code: true, file_search: true, mcp: ['docs'] }); + expect(result!.tools).toEqual([ + 'web_search', + 'execute_code', + 'search_mcp_docs', + 'read_mcp_files', + ]); + }); + + test('leaves tools untouched for an agent without user toggles', async () => { + const agentId = `agent_${uuidv4()}`; + await createAgent({ + id: agentId, + name: 'Plain Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['web_search', 'search_mcp_docs'], + }); + const result = await load(agentId, { web_search: false, mcp: [] }); + expect(result!.tools).toEqual(['web_search', 'search_mcp_docs']); + }); + }); + describe('Edge Cases', () => { test('should handle loadAgent with malformed req object', async () => { const result = await loadAgent( diff --git a/packages/api/src/agents/load.ts b/packages/api/src/agents/load.ts index 7ae16333b5a..fa1f062b386 100644 --- a/packages/api/src/agents/load.ts +++ b/packages/api/src/agents/load.ts @@ -6,6 +6,7 @@ import { isEphemeralAgentId, getEphemeralSender, encodeEphemeralAgentId, + applyAgentToolSwitches, } from 'librechat-data-provider'; import type { AgentModelParameters, @@ -62,6 +63,8 @@ export interface LoadAgentParams { agent_id: string; endpoint: string; model_parameters?: AgentModelParameters & { model?: string }; + /** The chat's tool switch state; only applied to the primary saved agent. */ + userToolSwitches?: TEphemeralAgent | null; } /** @@ -232,7 +235,7 @@ export async function loadAgent( params: LoadAgentParams, deps: LoadAgentDeps, ): Promise { - const { req, spec, agent_id, endpoint, model_parameters } = params; + const { req, spec, agent_id, endpoint, model_parameters, userToolSwitches } = params; if (!agent_id) { return null; } @@ -246,5 +249,6 @@ export async function loadAgent( } agent.version ??= agent.versions?.length ?? 0; + agent.tools = applyAgentToolSwitches(agent, userToolSwitches).tools; return agent; } From 106d98d12e014a87d16edbafca3b4eab1c1d741f Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 14:32:14 +0000 Subject: [PATCH 03/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20feat:=20Let=20Age?= =?UTF-8?q?nt=20Creators=20Make=20Built-in=20Tools=20Switchable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tools/ItemDialog/UserToggleSelect.tsx | 40 ++++++++++++ .../__tests__/UserToggleSelect.test.tsx | 65 +++++++++++++++++++ .../ItemDialog/sections/BuiltinSection.tsx | 14 +++- .../items/__tests__/configurable.spec.ts | 6 +- .../Agents/Tools/items/configurable.ts | 2 +- client/src/hooks/Agents/useUserToolToggle.ts | 35 ++++++++++ client/src/locales/en/translation.json | 5 ++ 7 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 client/src/components/SidePanel/Agents/Tools/ItemDialog/UserToggleSelect.tsx create mode 100644 client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/UserToggleSelect.test.tsx create mode 100644 client/src/hooks/Agents/useUserToolToggle.ts diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/UserToggleSelect.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/UserToggleSelect.tsx new file mode 100644 index 00000000000..a760d0df215 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/UserToggleSelect.tsx @@ -0,0 +1,40 @@ +import { Radio } from '@librechat/client'; +import type { UserToolToggle } from 'librechat-data-provider'; +import useUserToolToggle from '~/hooks/Agents/useUserToolToggle'; +import { useLocalize } from '~/hooks'; + +interface Props { + toolOptionKey: string; + labelId: string; +} + +const LOCKED = 'locked'; + +export default function UserToggleSelect({ toolOptionKey, labelId }: Props) { + const localize = useLocalize(); + const { value, setValue } = useUserToolToggle(toolOptionKey); + + const options = [ + { value: LOCKED, label: localize('com_ui_tool_toggle_locked') }, + { value: 'on', label: localize('com_ui_tool_toggle_on') }, + { value: 'off', label: localize('com_ui_tool_toggle_off') }, + ]; + + return ( +
+ + {localize('com_ui_tool_toggle_label')} + + setValue(next === LOCKED ? undefined : (next as UserToolToggle))} + fullWidth + aria-labelledby={labelId} + /> +

+ {localize('com_ui_tool_toggle_info')} +

+
+ ); +} diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/UserToggleSelect.test.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/UserToggleSelect.test.tsx new file mode 100644 index 00000000000..4308aeea449 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/UserToggleSelect.test.tsx @@ -0,0 +1,65 @@ +import '@testing-library/jest-dom/extend-expect'; +import { useForm, FormProvider, useWatch } from 'react-hook-form'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import type { AgentForm } from '~/common'; +import UserToggleSelect from '../UserToggleSelect'; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +function OptionsProbe() { + const value = useWatch({ name: 'tool_options' }); + return {JSON.stringify(value)}; +} + +function renderSelect(toolOptions: AgentForm['tool_options']) { + function Wrapper({ children }: { children: ReactNode }) { + const methods = useForm({ + defaultValues: { tool_options: toolOptions } as AgentForm, + }); + return ( + + {children} + + + ); + } + return render(, { + wrapper: Wrapper, + }); +} + +const stored = () => JSON.parse(screen.getByTestId('options').textContent ?? 'null'); +const option = (name: string) => screen.getByRole('radio', { name }); + +describe('UserToggleSelect', () => { + it('reflects a stored value', () => { + renderSelect({ web_search: { user_toggle: 'off' } }); + expect(option('com_ui_tool_toggle_off')).toHaveAttribute('aria-checked', 'true'); + }); + + it('is locked when nothing is stored', () => { + renderSelect(undefined); + expect(option('com_ui_tool_toggle_locked')).toHaveAttribute('aria-checked', 'true'); + }); + + it('writes user_toggle beside other options for the tool', () => { + renderSelect({ web_search: { defer_loading: true } }); + fireEvent.click(option('com_ui_tool_toggle_on')); + expect(stored()).toEqual({ web_search: { defer_loading: true, user_toggle: 'on' } }); + }); + + it('locking removes the key and drops the emptied entry', () => { + renderSelect({ web_search: { user_toggle: 'on' }, other: { defer_loading: true } }); + fireEvent.click(option('com_ui_tool_toggle_locked')); + expect(stored()).toEqual({ other: { defer_loading: true } }); + }); + + it('locking keeps sibling options on the entry', () => { + renderSelect({ web_search: { user_toggle: 'off', defer_loading: true } }); + fireEvent.click(option('com_ui_tool_toggle_locked')); + expect(stored()).toEqual({ web_search: { defer_loading: true } }); + }); +}); diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/BuiltinSection.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/BuiltinSection.tsx index 05a0c245541..edc2f6ed6c5 100644 --- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/BuiltinSection.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/BuiltinSection.tsx @@ -1,11 +1,18 @@ import { Radio, Checkbox } from '@librechat/client'; import { useFormContext, useWatch } from 'react-hook-form'; -import { Tools, MemoryScope, ArtifactModes, AgentCapabilities } from 'librechat-data-provider'; +import { + Tools, + MemoryScope, + ArtifactModes, + AgentCapabilities, + switchableBuiltinTools, +} from 'librechat-data-provider'; import type { TranslationKeys } from '~/hooks/useLocalize'; import type { AgentForm, ExtendedFile } from '~/common'; import type { BuiltinId } from '../../items/types'; import { useVerifyAgentToolAuth } from '~/data-provider'; import CodeBackground from '../../../Code/Background'; +import UserToggleSelect from '../UserToggleSelect'; import CodeSettings from '../../../Code/Settings'; import SearchAction from '../../../Search/Action'; import FileContext from '../../../FileContext'; @@ -140,6 +147,8 @@ export default function BuiltinSection({ const artifactsValue = (useWatch({ control, name: AgentCapabilities.artifacts }) ?? '') as string; const memoryScope = (useWatch({ control, name: 'memory_scope' }) ?? MemoryScope.user) as string; + const isSwitchable = (switchableBuiltinTools as readonly string[]).includes(builtinId); + let body: React.ReactNode = null; if (builtinId === 'execute_code') { @@ -180,6 +189,9 @@ export default function BuiltinSection({

{localizedDescription}

)} {body} + {isSwitchable && ( + + )} ); } diff --git a/client/src/components/SidePanel/Agents/Tools/items/__tests__/configurable.spec.ts b/client/src/components/SidePanel/Agents/Tools/items/__tests__/configurable.spec.ts index 52682526877..fa7550ab1af 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/__tests__/configurable.spec.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/__tests__/configurable.spec.ts @@ -14,9 +14,9 @@ describe('hasConfigurableSettings', () => { expect(hasConfigurableSettings(builtin('memory'))).toBe(true); }); - test('web_search is configurable only when auth is user-provided', () => { - expect(hasConfigurableSettings(builtin('web_search'))).toBe(false); - expect(hasConfigurableSettings(builtin('web_search', { userProvidedAuth: false }))).toBe(false); + test('web_search is always configurable because it carries the user toggle', () => { + expect(hasConfigurableSettings(builtin('web_search'))).toBe(true); + expect(hasConfigurableSettings(builtin('web_search', { userProvidedAuth: false }))).toBe(true); expect(hasConfigurableSettings(builtin('web_search', { userProvidedAuth: true }))).toBe(true); }); diff --git a/client/src/components/SidePanel/Agents/Tools/items/configurable.ts b/client/src/components/SidePanel/Agents/Tools/items/configurable.ts index cc63ac7f80d..9d535101f7c 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/configurable.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/configurable.ts @@ -16,7 +16,7 @@ export function hasConfigurableSettings(item: AgentItem): boolean { item.id === 'file_search' || item.id === 'context' || item.id === 'memory' || - (item.id === 'web_search' && item.userProvidedAuth === true) + item.id === 'web_search' ); case 'tool': return pluginNeedsAuth(item.plugin); diff --git a/client/src/hooks/Agents/useUserToolToggle.ts b/client/src/hooks/Agents/useUserToolToggle.ts new file mode 100644 index 00000000000..edddf4907b9 --- /dev/null +++ b/client/src/hooks/Agents/useUserToolToggle.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; +import type { AgentToolOptions, UserToolToggle } from 'librechat-data-provider'; +import type { AgentForm } from '~/common'; + +interface UseUserToolToggleReturn { + value: UserToolToggle | undefined; + setValue: (next: UserToolToggle | undefined) => void; +} + +/** Reads/writes one tool's `user_toggle`; `undefined` (locked) removes the key and any emptied entry. */ +export default function useUserToolToggle(toolOptionKey: string): UseUserToolToggleReturn { + const { getValues, setValue: setFormValue, control } = useFormContext(); + const toolOptions = useWatch({ control, name: 'tool_options' }); + + const setValue = useCallback( + (next: UserToolToggle | undefined) => { + const options: AgentToolOptions = { ...getValues('tool_options') }; + if (next != null) { + options[toolOptionKey] = { ...options[toolOptionKey], user_toggle: next }; + } else if (options[toolOptionKey] != null) { + const { user_toggle: _omit, ...rest } = options[toolOptionKey]; + if (Object.keys(rest).length === 0) { + delete options[toolOptionKey]; + } else { + options[toolOptionKey] = rest; + } + } + setFormValue('tool_options', options, { shouldDirty: true }); + }, + [getValues, setFormValue, toolOptionKey], + ); + + return { value: toolOptions?.[toolOptionKey]?.user_toggle, setValue }; +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index da661641f9b..92b51c671d9 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -2718,6 +2718,11 @@ "com_ui_tool_name_image_gen": "Image Generation", "com_ui_tool_name_set_memory": "Save Memory", "com_ui_tool_name_web_search": "Web Search", + "com_ui_tool_toggle_info": "Locked keeps the tool always on. On or Off lets users switch it in chat, starting in that state.", + "com_ui_tool_toggle_label": "User control", + "com_ui_tool_toggle_locked": "Locked", + "com_ui_tool_toggle_off": "Starts off", + "com_ui_tool_toggle_on": "Starts on", "com_ui_tool_response_placeholder": "Type a substitute result to return to the agent", "com_ui_tools": "Tools", "com_ui_tools_configure": "Configure", From 96e30c9b1938a6b88d4430b3641b1786819bae18 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 14:33:25 +0000 Subject: [PATCH 04/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20feat:=20Start=20C?= =?UTF-8?q?hat=20Tool=20Switches=20at=20the=20Agent's=20Defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/hooks/Agents/index.ts | 1 + .../hooks/Agents/useApplyAgentToolSwitches.ts | 47 ++++++++++++ .../applyAgentToolSwitchDefaults.test.ts | 74 +++++++++++++++++++ client/src/utils/endpoints.ts | 56 ++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 client/src/hooks/Agents/useApplyAgentToolSwitches.ts create mode 100644 client/src/utils/__tests__/applyAgentToolSwitchDefaults.test.ts diff --git a/client/src/hooks/Agents/index.ts b/client/src/hooks/Agents/index.ts index ede43629732..f5b5efb5999 100644 --- a/client/src/hooks/Agents/index.ts +++ b/client/src/hooks/Agents/index.ts @@ -20,3 +20,4 @@ export type { } from './useCodeWorkspace'; export { default as useMCPToolOptions } from './useMCPToolOptions'; export * from './useApplyModelSpecAgents'; +export * from './useApplyAgentToolSwitches'; diff --git a/client/src/hooks/Agents/useApplyAgentToolSwitches.ts b/client/src/hooks/Agents/useApplyAgentToolSwitches.ts new file mode 100644 index 00000000000..255759a9e89 --- /dev/null +++ b/client/src/hooks/Agents/useApplyAgentToolSwitches.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react'; +import { useSetRecoilState } from 'recoil'; +import { Constants, getAgentToolSwitches } from 'librechat-data-provider'; +import type { Agent } from 'librechat-data-provider'; +import { applyAgentToolSwitchDefaults } from '~/utils'; +import { ephemeralAgentByConvoId } from '~/store'; + +export function useApplyAgentToolSwitches({ + agent, + conversationId, +}: { + agent?: Pick | null; + conversationId?: string | null; +}) { + const convoId = conversationId ?? Constants.NEW_CONVO; + const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(convoId)); + const agentRef = useRef(agent); + agentRef.current = agent; + const agentId = agent?.id; + + useEffect(() => { + const current = agentRef.current; + if (!current) { + return; + } + const switches = getAgentToolSwitches(current); + const switchableServers = Object.keys(switches.mcp); + if (Object.keys(switches.builtins).length === 0 && switchableServers.length === 0) { + return; + } + const seeded = applyAgentToolSwitchDefaults({ + agent: current, + convoId, + isNewConvo: convoId === Constants.NEW_CONVO, + }); + setEphemeralAgent((previous) => ({ + ...previous, + ...seeded, + ...(seeded.mcp && { + mcp: [ + ...(previous?.mcp ?? []).filter((name) => !switchableServers.includes(name)), + ...seeded.mcp, + ], + }), + })); + }, [agentId, convoId, setEphemeralAgent]); +} diff --git a/client/src/utils/__tests__/applyAgentToolSwitchDefaults.test.ts b/client/src/utils/__tests__/applyAgentToolSwitchDefaults.test.ts new file mode 100644 index 00000000000..624e1c082ad --- /dev/null +++ b/client/src/utils/__tests__/applyAgentToolSwitchDefaults.test.ts @@ -0,0 +1,74 @@ +import { Constants, LocalStorageKeys, Tools, mcpServerToggleKey } from 'librechat-data-provider'; +import type { Agent } from 'librechat-data-provider'; +import { applyAgentToolSwitchDefaults } from '../endpoints'; +import { setTimestamp } from '../timestamps'; + +const createAgent = (overrides: Partial = {}): Agent => + ({ + id: 'agent_1', + tools: [Tools.web_search, Tools.execute_code, 'search_mcp_alpha', 'lookup_mcp_beta'], + tool_options: { + [Tools.web_search]: { user_toggle: 'on' }, + [Tools.execute_code]: { user_toggle: 'off' }, + [mcpServerToggleKey('alpha')]: { user_toggle: 'on' }, + [mcpServerToggleKey('beta')]: { user_toggle: 'off' }, + }, + ...overrides, + }) as Agent; + +function writeToggle(storagePrefix: string, convoId: string, value: unknown): void { + const key = `${storagePrefix}${convoId}`; + localStorage.setItem(key, JSON.stringify(value)); + setTimestamp(key); +} + +describe('applyAgentToolSwitchDefaults', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('seeds a new chat from the creator defaults', () => { + const result = applyAgentToolSwitchDefaults({ + agent: createAgent(), + convoId: `${Constants.NEW_CONVO}`, + isNewConvo: true, + }); + expect(result).toEqual({ web_search: true, execute_code: false, mcp: ['alpha'] }); + }); + + it('ignores stored overrides for a new chat', () => { + writeToggle(LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_, 'convo-1', false); + const result = applyAgentToolSwitchDefaults({ + agent: createAgent(), + convoId: 'convo-1', + isNewConvo: true, + }); + expect(result.web_search).toBe(true); + }); + + it('layers stored overrides on top of defaults for an existing chat', () => { + writeToggle(LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_, 'convo-1', false); + writeToggle(LocalStorageKeys.LAST_CODE_TOGGLE_, 'convo-1', true); + localStorage.setItem(`${LocalStorageKeys.LAST_MCP_}convo-1`, JSON.stringify(['beta'])); + const result = applyAgentToolSwitchDefaults({ + agent: createAgent(), + convoId: 'convo-1', + isNewConvo: false, + }); + expect(result).toEqual({ web_search: false, execute_code: true, mcp: ['beta'] }); + }); + + it('never stores keys outside the switchable set', () => { + writeToggle(LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_, 'convo-1', true); + localStorage.setItem( + `${LocalStorageKeys.LAST_MCP_}convo-1`, + JSON.stringify(['alpha', 'gamma']), + ); + const result = applyAgentToolSwitchDefaults({ + agent: createAgent({ tools: [Tools.web_search], tool_options: undefined }), + convoId: 'convo-1', + isNewConvo: false, + }); + expect(result).toEqual({}); + }); +}); diff --git a/client/src/utils/endpoints.ts b/client/src/utils/endpoints.ts index b39fbdf23f5..d7b245a84d4 100644 --- a/client/src/utils/endpoints.ts +++ b/client/src/utils/endpoints.ts @@ -1,10 +1,12 @@ import { + Tools, Constants, EModelEndpoint, defaultEndpoints, modularEndpoints, LocalStorageKeys, getEndpointField, + getAgentToolSwitches, isAgentsEndpoint, isEphemeralAgentId, isAssistantsEndpoint, @@ -394,6 +396,60 @@ export function applyModelSpecEphemeralAgent({ updateEphemeralAgent(key, agent); } +export function applyAgentToolSwitchDefaults({ + agent, + convoId, + isNewConvo, +}: { + agent: Pick; + convoId: string; + isNewConvo: boolean; +}): t.TEphemeralAgent { + const switches = getAgentToolSwitches(agent); + const builtins = { ...switches.builtins }; + const serverNames = Object.keys(switches.mcp); + let enabledServers = serverNames.filter((name) => switches.mcp[name]); + + if (!isNewConvo) { + const storageMap: Array<[keyof typeof switches.builtins, string]> = [ + [Tools.execute_code, LocalStorageKeys.LAST_CODE_TOGGLE_], + [Tools.web_search, LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_], + [Tools.file_search, LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_], + ]; + for (const [toolKey, storagePrefix] of storageMap) { + if (!(toolKey in builtins)) { + continue; + } + const raw = getTimestampedValue(`${storagePrefix}${convoId}`); + if (raw === null) { + continue; + } + try { + const stored = JSON.parse(raw); + if (typeof stored === 'boolean') { + builtins[toolKey] = stored; + } + } catch { + // ignore parse errors + } + } + + const mcpRaw = localStorage.getItem(`${LocalStorageKeys.LAST_MCP_}${convoId}`); + if (mcpRaw !== null && serverNames.length > 0) { + try { + const stored = JSON.parse(mcpRaw); + if (Array.isArray(stored)) { + enabledServers = serverNames.filter((name) => stored.includes(name)); + } + } catch { + // ignore parse errors + } + } + } + + return serverNames.length > 0 ? { ...builtins, mcp: enabledServers } : { ...builtins }; +} + /** * Resolves the default model spec for a new chat. Priority: hard admin default → * the most recent conversation's own selection → soft default → legacy first spec. From 0cd46b2391f61bdeac07344e1cebc29fd7ccf36a Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 14:37:54 +0000 Subject: [PATCH 05/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20fix:=20Limit=20To?= =?UTF-8?q?ol=20Switch=20Filtering=20to=20the=20Primary=20Agent=20and=20Dr?= =?UTF-8?q?op=20Stale=20MCP=20Lists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/services/Endpoints/agents/build.js | 2 +- .../api/src/agents/__tests__/load.spec.ts | 60 ++++++++++++++++++- packages/api/src/agents/load.ts | 9 +++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/api/server/services/Endpoints/agents/build.js b/api/server/services/Endpoints/agents/build.js index 98057108c0b..ce8f1f8c533 100644 --- a/api/server/services/Endpoints/agents/build.js +++ b/api/server/services/Endpoints/agents/build.js @@ -20,7 +20,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => { agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID, endpoint, model_parameters, - userToolSwitches: req.body?.ephemeralAgent, + userToolSwitches: req.body?.ephemeralAgent ?? null, }).catch((error) => { logger.error(`[/agents/:${agent_id}] Error retrieving agent during build options step`, error); return undefined; diff --git a/packages/api/src/agents/__tests__/load.spec.ts b/packages/api/src/agents/__tests__/load.spec.ts index 92a0f930f61..2824ec482c8 100644 --- a/packages/api/src/agents/__tests__/load.spec.ts +++ b/packages/api/src/agents/__tests__/load.spec.ts @@ -11,6 +11,7 @@ import type { } from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; import type { LoadAgentParams, LoadAgentDeps } from '../load'; +import { extractMCPServers } from '../context'; import { loadAddedAgent } from '../added'; import { loadAgent } from '../load'; @@ -1133,7 +1134,7 @@ describe('loadAgent', () => { test('keeps creator defaults when the chat sends no switches', async () => { const agentId = await createSwitchableAgent(); - const result = await load(agentId); + const result = await load(agentId, {}); expect(result!.tools).toEqual(['web_search', 'search_mcp_docs', 'read_mcp_files']); }); @@ -1154,6 +1155,63 @@ describe('loadAgent', () => { ]); }); + test('does not filter when no switch state is supplied at all', async () => { + const agentId = await createSwitchableAgent(); + const result = await load(agentId); + expect(result!.tools).toEqual([ + 'web_search', + 'execute_code', + 'search_mcp_docs', + 'read_mcp_files', + ]); + }); + + test('applies creator defaults when the switch state is null', async () => { + const agentId = await createSwitchableAgent(); + const result = await load(agentId, null); + expect(result!.tools).toEqual(['web_search', 'search_mcp_docs', 'read_mcp_files']); + }); + + test.each([ + [['C'], ['B']], + [['A'], ['A', 'B']], + ])( + 'drops the request server list so context follows the filtered tools (%j)', + async (requestedServers, expectedServers) => { + const agentId = `agent_${uuidv4()}`; + await createAgent({ + id: agentId, + name: 'Mixed Servers', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['search_mcp_A', 'read_mcp_B'], + tool_options: { sys__server__sys_mcp_A: { user_toggle: 'on' } }, + }); + const ephemeralAgent = { mcp: requestedServers }; + const result = await load(agentId, ephemeralAgent); + expect(ephemeralAgent).not.toHaveProperty('mcp'); + const toolDefinitions = result!.tools!.map((name) => ({ name })); + expect(extractMCPServers({ ...result, toolDefinitions } as never)).toEqual(expectedServers); + }, + ); + + test('leaves the request server list alone when no server is switchable', async () => { + const agentId = `agent_${uuidv4()}`; + await createAgent({ + id: agentId, + name: 'Locked Servers', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['web_search', 'read_mcp_B'], + tool_options: { web_search: { user_toggle: 'on' } }, + }); + const ephemeralAgent = { mcp: ['B'] }; + await load(agentId, ephemeralAgent); + expect(ephemeralAgent.mcp).toEqual(['B']); + }); + test('leaves tools untouched for an agent without user toggles', async () => { const agentId = `agent_${uuidv4()}`; await createAgent({ diff --git a/packages/api/src/agents/load.ts b/packages/api/src/agents/load.ts index fa1f062b386..b948f076b27 100644 --- a/packages/api/src/agents/load.ts +++ b/packages/api/src/agents/load.ts @@ -7,6 +7,7 @@ import { getEphemeralSender, encodeEphemeralAgentId, applyAgentToolSwitches, + getAgentToolSwitches, } from 'librechat-data-provider'; import type { AgentModelParameters, @@ -249,6 +250,14 @@ export async function loadAgent( } agent.version ??= agent.versions?.length ?? 0; + if (userToolSwitches === undefined) { + return agent; + } + const hasSwitchableServer = Object.keys(getAgentToolSwitches(agent).mcp).length > 0; agent.tools = applyAgentToolSwitches(agent, userToolSwitches).tools; + /** Context derivation prefers the request's server list over the agent's tools; drop it so the filtered tools decide. */ + if (userToolSwitches != null && hasSwitchableServer) { + delete userToolSwitches.mcp; + } return agent; } From 4c8188025b49692e4184792de39b58934b1d1789 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 14:43:08 +0000 Subject: [PATCH 06/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20feat:=20Let=20Age?= =?UTF-8?q?nt=20Creators=20Make=20MCP=20Servers=20Switchable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ItemDialog/__tests__/McpSection.spec.tsx | 1 + .../__tests__/McpSection.userToggle.test.tsx | 104 ++++++++++++++++++ .../Tools/ItemDialog/sections/McpSection.tsx | 7 ++ 3 files changed, 112 insertions(+) create mode 100644 client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.userToggle.test.tsx diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx index cff2b26c966..abbf92b8890 100644 --- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx @@ -118,6 +118,7 @@ jest.mock('../../../MCPToolItem', () => ({ ), })); +jest.mock('../UserToggleSelect', () => ({ __esModule: true, default: () => null })); jest.mock('~/components/MCP/MCPConfigDialog', () => ({ __esModule: true, default: () => null, diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.userToggle.test.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.userToggle.test.tsx new file mode 100644 index 00000000000..6ad20755005 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.userToggle.test.tsx @@ -0,0 +1,104 @@ +import '@testing-library/jest-dom/extend-expect'; +import { mcpServerToggleKey } from 'librechat-data-provider'; +import { useForm, FormProvider, useWatch } from 'react-hook-form'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import type { McpItem } from '../../items/types'; +import type { AgentForm } from '~/common'; +import McpSection from '../sections/McpSection'; + +jest.mock('~/Providers', () => ({ + useAgentPanelContext: () => ({ mcpServersMap: new Map(), mcpToolsLoading: false }), +})); + +jest.mock('~/components/ui', () => ({ + Collapse: ({ open, children }: { open: boolean; children: ReactNode }) => + open ? children : null, +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useCopyToClipboard: () => jest.fn(), + useAgentCapabilities: () => ({}), + useGetAgentsConfig: () => ({ agentsConfig: { capabilities: [] } }), + useMCPServerManager: () => ({ + getServerStatusIconProps: () => null, + getConfigDialogProps: () => null, + initializeServer: jest.fn(), + isConnectionDeferred: () => false, + resetConnectionDeferred: jest.fn(), + getOAuthUrl: () => undefined, + }), + useMCPToolOptions: () => ({ + isToolDeferred: () => false, + isToolProgrammatic: () => false, + isToolBackground: () => false, + isToolIntent: () => false, + isToolProgrammaticOnly: () => false, + areAllToolsDeferred: () => false, + areAllToolsProgrammatic: () => false, + areAllToolsBackground: () => false, + areAllToolsIntent: () => false, + }), +})); + +jest.mock('~/components/MCP/MCPConfigDialog', () => ({ __esModule: true, default: () => null })); +jest.mock('~/components/MCP/MCPServerStatusIcon', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('~/components/MCP/McpOAuthDialog', () => ({ __esModule: true, default: () => null })); +jest.mock('../../../MCPToolItem', () => ({ __esModule: true, default: () => null })); + +const item: McpItem = { + kind: 'mcp', + id: 'alpha', + name: 'alpha', + description: '', + iconKey: 'mcp', + server: { serverName: 'alpha', isConfigured: true, tools: [], metadata: {} } as never, + toolCount: 0, +}; + +const key = mcpServerToggleKey('alpha'); + +function OptionsProbe() { + const value = useWatch({ name: 'tool_options' }); + return {JSON.stringify(value)}; +} + +function renderSection(toolOptions: AgentForm['tool_options']) { + function Wrapper({ children }: { children: ReactNode }) { + const methods = useForm({ + defaultValues: { tools: [], tool_options: toolOptions } as unknown as AgentForm, + }); + return ( + + {children} + + + ); + } + return render(, { wrapper: Wrapper }); +} + +const stored = () => JSON.parse(screen.getByTestId('options').textContent ?? 'null'); +const option = (name: string) => screen.getByRole('radio', { name }); + +describe('McpSection user toggle', () => { + it('reflects the server-wide toggle stored under the server key', () => { + renderSection({ [key]: { user_toggle: 'on' } }); + expect(option('com_ui_tool_toggle_on')).toHaveAttribute('aria-checked', 'true'); + }); + + it('is locked when nothing is stored', () => { + renderSection(undefined); + expect(option('com_ui_tool_toggle_locked')).toHaveAttribute('aria-checked', 'true'); + }); + + it('writes user_toggle under the server key', () => { + renderSection(undefined); + fireEvent.click(option('com_ui_tool_toggle_off')); + expect(stored()).toEqual({ [key]: { user_toggle: 'off' } }); + }); +}); diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx index e52b62a2864..c36c6c8a493 100644 --- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx @@ -6,6 +6,7 @@ import { AgentCapabilities, Constants, splitMCPToolKey, + mcpServerToggleKey, normalizeServerName, buildServerNameAliases, stripServerNamePrefix, @@ -28,6 +29,7 @@ import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; import McpOAuthDialog from '~/components/MCP/McpOAuthDialog'; import { useAgentPanelContext } from '~/Providers'; import { getIconForItem } from '../../items/icons'; +import UserToggleSelect from '../UserToggleSelect'; import OptionToggle from '../../../OptionToggle'; import MCPToolItem from '../../../MCPToolItem'; import { Collapse } from '~/components/ui'; @@ -610,6 +612,11 @@ export default function McpSection({ item }: Props) { + + {configDialogProps && } Date: Tue, 29 Sep 2026 14:45:49 +0000 Subject: [PATCH 07/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20feat:=20Show=20Sa?= =?UTF-8?q?ved=20Agent=20Tool=20Switches=20in=20the=20Chat=20Input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/Providers/BadgeRowContext.tsx | 23 ++- client/src/components/Chat/Input/BadgeRow.tsx | 24 ++- client/src/components/Chat/Input/ChatForm.tsx | 3 + .../components/Chat/Input/ToolsDropdown.tsx | 17 +- .../__tests__/BadgeRow.agentSwitches.test.tsx | 176 ++++++++++++++++++ 5 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 client/src/components/Chat/Input/__tests__/BadgeRow.agentSwitches.test.tsx diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx index 59388825a28..ede2466803b 100644 --- a/client/src/Providers/BadgeRowContext.tsx +++ b/client/src/Providers/BadgeRowContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react'; import { useSetRecoilState } from 'recoil'; import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider'; -import type { TAgentsEndpoint, TEphemeralAgent } from 'librechat-data-provider'; +import type { AgentToolSwitches, TAgentsEndpoint, TEphemeralAgent } from 'librechat-data-provider'; import { useMCPServerManager, useSearchApiKeyForm, @@ -16,6 +16,8 @@ interface BadgeRowContextType { conversationId?: string | null; storageContextKey?: string; agentsConfig?: TAgentsEndpoint | null; + /** Present only in a saved agent's chat: the tools its creator made switchable. */ + agentToolSwitches?: AgentToolSwitches; skills: ReturnType; memory: ReturnType; webSearch: ReturnType; @@ -38,6 +40,7 @@ interface BadgeRowProviderProps { conversationId?: string | null; specName?: string | null; observeToolAuthorization?: boolean; + agentToolSwitches?: AgentToolSwitches; } export default function BadgeRowProvider({ @@ -46,6 +49,7 @@ export default function BadgeRowProvider({ conversationId, specName, observeToolAuthorization = false, + agentToolSwitches, }: BadgeRowProviderProps) { const lastContextKeyRef = useRef(''); const hasInitializedRef = useRef(false); @@ -280,6 +284,20 @@ export default function BadgeRowProvider({ observeToolAuthorization, }); + /** In a saved agent's chat the MCP menu offers only the servers its creator made switchable. */ + const chatMcpServerManager = useMemo(() => { + if (!agentToolSwitches) { + return mcpServerManager; + } + const isSwitchable = ({ serverName }: { serverName: string }) => + serverName in agentToolSwitches.mcp; + return { + ...mcpServerManager, + availableMCPServers: mcpServerManager.availableMCPServers.filter(isSwitchable), + selectableServers: mcpServerManager.selectableServers.filter(isSwitchable), + }; + }, [mcpServerManager, agentToolSwitches]); + const value: BadgeRowContextType = { skills, memory, @@ -287,11 +305,12 @@ export default function BadgeRowProvider({ artifacts, fileSearch, agentsConfig, + agentToolSwitches, conversationId, storageContextKey, codeInterpreter, searchApiKeyForm, - mcpServerManager, + mcpServerManager: chatMcpServerManager, }; return {children}; diff --git a/client/src/components/Chat/Input/BadgeRow.tsx b/client/src/components/Chat/Input/BadgeRow.tsx index 2a0e0100123..1b715dc684d 100644 --- a/client/src/components/Chat/Input/BadgeRow.tsx +++ b/client/src/components/Chat/Input/BadgeRow.tsx @@ -10,12 +10,14 @@ import React, { } from 'react'; import { Badge } from '@librechat/client'; import { useRecoilValue, useRecoilCallback } from 'recoil'; +import { Tools, getAgentToolSwitches } from 'librechat-data-provider'; import type { LucideIcon } from 'lucide-react'; import type { BadgeItem } from '~/common'; +import { useChatBadges, useApplyAgentToolSwitches } from '~/hooks'; +import { useGetAgentByIdQuery } from '~/data-provider'; import CodeInterpreter from './CodeInterpreter'; import { BadgeRowProvider } from '~/Providers'; import ToolsDropdown from './ToolsDropdown'; -import { useChatBadges } from '~/hooks'; import ToolDialogs from './ToolDialogs'; import FileSearch from './FileSearch'; import Artifacts from './Artifacts'; @@ -30,6 +32,7 @@ interface BadgeRowProps { onChange: (badges: Pick[]) => void; onToggle?: (badgeId: string, currentActive: boolean) => void; conversationId?: string | null; + agentId?: string | null; specName?: string | null; isSubmitting?: boolean; isInChat: boolean; @@ -145,6 +148,7 @@ const dragReducer = (state: DragState, action: DragAction): DragState => { function BadgeRow({ showEphemeralBadges, conversationId, + agentId, specName, isSubmitting, onChange, @@ -166,6 +170,13 @@ function BadgeRow({ const containerRectRef = useRef(null); const allBadges = useChatBadges(); + const { data: agent } = useGetAgentByIdQuery(agentId); + useApplyAgentToolSwitches({ agent, conversationId }); + const agentToolSwitches = useMemo( + () => (agentId && agent ? getAgentToolSwitches(agent) : undefined), + [agentId, agent], + ); + const hasSwitchableServer = Object.keys(agentToolSwitches?.mcp ?? {}).length > 0; const isEditing = useRecoilValue(store.isEditingBadges); const badges = useMemo( @@ -329,9 +340,10 @@ function BadgeRow({ specName={specName} isSubmitting={isSubmitting} observeToolAuthorization={showEphemeralBadges === true} + agentToolSwitches={agentToolSwitches} >
- {showEphemeralBadges === true && } + {(showEphemeralBadges === true || agentToolSwitches != null) && } {tempBadges.map((badge, index) => ( {dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && ( @@ -382,6 +394,14 @@ function BadgeRow({ )} + {showEphemeralBadges !== true && agentToolSwitches != null && ( + <> + {agentToolSwitches.builtins[Tools.web_search] != null && } + {agentToolSwitches.builtins[Tools.execute_code] != null && } + {agentToolSwitches.builtins[Tools.file_search] != null && } + {hasSwitchableServer && } + + )} {ghostBadge && (
{ const canUseMemory = useHasMemoryAccess(); const showMemory = canUseMemory && memoryEnabled && user?.personalization?.memories !== false; + const agentToolSwitches = context?.agentToolSwitches; + const isSwitchable = (tool: Tools) => + agentToolSwitches == null || agentToolSwitches.builtins[tool] != null; + const [isPopoverActive, setIsPopoverActive] = useState(false); const isDisabled = disabled ?? false; const { @@ -162,7 +167,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { const dropdownItems: MenuItemProps[] = []; - if (fileSearchEnabled && canUseFileSearch) { + if (fileSearchEnabled && canUseFileSearch && isSwitchable(Tools.file_search)) { dropdownItems.push({ onClick: handleFileSearchToggle, hideOnClick: false, @@ -194,7 +199,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { }); } - if (canUseWebSearch && webSearchEnabled) { + if (canUseWebSearch && webSearchEnabled && isSwitchable(Tools.web_search)) { dropdownItems.push({ onClick: handleWebSearchToggle, hideOnClick: false, @@ -248,7 +253,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { }); } - if (canUseSkills && skillsEnabled) { + if (canUseSkills && skillsEnabled && agentToolSwitches == null) { dropdownItems.push({ onClick: handleSkillsToggle, hideOnClick: false, @@ -280,7 +285,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { }); } - if (showMemory) { + if (showMemory && agentToolSwitches == null) { dropdownItems.push({ onClick: handleMemoryToggle, hideOnClick: false, @@ -312,7 +317,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { }); } - if (canRunCode && codeEnabled) { + if (canRunCode && codeEnabled && isSwitchable(Tools.execute_code)) { dropdownItems.push({ onClick: handleCodeInterpreterToggle, hideOnClick: false, @@ -346,7 +351,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { }); } - if (artifactsEnabled && setIsArtifactsPinned != null) { + if (artifactsEnabled && setIsArtifactsPinned != null && agentToolSwitches == null) { dropdownItems.push({ hideOnClick: false, render: (props) => ( diff --git a/client/src/components/Chat/Input/__tests__/BadgeRow.agentSwitches.test.tsx b/client/src/components/Chat/Input/__tests__/BadgeRow.agentSwitches.test.tsx new file mode 100644 index 00000000000..1945a6e286b --- /dev/null +++ b/client/src/components/Chat/Input/__tests__/BadgeRow.agentSwitches.test.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import { Tools, mcpServerToggleKey } from 'librechat-data-provider'; +import type { Agent } from 'librechat-data-provider'; +import BadgeRow from '../BadgeRow'; + +let mockAgent: Partial | undefined; +let mockContext: Record = {}; +const mockApplySwitches = jest.fn(); +const mockProvider = jest.fn(); + +jest.mock('~/data-provider', () => ({ + useGetStartupConfig: () => ({ data: undefined }), + useGetAgentByIdQuery: () => ({ data: mockAgent }), +})); + +jest.mock('~/hooks', () => ({ + useChatBadges: () => [], + useApplyAgentToolSwitches: (args: unknown) => mockApplySwitches(args), + useLocalize: () => (key: string) => key, + useHasAccess: () => true, + useAuthContext: () => ({ user: undefined }), + useHasMemoryAccess: () => false, + useAgentCapabilities: () => ({ + codeEnabled: true, + memoryEnabled: true, + webSearchEnabled: true, + artifactsEnabled: false, + fileSearchEnabled: true, + skillsEnabled: true, + }), +})); + +jest.mock('~/Providers', () => ({ + useBadgeRowContext: () => mockContext, + BadgeRowProvider: (props: { children: React.ReactNode }) => { + mockProvider(props); + return <>{props.children}; + }, +})); + +jest.mock('~/components/Chat/Input/MCPSubMenu', () => ({ + __esModule: true, + default: () =>
, +})); +jest.mock('~/components/Chat/Input/ArtifactsSubMenu', () => ({ + __esModule: true, + default: () => null, +})); + +const stub = (testId: string) => ({ + __esModule: true, + default: () =>
, +}); +jest.mock('../CodeInterpreter', () => stub('code-badge')); +jest.mock('../FileSearch', () => stub('file-badge')); +jest.mock('../WebSearch', () => stub('web-badge')); +jest.mock('../MCPSelect', () => stub('mcp-badge')); +jest.mock('../Artifacts', () => stub('artifacts-badge')); +jest.mock('../Memory', () => stub('memory-badge')); +jest.mock('../Skills', () => stub('skills-badge')); +jest.mock('../ToolDialogs', () => stub('tool-dialogs')); + +const savedAgent: Partial = { + id: 'agent_1', + tools: [Tools.web_search, Tools.execute_code, 'search_mcp_docs'], + tool_options: { + [Tools.web_search]: { user_toggle: 'on' }, + [mcpServerToggleKey('docs')]: { user_toggle: 'off' }, + }, +}; + +const renderRow = (props: Partial>) => + render( + + + , + ); + +describe('BadgeRow agent switches', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAgent = savedAgent; + mockContext = {}; + }); + + it('shows only the switchable tools of a saved agent and seeds their defaults', () => { + renderRow({ agentId: 'agent_1', showEphemeralBadges: false }); + + expect(screen.getByTestId('web-badge')).toBeInTheDocument(); + expect(screen.getByTestId('mcp-badge')).toBeInTheDocument(); + expect(screen.queryByTestId('code-badge')).not.toBeInTheDocument(); + expect(screen.queryByTestId('file-badge')).not.toBeInTheDocument(); + expect(screen.queryByTestId('skills-badge')).not.toBeInTheDocument(); + expect(screen.queryByTestId('memory-badge')).not.toBeInTheDocument(); + expect(mockApplySwitches).toHaveBeenCalledWith({ + agent: savedAgent, + conversationId: 'convo_1', + }); + expect(mockProvider).toHaveBeenCalledWith( + expect.objectContaining({ + agentToolSwitches: { builtins: { [Tools.web_search]: true }, mcp: { docs: false } }, + }), + ); + }); + + it('renders no switch badges for a saved agent without switches', () => { + mockAgent = { id: 'agent_1', tools: [Tools.web_search] }; + renderRow({ agentId: 'agent_1', showEphemeralBadges: false }); + + expect(screen.queryByTestId('web-badge')).not.toBeInTheDocument(); + expect(screen.queryByTestId('mcp-badge')).not.toBeInTheDocument(); + }); + + it('keeps the full badge set for ephemeral chats', () => { + renderRow({ showEphemeralBadges: true }); + + for (const testId of ['web-badge', 'code-badge', 'file-badge', 'skills-badge', 'mcp-badge']) { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + } + expect(mockProvider).toHaveBeenCalledWith( + expect.objectContaining({ agentToolSwitches: undefined }), + ); + }); +}); + +describe('ToolsDropdown agent switches', () => { + const renderDropdown = async () => { + const ToolsDropdown = jest.requireActual('../ToolsDropdown').default; + render( +
+ +
, + ); + await userEvent.click(screen.getByRole('button', { name: 'Tools Options' })); + }; + + it('lists only the switchable tools for a saved agent', async () => { + mockContext = { + agentToolSwitches: { builtins: { [Tools.web_search]: true }, mcp: {} }, + webSearch: {}, + codeInterpreter: {}, + fileSearch: {}, + skills: {}, + memory: {}, + mcpServerManager: { availableMCPServers: [] }, + }; + await renderDropdown(); + + expect(await screen.findByText('com_ui_web_search')).toBeInTheDocument(); + expect(screen.queryByText('com_ui_run_code')).not.toBeInTheDocument(); + expect(screen.queryByText('com_assistants_file_search')).not.toBeInTheDocument(); + expect(screen.queryByText('com_ui_skills')).not.toBeInTheDocument(); + expect(screen.queryByTestId('mcp-submenu')).not.toBeInTheDocument(); + }); + + it('lists every enabled tool without agent switches', async () => { + mockContext = { + webSearch: {}, + codeInterpreter: {}, + fileSearch: {}, + skills: {}, + memory: {}, + mcpServerManager: { availableMCPServers: [{ serverName: 'docs' }] }, + }; + await renderDropdown(); + + expect(await screen.findByText('com_ui_web_search')).toBeInTheDocument(); + expect(screen.getByText('com_ui_run_code')).toBeInTheDocument(); + expect(screen.getByText('com_assistants_file_search')).toBeInTheDocument(); + expect(screen.getByText('com_ui_skills')).toBeInTheDocument(); + expect(screen.getByTestId('mcp-submenu')).toBeInTheDocument(); + }); +}); From 8131819febe07234141cb0288031118f96cc79bd Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 15:15:48 +0000 Subject: [PATCH 08/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20fix:=20Select=20D?= =?UTF-8?q?efault-On=20MCP=20Servers=20in=20Saved=20Agent=20Chats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useApplyAgentToolSwitches.test.tsx | 80 +++++++++++++++++++ .../hooks/Agents/useApplyAgentToolSwitches.ts | 5 +- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx diff --git a/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx b/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx new file mode 100644 index 00000000000..3410e4dca72 --- /dev/null +++ b/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { Provider, createStore } from 'jotai'; +import { render, waitFor } from '@testing-library/react'; +import { mcpServerToggleKey } from 'librechat-data-provider'; +import type { Agent } from 'librechat-data-provider'; +import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager'; +import { useApplyAgentToolSwitches } from '../useApplyAgentToolSwitches'; +import { useMCPSelect } from '~/hooks/MCP/useMCPSelect'; + +jest.mock('~/data-provider', () => ({ + ...jest.requireActual('~/data-provider'), + useGetStartupConfig: jest.fn(() => ({ data: undefined })), +})); + +const serverName = 'mock-elicitation'; +const servers = [ + { serverName, config: { type: 'sse', url: 'http://localhost' }, effectivePermissions: 1 }, +] as unknown as MCPServerDefinition[]; + +type SavedAgent = Pick; + +const withToggle = (toggle?: 'on' | 'off'): SavedAgent => ({ + id: 'agent_1', + tools: [`echo_mcp_${serverName}`], + tool_options: toggle ? { [mcpServerToggleKey(serverName)]: { user_toggle: toggle } } : {}, +}); + +let selected: string[] = []; + +function Picker() { + const { mcpValues } = useMCPSelect({ conversationId: null, servers, ownsChatSelection: true }); + selected = mcpValues; + return null; +} + +function Chat({ agent }: { agent: SavedAgent }) { + useApplyAgentToolSwitches({ agent, conversationId: null }); + return ; +} + +describe('useApplyAgentToolSwitches', () => { + beforeEach(() => { + localStorage.clear(); + selected = []; + }); + + it('selects a default-on MCP server in a new chat', async () => { + render( + + + + + , + ); + await waitFor(() => expect(selected).toEqual([serverName])); + }); + + it.each([ + ['not switchable', undefined], + ['switchable, starts off', 'off' as const], + ])( + 'selects a default-on MCP server once the saved agent changes from %s', + async (_label, previousToggle) => { + const store = createStore(); + const tree = (agent: SavedAgent) => ( + + + + + + ); + const { rerender } = render(tree(withToggle(previousToggle))); + await waitFor(() => expect(selected).toEqual([])); + + rerender(tree(withToggle('on'))); + await waitFor(() => expect(selected).toEqual([serverName])); + }, + ); +}); diff --git a/client/src/hooks/Agents/useApplyAgentToolSwitches.ts b/client/src/hooks/Agents/useApplyAgentToolSwitches.ts index 255759a9e89..e0cc568948c 100644 --- a/client/src/hooks/Agents/useApplyAgentToolSwitches.ts +++ b/client/src/hooks/Agents/useApplyAgentToolSwitches.ts @@ -17,6 +17,9 @@ export function useApplyAgentToolSwitches({ const agentRef = useRef(agent); agentRef.current = agent; const agentId = agent?.id; + /** The saved agent's cache entry is replaced in place when its creator edits it, + * so reseed on a change in the switch config itself, not just the agent id. */ + const switchesKey = agent ? JSON.stringify(getAgentToolSwitches(agent)) : ''; useEffect(() => { const current = agentRef.current; @@ -43,5 +46,5 @@ export function useApplyAgentToolSwitches({ ], }), })); - }, [agentId, convoId, setEphemeralAgent]); + }, [agentId, switchesKey, convoId, setEphemeralAgent]); } From 3856d0879611ad08c2e03808f4bee3aa424191f0 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 15:23:13 +0000 Subject: [PATCH 09/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20fix:=20Keep=20Too?= =?UTF-8?q?l=20Switch=20Choices=20When=20a=20Saved=20Agent=20Chat=20Gets?= =?UTF-8?q?=20Its=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useApplyAgentToolSwitches.test.tsx | 64 +++++++++++++++++-- .../hooks/Agents/useApplyAgentToolSwitches.ts | 34 ++++++++-- 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx b/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx index 3410e4dca72..c7504537915 100644 --- a/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx +++ b/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx @@ -1,12 +1,15 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; +import { useSetRecoilState } from 'recoil'; import { Provider, createStore } from 'jotai'; -import { render, waitFor } from '@testing-library/react'; -import { mcpServerToggleKey } from 'librechat-data-provider'; +import { act, render, waitFor } from '@testing-library/react'; +import { LocalStorageKeys, mcpServerToggleKey } from 'librechat-data-provider'; +import type { TEphemeralAgent } from 'librechat-data-provider'; import type { Agent } from 'librechat-data-provider'; import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager'; import { useApplyAgentToolSwitches } from '../useApplyAgentToolSwitches'; import { useMCPSelect } from '~/hooks/MCP/useMCPSelect'; +import { ephemeralAgentByConvoId } from '~/store'; jest.mock('~/data-provider', () => ({ ...jest.requireActual('~/data-provider'), @@ -27,16 +30,28 @@ const withToggle = (toggle?: 'on' | 'off'): SavedAgent => ({ }); let selected: string[] = []; +let select: (value: string[]) => void = () => undefined; -function Picker() { - const { mcpValues } = useMCPSelect({ conversationId: null, servers, ownsChatSelection: true }); +function Picker({ conversationId }: { conversationId: string | null }) { + const { mcpValues, setMCPValues } = useMCPSelect({ + conversationId, + servers, + ownsChatSelection: true, + }); selected = mcpValues; + select = setMCPValues; return null; } -function Chat({ agent }: { agent: SavedAgent }) { - useApplyAgentToolSwitches({ agent, conversationId: null }); - return ; +function Chat({ + agent, + conversationId = null, +}: { + agent: SavedAgent; + conversationId?: string | null; +}) { + useApplyAgentToolSwitches({ agent, conversationId }); + return ; } describe('useApplyAgentToolSwitches', () => { @@ -77,4 +92,39 @@ describe('useApplyAgentToolSwitches', () => { await waitFor(() => expect(selected).toEqual([serverName])); }, ); + + it('keeps a default-on server the user turned off once the new chat gets its real id', async () => { + const store = createStore(); + let applyTemplate: (agent: TEphemeralAgent) => void = () => undefined; + function TemplateWriter() { + applyTemplate = useSetRecoilState(ephemeralAgentByConvoId('real1')); + return null; + } + const tree = (conversationId: string | null) => ( + + + + + + + ); + const { rerender } = render(tree(null)); + await waitFor(() => expect(selected).toEqual([serverName])); + + act(() => select([])); + await waitFor(() => expect(selected).toEqual([])); + + /** What the SSE handlers do on the first response: copy the submitted state to the real id. */ + act(() => { + applyTemplate({ mcp: [] }); + rerender(tree('real1')); + }); + await waitFor(() => + expect(localStorage.getItem(`${LocalStorageKeys.LAST_MCP_}real1`)).not.toBeNull(), + ); + expect(selected).toEqual([]); + expect( + JSON.parse(localStorage.getItem(`${LocalStorageKeys.LAST_MCP_}real1`) ?? 'null'), + ).toEqual([]); + }); }); diff --git a/client/src/hooks/Agents/useApplyAgentToolSwitches.ts b/client/src/hooks/Agents/useApplyAgentToolSwitches.ts index e0cc568948c..3f8024095ee 100644 --- a/client/src/hooks/Agents/useApplyAgentToolSwitches.ts +++ b/client/src/hooks/Agents/useApplyAgentToolSwitches.ts @@ -1,9 +1,10 @@ import { useEffect, useRef } from 'react'; -import { useSetRecoilState } from 'recoil'; +import { useSetAtom } from 'jotai'; +import { useRecoilCallback, useSetRecoilState } from 'recoil'; import { Constants, getAgentToolSwitches } from 'librechat-data-provider'; -import type { Agent } from 'librechat-data-provider'; +import type { Agent, TEphemeralAgent } from 'librechat-data-provider'; +import { ephemeralAgentByConvoId, mcpValuesAtomFamily } from '~/store'; import { applyAgentToolSwitchDefaults } from '~/utils'; -import { ephemeralAgentByConvoId } from '~/store'; export function useApplyAgentToolSwitches({ agent, @@ -14,6 +15,14 @@ export function useApplyAgentToolSwitches({ }) { const convoId = conversationId ?? Constants.NEW_CONVO; const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(convoId)); + const setMCPValues = useSetAtom(mcpValuesAtomFamily(convoId)); + const getEphemeralAgent = useRecoilCallback( + ({ snapshot }) => + () => + snapshot.getLoadable(ephemeralAgentByConvoId(convoId)).contents as TEphemeralAgent | null, + [convoId], + ); + const seededRef = useRef<{ convoId: string; seedKey: string } | null>(null); const agentRef = useRef(agent); agentRef.current = agent; const agentId = agent?.id; @@ -31,6 +40,23 @@ export function useApplyAgentToolSwitches({ if (Object.keys(switches.builtins).length === 0 && switchableServers.length === 0) { return; } + const previousSeed = seededRef.current; + const seedKey = `${agentId}:${switchesKey}`; + seededRef.current = { convoId, seedKey }; + /** A new chat that just received its real id already carries the user's + * choices, copied over from the submission; persist them instead of reseeding. */ + const carried = + previousSeed?.convoId === Constants.NEW_CONVO && + previousSeed.seedKey === seedKey && + convoId !== Constants.NEW_CONVO + ? getEphemeralAgent() + : null; + if (carried) { + if (Array.isArray(carried.mcp)) { + setMCPValues(carried.mcp); + } + return; + } const seeded = applyAgentToolSwitchDefaults({ agent: current, convoId, @@ -46,5 +72,5 @@ export function useApplyAgentToolSwitches({ ], }), })); - }, [agentId, switchesKey, convoId, setEphemeralAgent]); + }, [agentId, switchesKey, convoId, setEphemeralAgent, setMCPValues, getEphemeralAgent]); } From c0a0d30a4212785ce38e19a2e539602a331dabc8 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 15:30:30 +0000 Subject: [PATCH 10/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20fix:=20Keep=20Swi?= =?UTF-8?q?tched-Off=20MCP=20Servers=20Off=20After=20Reload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useApplyAgentToolSwitches.test.tsx | 44 +++++++++++++++++++ .../hooks/Agents/useApplyAgentToolSwitches.ts | 6 ++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx b/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx index c7504537915..97103f7c5c6 100644 --- a/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx +++ b/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx @@ -8,6 +8,7 @@ import type { TEphemeralAgent } from 'librechat-data-provider'; import type { Agent } from 'librechat-data-provider'; import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager'; import { useApplyAgentToolSwitches } from '../useApplyAgentToolSwitches'; +import { cleanupTimestampedStorage } from '~/utils/timestamps'; import { useMCPSelect } from '~/hooks/MCP/useMCPSelect'; import { ephemeralAgentByConvoId } from '~/store'; @@ -127,4 +128,47 @@ describe('useApplyAgentToolSwitches', () => { JSON.parse(localStorage.getItem(`${LocalStorageKeys.LAST_MCP_}real1`) ?? 'null'), ).toEqual([]); }); + + it('keeps a default-on server the user turned off after the app reloads', async () => { + let applyTemplate: (agent: TEphemeralAgent) => void = () => undefined; + function TemplateWriter() { + applyTemplate = useSetRecoilState(ephemeralAgentByConvoId('real1')); + return null; + } + const store = createStore(); + const tree = (conversationId: string | null) => ( + + + + + + + ); + const { rerender, unmount } = render(tree(null)); + await waitFor(() => expect(selected).toEqual([serverName])); + + act(() => select([])); + await waitFor(() => expect(selected).toEqual([])); + + act(() => { + applyTemplate({ mcp: [] }); + rerender(tree('real1')); + }); + await waitFor(() => + expect(localStorage.getItem(`${LocalStorageKeys.LAST_MCP_}real1`)).not.toBeNull(), + ); + unmount(); + + /** App startup prunes conversation keys without a timestamp. */ + cleanupTimestampedStorage(); + render( + + + + + , + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(selected).toEqual([]); + }); }); diff --git a/client/src/hooks/Agents/useApplyAgentToolSwitches.ts b/client/src/hooks/Agents/useApplyAgentToolSwitches.ts index 3f8024095ee..443b24edac1 100644 --- a/client/src/hooks/Agents/useApplyAgentToolSwitches.ts +++ b/client/src/hooks/Agents/useApplyAgentToolSwitches.ts @@ -1,10 +1,11 @@ import { useEffect, useRef } from 'react'; import { useSetAtom } from 'jotai'; import { useRecoilCallback, useSetRecoilState } from 'recoil'; -import { Constants, getAgentToolSwitches } from 'librechat-data-provider'; +import { Constants, LocalStorageKeys, getAgentToolSwitches } from 'librechat-data-provider'; import type { Agent, TEphemeralAgent } from 'librechat-data-provider'; import { ephemeralAgentByConvoId, mcpValuesAtomFamily } from '~/store'; import { applyAgentToolSwitchDefaults } from '~/utils'; +import { setTimestamp } from '~/utils/timestamps'; export function useApplyAgentToolSwitches({ agent, @@ -54,6 +55,9 @@ export function useApplyAgentToolSwitches({ if (carried) { if (Array.isArray(carried.mcp)) { setMCPValues(carried.mcp); + /** An explicit empty list overrides the creator's default-on servers, so it + * needs a timestamp to survive startup cleanup like a non-empty one. */ + setTimestamp(`${LocalStorageKeys.LAST_MCP_}${convoId}`); } return; } From 75493e54f132db5b08cb39e6637d49455486b97d Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 15:40:25 +0000 Subject: [PATCH 11/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20fix:=20Keep=20Swi?= =?UTF-8?q?tched-Off=20MCP=20Servers=20Off=20in=20Existing=20Agent=20Chats?= =?UTF-8?q?=20After=20Reload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useApplyAgentToolSwitches.test.tsx | 34 +++++++++++++++++++ .../hooks/Agents/useApplyAgentToolSwitches.ts | 25 ++++++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx b/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx index 97103f7c5c6..888dcf0cc91 100644 --- a/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx +++ b/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx @@ -171,4 +171,38 @@ describe('useApplyAgentToolSwitches', () => { await new Promise((resolve) => setTimeout(resolve, 50)); expect(selected).toEqual([]); }); + + it('keeps a default-on server turned off in an existing chat after a reload near the cleanup age', async () => { + const hour = 60 * 60 * 1000; + const storageKey = `${LocalStorageKeys.LAST_MCP_}c1`; + const tree = () => ( + + + + + + ); + const { unmount } = render(tree()); + await waitFor(() => expect(selected).toEqual([serverName])); + + /** The last non-empty selection was stamped 47 hours ago. */ + localStorage.setItem(`${storageKey}_TIMESTAMP`, String(Date.now() - 47 * hour)); + act(() => select([])); + await waitFor(() => expect(selected).toEqual([])); + unmount(); + + /** Reload three hours later: startup cleanup runs before the chat mounts. */ + const now = Date.now() + 3 * hour; + const dateNow = jest.spyOn(Date, 'now').mockReturnValue(now); + try { + cleanupTimestampedStorage(); + } finally { + dateNow.mockRestore(); + } + expect(JSON.parse(localStorage.getItem(storageKey) ?? 'null')).toEqual([]); + + render(tree()); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(selected).toEqual([]); + }); }); diff --git a/client/src/hooks/Agents/useApplyAgentToolSwitches.ts b/client/src/hooks/Agents/useApplyAgentToolSwitches.ts index 443b24edac1..9483d35f2c9 100644 --- a/client/src/hooks/Agents/useApplyAgentToolSwitches.ts +++ b/client/src/hooks/Agents/useApplyAgentToolSwitches.ts @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react'; -import { useSetAtom } from 'jotai'; +import { useAtom } from 'jotai'; import { useRecoilCallback, useSetRecoilState } from 'recoil'; import { Constants, LocalStorageKeys, getAgentToolSwitches } from 'librechat-data-provider'; import type { Agent, TEphemeralAgent } from 'librechat-data-provider'; @@ -16,7 +16,7 @@ export function useApplyAgentToolSwitches({ }) { const convoId = conversationId ?? Constants.NEW_CONVO; const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(convoId)); - const setMCPValues = useSetAtom(mcpValuesAtomFamily(convoId)); + const [mcpValues, setMCPValues] = useAtom(mcpValuesAtomFamily(convoId)); const getEphemeralAgent = useRecoilCallback( ({ snapshot }) => () => @@ -29,7 +29,9 @@ export function useApplyAgentToolSwitches({ const agentId = agent?.id; /** The saved agent's cache entry is replaced in place when its creator edits it, * so reseed on a change in the switch config itself, not just the agent id. */ - const switchesKey = agent ? JSON.stringify(getAgentToolSwitches(agent)) : ''; + const agentSwitches = agent ? getAgentToolSwitches(agent) : null; + const switchesKey = agentSwitches ? JSON.stringify(agentSwitches) : ''; + const hasMCPSwitches = agentSwitches != null && Object.keys(agentSwitches.mcp).length > 0; useEffect(() => { const current = agentRef.current; @@ -55,9 +57,6 @@ export function useApplyAgentToolSwitches({ if (carried) { if (Array.isArray(carried.mcp)) { setMCPValues(carried.mcp); - /** An explicit empty list overrides the creator's default-on servers, so it - * needs a timestamp to survive startup cleanup like a non-empty one. */ - setTimestamp(`${LocalStorageKeys.LAST_MCP_}${convoId}`); } return; } @@ -77,4 +76,18 @@ export function useApplyAgentToolSwitches({ }), })); }, [agentId, switchesKey, convoId, setEphemeralAgent, setMCPValues, getEphemeralAgent]); + + /** An explicit empty list overrides the creator's default-on servers, so it needs a + * fresh timestamp to survive startup cleanup; plain chats only stamp non-empty lists. */ + useEffect(() => { + const storageKey = `${LocalStorageKeys.LAST_MCP_}${convoId}`; + if ( + !hasMCPSwitches || + convoId === Constants.NEW_CONVO || + localStorage.getItem(storageKey) === null + ) { + return; + } + setTimestamp(storageKey); + }, [hasMCPSwitches, convoId, mcpValues]); } From 6a122a26f569f8a7cecfeb8201c51d98118afa94 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 16:01:11 +0000 Subject: [PATCH 12/22] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Sort=20Agent=20To?= =?UTF-8?q?ol=20Option=20Imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/data-provider/src/agentToolOptions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/data-provider/src/agentToolOptions.ts b/packages/data-provider/src/agentToolOptions.ts index 1968306516f..41afe97c3ce 100644 --- a/packages/data-provider/src/agentToolOptions.ts +++ b/packages/data-provider/src/agentToolOptions.ts @@ -1,4 +1,5 @@ -import { Constants } from './config'; +import type { TEphemeralAgent } from './types'; +import type { Agent } from './types/agents'; import { Tools, actionDelimiter, @@ -7,8 +8,7 @@ import { type AgentToolOptions, type AllowedCaller, } from './types/tools'; -import type { TEphemeralAgent } from './types'; -import type { Agent } from './types/agents'; +import { Constants } from './config'; const actionDomainSeparatorRegex = new RegExp(actionDomainSeparator, 'g'); From 0f9767fcb37c574c2a0e366828136ae84f1ba497 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 16:18:55 +0000 Subject: [PATCH 13/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20fix:=20Match=20MC?= =?UTF-8?q?P=20Server=20Switches=20by=20Configured=20Server=20Name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/agentToolOptions.spec.ts | 63 +++++++++++++++ .../data-provider/src/agentToolOptions.ts | 80 +++++++++++++------ 2 files changed, 119 insertions(+), 24 deletions(-) diff --git a/packages/data-provider/src/agentToolOptions.spec.ts b/packages/data-provider/src/agentToolOptions.spec.ts index 1dc6abcde8d..d1c90dd9092 100644 --- a/packages/data-provider/src/agentToolOptions.spec.ts +++ b/packages/data-provider/src/agentToolOptions.spec.ts @@ -3,6 +3,7 @@ import { mcpServerToggleKey, getAgentToolSwitches, applyAgentToolSwitches, + pickUserToggleOptions, normalizeActionToolName, removeCodeExecutionCaller, } from './agentToolOptions'; @@ -87,6 +88,24 @@ describe('getAgentToolSwitches', () => { it('is empty without tool_options', () => { expect(getAgentToolSwitches({ tools: ['web_search'] })).toEqual({ builtins: {}, mcp: {} }); }); + + it('keeps a server whose name contains the MCP delimiter', () => { + expect( + getAgentToolSwitches({ + tools: ['search_mcp_Google_mcp_Workspace'], + tool_options: { [mcpServerToggleKey('Google_mcp_Workspace')]: { user_toggle: 'on' } }, + }), + ).toEqual({ builtins: {}, mcp: { Google_mcp_Workspace: true } }); + }); + + it('keys a server by its configured name when its tool keys use the normalized name', () => { + expect( + getAgentToolSwitches({ + tools: ['search_mcp_My_Docs'], + tool_options: { [mcpServerToggleKey('My Docs')]: { user_toggle: 'off' } }, + }), + ).toEqual({ builtins: {}, mcp: { 'My Docs': false } }); + }); }); describe('applyAgentToolSwitches', () => { @@ -145,6 +164,31 @@ describe('applyAgentToolSwitches', () => { expect(result).toEqual({ tools: ['file_search'], mcp: [] }); }); + it('tells apart a server named bar from one named foo_mcp_bar', () => { + const tool_options = { + [mcpServerToggleKey('bar')]: { user_toggle: 'on' as const }, + [mcpServerToggleKey('foo_mcp_bar')]: { user_toggle: 'on' as const }, + }; + const tools = ['a_mcp_bar', 'b_mcp_foo_mcp_bar']; + expect(applyAgentToolSwitches({ tools, tool_options }, { mcp: ['bar'] }).tools).toEqual([ + 'a_mcp_bar', + ]); + expect(applyAgentToolSwitches({ tools, tool_options }, { mcp: ['foo_mcp_bar'] }).tools).toEqual( + ['b_mcp_foo_mcp_bar'], + ); + }); + + it('matches the chat list by configured name for a normalized server', () => { + const agent = { + tools: ['search_mcp_My_Docs'], + tool_options: { [mcpServerToggleKey('My Docs')]: { user_toggle: 'off' as const } }, + }; + expect(applyAgentToolSwitches(agent, { mcp: [] }).tools).toEqual([]); + expect(applyAgentToolSwitches(agent, { mcp: ['My Docs'] }).tools).toEqual([ + 'search_mcp_My_Docs', + ]); + }); + it('drops the server placeholder and wildcard tokens with the server', () => { const result = applyAgentToolSwitches( { @@ -156,3 +200,22 @@ describe('applyAgentToolSwitches', () => { expect(result.tools).toEqual([]); }); }); + +describe('pickUserToggleOptions', () => { + it('keeps only user_toggle entries and nothing else from the options', () => { + expect( + pickUserToggleOptions({ + web_search: { user_toggle: 'off', defer_loading: true }, + search_mcp_docs: { allowed_callers: ['direct'] }, + [mcpServerToggleKey('docs')]: { user_toggle: 'on' }, + }), + ).toEqual({ + web_search: { user_toggle: 'off' }, + [mcpServerToggleKey('docs')]: { user_toggle: 'on' }, + }); + }); + + it('returns undefined without options', () => { + expect(pickUserToggleOptions(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/data-provider/src/agentToolOptions.ts b/packages/data-provider/src/agentToolOptions.ts index 41afe97c3ce..82e5adb109d 100644 --- a/packages/data-provider/src/agentToolOptions.ts +++ b/packages/data-provider/src/agentToolOptions.ts @@ -8,7 +8,7 @@ import { type AgentToolOptions, type AllowedCaller, } from './types/tools'; -import { Constants } from './config'; +import { Constants, splitMCPToolKey, normalizeServerName } from './config'; const actionDomainSeparatorRegex = new RegExp(actionDomainSeparator, 'g'); @@ -83,20 +83,37 @@ export interface AgentToolSwitches { type SwitchableAgent = Pick; -function isServerTool(tool: string, serverName: string): boolean { - const suffix = `${Constants.mcp_delimiter}${serverName}`; - return tool.endsWith(suffix) || tool === `${Constants.mcp_prefix}${serverName}`; +const mcpServerTogglePrefix = mcpServerToggleKey(''); + +/** Configured names of the servers carrying a `user_toggle`, attached or not. */ +export function getMCPSwitchServerNames(toolOptions: AgentToolOptions | undefined): string[] { + return Object.entries(toolOptions ?? {}) + .filter( + ([key, options]) => key.startsWith(mcpServerTogglePrefix) && options.user_toggle != null, + ) + .map(([key]) => key.slice(mcpServerTogglePrefix.length)); } -function attachedServerNames(tools: string[]): string[] { - const names = new Set(); - for (const tool of tools) { - const index = tool.lastIndexOf(Constants.mcp_delimiter); - if (index >= 0) { - names.add(tool.slice(index + Constants.mcp_delimiter.length)); +/** Only the `user_toggle` of each entry, so a view-only reader learns nothing else. */ +export function pickUserToggleOptions( + toolOptions: AgentToolOptions | undefined, +): AgentToolOptions | undefined { + if (toolOptions == null) { + return undefined; + } + const picked: AgentToolOptions = {}; + for (const [key, options] of Object.entries(toolOptions)) { + if (options.user_toggle != null) { + picked[key] = { user_toggle: options.user_toggle }; } } - return Array.from(names); + return picked; +} + +/** Tool keys carry the normalized server name, which may itself contain the MCP + * delimiter; the switch servers' key names disambiguate the split. */ +function toolServerName(tool: string, keyServerNames: string[]): string | undefined { + return splitMCPToolKey(tool, keyServerNames)[1]; } /** The creator's switchable set and defaults. Locked or unattached tools never appear. */ @@ -110,12 +127,14 @@ export function getAgentToolSwitches(agent: SwitchableAgent): AgentToolSwitches switches.builtins[tool] = toggle === 'on'; } } - for (const serverName of attachedServerNames(tools)) { - const toggle = options[mcpServerToggleKey(serverName)]?.user_toggle; - if (toggle != null) { - switches.mcp[serverName] = toggle === 'on'; + const serverNames = getMCPSwitchServerNames(options); + const keyServerNames = serverNames.map(normalizeServerName); + const attached = new Set(tools.map((tool) => toolServerName(tool, keyServerNames))); + serverNames.forEach((serverName, index) => { + if (attached.has(keyServerNames[index])) { + switches.mcp[serverName] = options[mcpServerToggleKey(serverName)]?.user_toggle === 'on'; } - } + }); return switches; } @@ -136,13 +155,26 @@ export function applyAgentToolSwitches( } } const requestedServers = Array.isArray(requested?.mcp) ? requested.mcp : undefined; - const offServers = Object.entries(mcp) - .filter( - ([name, isDefaultOn]) => !(requestedServers ? requestedServers.includes(name) : isDefaultOn), - ) - .map(([name]) => name); - const kept = tools.filter( - (tool) => !dropped.has(tool) && !offServers.some((name) => isServerTool(tool, name)), + const keyServerNames = getMCPSwitchServerNames(agent.tool_options).map(normalizeServerName); + const offServers = new Set( + Object.entries(mcp) + .filter( + ([name, isDefaultOn]) => + !(requestedServers ? requestedServers.includes(name) : isDefaultOn), + ) + .map(([name]) => normalizeServerName(name)), ); - return { tools: kept, mcp: attachedServerNames(kept) }; + const kept: string[] = []; + const keptServers = new Set(); + for (const tool of tools) { + const serverName = toolServerName(tool, keyServerNames); + if (dropped.has(tool) || (serverName != null && offServers.has(serverName))) { + continue; + } + kept.push(tool); + if (serverName != null) { + keptServers.add(serverName); + } + } + return { tools: kept, mcp: Array.from(keptServers) }; } From 322b1b2a9b4035beb03479b87564d9a8b3edfc1a Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 16:19:01 +0000 Subject: [PATCH 14/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20fix:=20Send=20Age?= =?UTF-8?q?nt=20Tool=20Switches=20to=20Chat=20Users=20With=20View=20Access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/controllers/agents/v1.js | 3 +++ api/server/controllers/agents/v1.spec.js | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index d6137f281b9..9c3ab1d2ade 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -67,6 +67,7 @@ const { EModelEndpoint, resolveAllowedStatefulCodeEnvironments, removeCodeExecutionCaller, + pickUserToggleOptions, hasActivePiiFields, hasActivePiiPatterns, openapiToFunction, @@ -1018,6 +1019,8 @@ const getAgentHandler = async (req, res, expandProperties = false) => { provider: agent.provider, model: agent.model, model_parameters: getSafeModelParameters(agent.model_parameters), + tools: agent.tools, + tool_options: pickUserToggleOptions(agent.tool_options), isPublic: agent.isPublic, version: agent.version, // Safe metadata diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index bf704940ba7..8e6cda9d4de 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -1407,6 +1407,30 @@ describe('Agent Controllers - Mass Assignment Protection', () => { const response = mockRes.json.mock.calls[0][0]; expect(response.conversation_starters).toEqual(starters); }); + + test('should include the tools and only their user switches in the basic VIEW response', async () => { + const agent = await Agent.create({ + id: `agent_${uuidv4()}`, + name: 'Switchable Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['web_search', 'search_mcp_docs'], + tool_options: { + web_search: { user_toggle: 'off', defer_loading: true }, + search_mcp_docs: { allowed_callers: ['direct'] }, + }, + }); + + mockReq.params = { id: agent.id }; + + await getAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + const response = mockRes.json.mock.calls[0][0]; + expect(response.tools).toEqual(['web_search', 'search_mcp_docs']); + expect(response.tool_options).toEqual({ web_search: { user_toggle: 'off' } }); + }); }); describe('getAgentVersionsHandler', () => { From 05b2fcff94796748f68d87453590d1e5f516e330 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 16:21:08 +0000 Subject: [PATCH 15/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20fix:=20Resume=20a?= =?UTF-8?q?=20Paused=20Agent=20Turn=20With=20the=20Chat's=20MCP=20Switches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/src/agents/__tests__/load.spec.ts | 34 +++++++++++++++++-- packages/api/src/agents/context.spec.ts | 30 ++++++++++++++++ packages/api/src/agents/context.ts | 10 ++++-- packages/api/src/agents/load.ts | 11 ++---- 4 files changed, 72 insertions(+), 13 deletions(-) diff --git a/packages/api/src/agents/__tests__/load.spec.ts b/packages/api/src/agents/__tests__/load.spec.ts index 2824ec482c8..7fa8f411d7a 100644 --- a/packages/api/src/agents/__tests__/load.spec.ts +++ b/packages/api/src/agents/__tests__/load.spec.ts @@ -11,6 +11,7 @@ import type { } from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; import type { LoadAgentParams, LoadAgentDeps } from '../load'; +import { applyResumeContext, pickResumeContext } from '../hitl/policy'; import { extractMCPServers } from '../context'; import { loadAddedAgent } from '../added'; import { loadAgent } from '../load'; @@ -1176,7 +1177,7 @@ describe('loadAgent', () => { [['C'], ['B']], [['A'], ['A', 'B']], ])( - 'drops the request server list so context follows the filtered tools (%j)', + 'leaves the request server list intact while the filtered tools name the servers (%j)', async (requestedServers, expectedServers) => { const agentId = `agent_${uuidv4()}`; await createAgent({ @@ -1190,12 +1191,41 @@ describe('loadAgent', () => { }); const ephemeralAgent = { mcp: requestedServers }; const result = await load(agentId, ephemeralAgent); - expect(ephemeralAgent).not.toHaveProperty('mcp'); + expect(ephemeralAgent).toEqual({ mcp: requestedServers }); const toolDefinitions = result!.tools!.map((name) => ({ name })); expect(extractMCPServers({ ...result, toolDefinitions } as never)).toEqual(expectedServers); }, ); + test.each([ + ['on' as const, [], ['web_search']], + ['off' as const, ['A'], ['search_mcp_A', 'web_search']], + ])( + 'rebuilds the same tools after a pause and resume (server starts %s, chat sends %j)', + async (userToggle, requestedServers, expectedTools) => { + const agentId = `agent_${uuidv4()}`; + await createAgent({ + id: agentId, + name: 'Paused Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['search_mcp_A', 'web_search'], + tool_options: { sys__server__sys_mcp_A: { user_toggle: userToggle } }, + }); + const pausedBody: Record = { + agent_id: agentId, + ephemeralAgent: { mcp: requestedServers }, + }; + const paused = await load(agentId, pausedBody.ephemeralAgent as TEphemeralAgent); + const resumedBody: Record = { agent_id: agentId }; + applyResumeContext(resumedBody, pickResumeContext(pausedBody)); + const resumed = await load(agentId, resumedBody.ephemeralAgent as TEphemeralAgent); + expect(paused!.tools).toEqual(expectedTools); + expect(resumed!.tools).toEqual(expectedTools); + }, + ); + test('leaves the request server list alone when no server is switchable', async () => { const agentId = `agent_${uuidv4()}`; await createAgent({ diff --git a/packages/api/src/agents/context.spec.ts b/packages/api/src/agents/context.spec.ts index 4094f73638d..c97116400d2 100644 --- a/packages/api/src/agents/context.spec.ts +++ b/packages/api/src/agents/context.spec.ts @@ -368,6 +368,36 @@ describe('Agent Context Utilities', () => { expect(agent.additional_instructions).toBe('Context'); }); + it('follows the agent tools, not the request list, when the agent has switchable servers', async () => { + const agent: AgentWithTools = { + id: 'test-agent', + instructions: 'Base', + tool_options: { + [`${Constants.mcp_server}${Constants.mcp_delimiter}A`]: { user_toggle: 'on' }, + }, + tools: [ + new DynamicStructuredTool({ + name: `tool${Constants.mcp_delimiter}B`, + description: 'Locked server tool', + schema: testSchema, + func: async () => 'result', + }), + ], + }; + + mockMCPManager.formatInstructionsForContext.mockResolvedValue('B MCP'); + + await applyContextToAgent({ + agent, + sharedRunContext: '', + mcpManager: mockMCPManager, + ephemeralAgent: { mcp: ['C'] }, + logger: mockLogger, + }); + + expect(mockMCPManager.formatInstructionsForContext).toHaveBeenCalledWith(['B'], undefined); + }); + it('should prefer agent tools over empty ephemeral MCP array', async () => { const agent: AgentWithTools = { id: 'test-agent', diff --git a/packages/api/src/agents/context.ts b/packages/api/src/agents/context.ts index f27875aeb1d..3082d7bfc77 100644 --- a/packages/api/src/agents/context.ts +++ b/packages/api/src/agents/context.ts @@ -1,5 +1,5 @@ -import { Constants } from 'librechat-data-provider'; import { DynamicStructuredTool } from '@librechat/agents/langchain/tools'; +import { Constants, getMCPSwitchServerNames } from 'librechat-data-provider'; import type { Agent, TEphemeralAgent } from 'librechat-data-provider'; import type { LCTool } from '@librechat/agents'; import type { Logger } from 'winston'; @@ -160,7 +160,13 @@ export async function applyContextToAgent({ const additionalInstructions = agent.additional_instructions || ''; try { - const mcpServers = ephemeralAgent?.mcp?.length ? ephemeralAgent.mcp : extractMCPServers(agent); + /** An agent with switchable servers already had its tools filtered by the chat's + * list, which omits its locked servers, so its tools name the servers. */ + const requestServers = ephemeralAgent?.mcp; + const mcpServers = + requestServers?.length && getMCPSwitchServerNames(agent.tool_options).length === 0 + ? requestServers + : extractMCPServers(agent); const mcpInstructions = await getMCPInstructionsForServers( mcpServers, mcpManager, diff --git a/packages/api/src/agents/load.ts b/packages/api/src/agents/load.ts index b948f076b27..e0c26084462 100644 --- a/packages/api/src/agents/load.ts +++ b/packages/api/src/agents/load.ts @@ -7,7 +7,6 @@ import { getEphemeralSender, encodeEphemeralAgentId, applyAgentToolSwitches, - getAgentToolSwitches, } from 'librechat-data-provider'; import type { AgentModelParameters, @@ -250,14 +249,8 @@ export async function loadAgent( } agent.version ??= agent.versions?.length ?? 0; - if (userToolSwitches === undefined) { - return agent; - } - const hasSwitchableServer = Object.keys(getAgentToolSwitches(agent).mcp).length > 0; - agent.tools = applyAgentToolSwitches(agent, userToolSwitches).tools; - /** Context derivation prefers the request's server list over the agent's tools; drop it so the filtered tools decide. */ - if (userToolSwitches != null && hasSwitchableServer) { - delete userToolSwitches.mcp; + if (userToolSwitches !== undefined) { + agent.tools = applyAgentToolSwitches(agent, userToolSwitches).tools; } return agent; } From e19614dc8211485cdaf9583c4a278a9307142eb0 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 16:21:30 +0000 Subject: [PATCH 16/22] =?UTF-8?q?=F0=9F=93=98=20docs:=20Regenerate=20Agent?= =?UTF-8?q?s=20OpenAPI=20Spec=20for=20Tool=20User=20Switches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/openapi/agents.openapi.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/api/openapi/agents.openapi.json b/packages/api/openapi/agents.openapi.json index df429025b71..a2e4d216fab 100644 --- a/packages/api/openapi/agents.openapi.json +++ b/packages/api/openapi/agents.openapi.json @@ -2263,6 +2263,13 @@ }, "describe_intent": { "type": "boolean" + }, + "user_toggle": { + "type": "string", + "enum": [ + "on", + "off" + ] } } } @@ -2702,6 +2709,13 @@ }, "describe_intent": { "type": "boolean" + }, + "user_toggle": { + "type": "string", + "enum": [ + "on", + "off" + ] } } } @@ -3165,6 +3179,13 @@ }, "describe_intent": { "type": "boolean" + }, + "user_toggle": { + "type": "string", + "enum": [ + "on", + "off" + ] } } } From 1891ed5de62e58c795da1fda25fe28c0e73b2e15 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 16:24:12 +0000 Subject: [PATCH 17/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20fix:=20Offer=20a?= =?UTF-8?q?=20Saved=20Agent's=20Switchable=20MCP=20Servers=20the=20Chat=20?= =?UTF-8?q?Menu=20Hides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/Providers/BadgeRowContext.tsx | 13 +++- .../BadgeRowContext.agentSwitches.spec.tsx | 71 +++++++++++++++++++ .../hooks/MCP/__tests__/useMCPSelect.test.tsx | 27 +++++++ client/src/hooks/MCP/useMCPSelect.ts | 19 +++-- client/src/hooks/MCP/useMCPServerManager.ts | 4 ++ 5 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 client/src/Providers/__tests__/BadgeRowContext.agentSwitches.spec.tsx diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx index ede2466803b..7b81b73ec64 100644 --- a/client/src/Providers/BadgeRowContext.tsx +++ b/client/src/Providers/BadgeRowContext.tsx @@ -276,25 +276,32 @@ export default function BadgeRowProvider({ isAuthenticated: true, }); + const agentServers = useMemo( + () => (agentToolSwitches ? Object.keys(agentToolSwitches.mcp) : undefined), + [agentToolSwitches], + ); const mcpServerManager = useMCPServerManager({ conversationId, storageContextKey, specName, + agentServers, ownsChatSelection: true, observeToolAuthorization, }); - /** In a saved agent's chat the MCP menu offers only the servers its creator made switchable. */ + /** In a saved agent's chat the MCP menu offers exactly the servers its creator made + * switchable, including ones `chatMenu: false` hides from plain chats. */ const chatMcpServerManager = useMemo(() => { if (!agentToolSwitches) { return mcpServerManager; } const isSwitchable = ({ serverName }: { serverName: string }) => serverName in agentToolSwitches.mcp; + const agentMCPServers = mcpServerManager.availableMCPServers.filter(isSwitchable); return { ...mcpServerManager, - availableMCPServers: mcpServerManager.availableMCPServers.filter(isSwitchable), - selectableServers: mcpServerManager.selectableServers.filter(isSwitchable), + availableMCPServers: agentMCPServers, + selectableServers: agentMCPServers, }; }, [mcpServerManager, agentToolSwitches]); diff --git a/client/src/Providers/__tests__/BadgeRowContext.agentSwitches.spec.tsx b/client/src/Providers/__tests__/BadgeRowContext.agentSwitches.spec.tsx new file mode 100644 index 00000000000..386c8457d58 --- /dev/null +++ b/client/src/Providers/__tests__/BadgeRowContext.agentSwitches.spec.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { render } from '@testing-library/react'; +import type { AgentToolSwitches } from 'librechat-data-provider'; +import BadgeRowProvider, { useBadgeRowContext } from '../BadgeRowContext'; + +const mockUseMCPServerManager = jest.fn(); + +const server = (serverName: string, chatMenu?: boolean) => ({ + serverName, + config: { type: 'sse', url: 'http://mcp', chatMenu }, +}); + +jest.mock('~/hooks', () => ({ + useMCPServerManager: (args: unknown) => mockUseMCPServerManager(args), + useSearchApiKeyForm: () => ({}), + useGetAgentsConfig: () => ({ agentsConfig: undefined }), + useToolToggle: () => ({}), +})); + +jest.mock('~/data-provider', () => ({ + useGetStartupConfig: () => ({ data: undefined }), +})); + +let context: ReturnType; +const Consumer = () => { + context = useBadgeRowContext(); + return null; +}; + +const renderProvider = (agentToolSwitches?: AgentToolSwitches) => + render( + + + + + , + ); + +describe('BadgeRowProvider agent switches', () => { + beforeEach(() => { + mockUseMCPServerManager.mockReset(); + const hidden = server('hidden', false); + const visible = server('visible'); + const other = server('other'); + mockUseMCPServerManager.mockReturnValue({ + availableMCPServers: [hidden, visible, other], + selectableServers: [visible, other], + }); + }); + + it("offers the agent's switchable servers, including one hidden from the chat menu", () => { + renderProvider({ builtins: {}, mcp: { hidden: true, visible: false } }); + + expect(mockUseMCPServerManager).toHaveBeenCalledWith( + expect.objectContaining({ agentServers: ['hidden', 'visible'] }), + ); + const names = (list: Array<{ serverName: string }>) => list.map((s) => s.serverName); + expect(names(context!.mcpServerManager.selectableServers)).toEqual(['hidden', 'visible']); + expect(names(context!.mcpServerManager.availableMCPServers)).toEqual(['hidden', 'visible']); + }); + + it('leaves the manager untouched outside a saved agent chat', () => { + renderProvider(); + + expect(mockUseMCPServerManager).toHaveBeenCalledWith( + expect.objectContaining({ agentServers: undefined }), + ); + expect(context!.mcpServerManager.selectableServers).toHaveLength(2); + }); +}); diff --git a/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx b/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx index 93ca85f519d..bfde05cdb83 100644 --- a/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx +++ b/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx @@ -965,6 +965,33 @@ describe('useMCPSelect', () => { }); }); + it("keeps a saved agent's switchable server, even though the menu hides it", async () => { + const { Wrapper } = createWrapper(); + + const TestComponent = () => { + const mcpHook = useMCPSelect({ + ownsChatSelection: true, + servers: createMCPServers(['visible']), + allServers: createMCPServers(['visible', 'agent-server', 'stale-hidden']), + agentServers: ['agent-server'], + }); + const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); + const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(Constants.NEW_CONVO)); + return { mcpHook, ephemeralAgent, setEphemeralAgent }; + }; + + const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper }); + + act(() => { + result.current.setEphemeralAgent({ mcp: ['visible', 'agent-server', 'stale-hidden'] }); + }); + + await waitFor(() => { + expect(result.current.ephemeralAgent?.mcp).toEqual(['visible', 'agent-server']); + expect(result.current.mcpHook.mcpValues).toEqual(['visible', 'agent-server']); + }); + }); + it("lets a catalog-only instance leave the picker instance's selection alone", async () => { mockStartupConfig = { modelSpecs: { list: [{ name: 'pins-hidden', mcpServers: ['spec-server'] }] }, diff --git a/client/src/hooks/MCP/useMCPSelect.ts b/client/src/hooks/MCP/useMCPSelect.ts index f4e08efaee3..4fa77495c1c 100644 --- a/client/src/hooks/MCP/useMCPSelect.ts +++ b/client/src/hooks/MCP/useMCPSelect.ts @@ -18,6 +18,7 @@ export function useMCPSelect({ servers, allServers, specName, + agentServers, ownsChatSelection = false, }: { conversationId?: string | null; @@ -28,6 +29,8 @@ export function useMCPSelect({ allServers?: MCPServerDefinition[]; /** Active model spec, whose pinned servers are exempt from pruning. */ specName?: string | null; + /** The saved agent's switchable servers, exempt from pruning like a spec's. */ + agentServers?: string[]; /** * Whether this instance drives the chat picker and may therefore rewrite the * shared selection. Off by default: every instance keyed to a conversation @@ -57,21 +60,23 @@ export function useMCPSelect({ const { data: startupConfig } = useGetStartupConfig(); /** * Selections that survive pruning: what the dropdown offers, plus whatever the - * active model spec pins. `chatMenu` hides a server from the picker; it does - * not override an admin's spec, so a spec-assigned server stays selected even - * when the picker would never have offered it. + * active model spec pins or the saved agent makes switchable. `chatMenu` hides a + * server from the picker; it does not override an admin's spec or an agent + * creator's switch, so such a server stays selected even when the picker would + * never have offered it. */ const retainedServers = useMemo(() => { - const specServers = getModelSpec({ specName, startupConfig })?.mcpServers; - if (!specServers?.length) { + const specServers = getModelSpec({ specName, startupConfig })?.mcpServers ?? []; + const exempt = [...specServers, ...(agentServers ?? [])]; + if (!exempt.length) { return configuredServers; } const retained = new Set(configuredServers); - for (const serverName of specServers) { + for (const serverName of exempt) { retained.add(serverName); } return retained; - }, [configuredServers, specName, startupConfig]); + }, [configuredServers, specName, startupConfig, agentServers]); /** * For new conversations, key the MCP atom by environment (spec or defaults) diff --git a/client/src/hooks/MCP/useMCPServerManager.ts b/client/src/hooks/MCP/useMCPServerManager.ts index 160ea9ec1e6..47c9d97d429 100644 --- a/client/src/hooks/MCP/useMCPServerManager.ts +++ b/client/src/hooks/MCP/useMCPServerManager.ts @@ -65,12 +65,15 @@ export function useMCPServerManager({ conversationId, storageContextKey, specName, + agentServers, ownsChatSelection = false, observeToolAuthorization = false, }: { conversationId?: string | null; storageContextKey?: string; specName?: string | null; + /** The saved agent's switchable servers, kept selected even when the menu hides them. */ + agentServers?: string[]; /** * Opt in to managing the chat MCP selection. Most callers mount this hook for * the catalog, the server actions, or the status icons and never read the @@ -150,6 +153,7 @@ export function useMCPServerManager({ servers: selectableServers, allServers: availableMCPServers, specName, + agentServers, ownsChatSelection, }); const mcpValuesRef = useRef(mcpValues); From 1dce2f9be177f4bd5ec4e8220c1f9728b6f1aafe Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 16:26:14 +0000 Subject: [PATCH 18/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20fix:=20Clear=20th?= =?UTF-8?q?e=20Previous=20Agent's=20Tool=20Switches=20When=20Switching=20A?= =?UTF-8?q?gents=20in=20a=20New=20Chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useApplyAgentToolSwitches.test.tsx | 55 +++++++++++++- .../hooks/Agents/useApplyAgentToolSwitches.ts | 72 ++++++++++++++----- 2 files changed, 108 insertions(+), 19 deletions(-) diff --git a/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx b/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx index 888dcf0cc91..e9642c6749e 100644 --- a/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx +++ b/client/src/hooks/Agents/__tests__/useApplyAgentToolSwitches.test.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; -import { useSetRecoilState } from 'recoil'; import { Provider, createStore } from 'jotai'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { act, render, waitFor } from '@testing-library/react'; -import { LocalStorageKeys, mcpServerToggleKey } from 'librechat-data-provider'; +import { Constants, LocalStorageKeys, mcpServerToggleKey } from 'librechat-data-provider'; import type { TEphemeralAgent } from 'librechat-data-provider'; import type { Agent } from 'librechat-data-provider'; import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager'; @@ -94,6 +94,57 @@ describe('useApplyAgentToolSwitches', () => { }, ); + describe('switching agents inside one new chat', () => { + const agentX: SavedAgent = { + id: 'agent_x', + tools: ['web_search', 'echo_mcp_x-server'], + tool_options: { + web_search: { user_toggle: 'on' }, + [mcpServerToggleKey('x-server')]: { user_toggle: 'on' }, + }, + }; + let ephemeralAgent: TEphemeralAgent | null = null; + let store = createStore(); + beforeEach(() => { + ephemeralAgent = null; + store = createStore(); + }); + function Probe({ agent }: { agent: SavedAgent }) { + useApplyAgentToolSwitches({ agent, conversationId: null }); + ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(Constants.NEW_CONVO)); + return null; + } + const tree = (agent: SavedAgent) => ( + + + + + + ); + + it("shows the next agent's defaults and clears the previous agent's switches", async () => { + const { rerender } = render(tree(agentX)); + await waitFor(() => expect(ephemeralAgent).toEqual({ web_search: true, mcp: ['x-server'] })); + + rerender( + tree({ + id: 'agent_y', + tools: ['execute_code'], + tool_options: { execute_code: { user_toggle: 'off' } }, + }), + ); + await waitFor(() => expect(ephemeralAgent).toEqual({ execute_code: false, mcp: [] })); + }); + + it("clears the previous agent's switches when the next agent has none", async () => { + const { rerender } = render(tree(agentX)); + await waitFor(() => expect(ephemeralAgent).toEqual({ web_search: true, mcp: ['x-server'] })); + + rerender(tree({ id: 'agent_z', tools: ['web_search'], tool_options: {} })); + await waitFor(() => expect(ephemeralAgent).toEqual({ mcp: [] })); + }); + }); + it('keeps a default-on server the user turned off once the new chat gets its real id', async () => { const store = createStore(); let applyTemplate: (agent: TEphemeralAgent) => void = () => undefined; diff --git a/client/src/hooks/Agents/useApplyAgentToolSwitches.ts b/client/src/hooks/Agents/useApplyAgentToolSwitches.ts index 9483d35f2c9..50348b52718 100644 --- a/client/src/hooks/Agents/useApplyAgentToolSwitches.ts +++ b/client/src/hooks/Agents/useApplyAgentToolSwitches.ts @@ -1,8 +1,13 @@ import { useEffect, useRef } from 'react'; import { useAtom } from 'jotai'; import { useRecoilCallback, useSetRecoilState } from 'recoil'; -import { Constants, LocalStorageKeys, getAgentToolSwitches } from 'librechat-data-provider'; -import type { Agent, TEphemeralAgent } from 'librechat-data-provider'; +import { + Constants, + LocalStorageKeys, + getAgentToolSwitches, + switchableBuiltinTools, +} from 'librechat-data-provider'; +import type { Agent, TEphemeralAgent, SwitchableBuiltinTool } from 'librechat-data-provider'; import { ephemeralAgentByConvoId, mcpValuesAtomFamily } from '~/store'; import { applyAgentToolSwitchDefaults } from '~/utils'; import { setTimestamp } from '~/utils/timestamps'; @@ -23,7 +28,12 @@ export function useApplyAgentToolSwitches({ snapshot.getLoadable(ephemeralAgentByConvoId(convoId)).contents as TEphemeralAgent | null, [convoId], ); - const seededRef = useRef<{ convoId: string; seedKey: string } | null>(null); + const seededRef = useRef<{ + convoId: string; + seedKey: string; + builtins: SwitchableBuiltinTool[]; + servers: string[]; + } | null>(null); const agentRef = useRef(agent); agentRef.current = agent; const agentId = agent?.id; @@ -39,13 +49,38 @@ export function useApplyAgentToolSwitches({ return; } const switches = getAgentToolSwitches(current); + const switchableBuiltins = switchableBuiltinTools.filter((tool) => tool in switches.builtins); const switchableServers = Object.keys(switches.mcp); - if (Object.keys(switches.builtins).length === 0 && switchableServers.length === 0) { - return; - } const previousSeed = seededRef.current; const seedKey = `${agentId}:${switchesKey}`; - seededRef.current = { convoId, seedKey }; + seededRef.current = { + convoId, + seedKey, + builtins: switchableBuiltins, + servers: switchableServers, + }; + /** Another agent picked in this same chat leaves its switches behind; drop them. */ + const stale = + previousSeed?.convoId === convoId && previousSeed.seedKey !== seedKey ? previousSeed : null; + const withoutStale = (previous: TEphemeralAgent | null): TEphemeralAgent | null => { + if (!stale || !previous) { + return previous; + } + const next: TEphemeralAgent = { ...previous }; + for (const tool of stale.builtins) { + delete next[tool]; + } + if (Array.isArray(next.mcp)) { + next.mcp = next.mcp.filter((name) => !stale.servers.includes(name)); + } + return next; + }; + if (switchableBuiltins.length === 0 && switchableServers.length === 0) { + if (stale) { + setEphemeralAgent(withoutStale); + } + return; + } /** A new chat that just received its real id already carries the user's * choices, copied over from the submission; persist them instead of reseeding. */ const carried = @@ -65,16 +100,19 @@ export function useApplyAgentToolSwitches({ convoId, isNewConvo: convoId === Constants.NEW_CONVO, }); - setEphemeralAgent((previous) => ({ - ...previous, - ...seeded, - ...(seeded.mcp && { - mcp: [ - ...(previous?.mcp ?? []).filter((name) => !switchableServers.includes(name)), - ...seeded.mcp, - ], - }), - })); + setEphemeralAgent((current) => { + const previous = withoutStale(current); + return { + ...previous, + ...seeded, + ...(seeded.mcp && { + mcp: [ + ...(previous?.mcp ?? []).filter((name) => !switchableServers.includes(name)), + ...seeded.mcp, + ], + }), + }; + }); }, [agentId, switchesKey, convoId, setEphemeralAgent, setMCPValues, getEphemeralAgent]); /** An explicit empty list overrides the creator's default-on servers, so it needs a From cbc9a1e6c39fbcb7cd54436ada6fd9586dd1fbea Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 16:26:59 +0000 Subject: [PATCH 19/22] =?UTF-8?q?=E2=99=BF=20fix:=20Label=20the=20MCP=20Se?= =?UTF-8?q?rver=20Switch=20Setting=20for=20Server=20Names=20With=20Spaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/McpSection.userToggle.test.tsx | 19 +++++++++++++++++-- .../Tools/ItemDialog/sections/McpSection.tsx | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.userToggle.test.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.userToggle.test.tsx index 6ad20755005..30e6da12a31 100644 --- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.userToggle.test.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.userToggle.test.tsx @@ -67,7 +67,7 @@ function OptionsProbe() { return {JSON.stringify(value)}; } -function renderSection(toolOptions: AgentForm['tool_options']) { +function renderSection(toolOptions: AgentForm['tool_options'], section: McpItem = item) { function Wrapper({ children }: { children: ReactNode }) { const methods = useForm({ defaultValues: { tools: [], tool_options: toolOptions } as unknown as AgentForm, @@ -79,7 +79,7 @@ function renderSection(toolOptions: AgentForm['tool_options']) { ); } - return render(, { wrapper: Wrapper }); + return render(, { wrapper: Wrapper }); } const stored = () => JSON.parse(screen.getByTestId('options').textContent ?? 'null'); @@ -101,4 +101,19 @@ describe('McpSection user toggle', () => { fireEvent.click(option('com_ui_tool_toggle_off')); expect(stored()).toEqual({ [key]: { user_toggle: 'off' } }); }); + + it('labels the setting and keys it by the configured name for a server name with spaces', () => { + const spaced: McpItem = { + ...item, + id: 'My Docs', + name: 'My Docs', + server: { serverName: 'My Docs', isConfigured: true, tools: [], metadata: {} } as never, + }; + renderSection(undefined, spaced); + expect( + screen.getByRole('radiogroup', { name: 'com_ui_tool_toggle_label' }), + ).toBeInTheDocument(); + fireEvent.click(option('com_ui_tool_toggle_on')); + expect(stored()).toEqual({ [mcpServerToggleKey('My Docs')]: { user_toggle: 'on' } }); + }); }); diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx index c36c6c8a493..098bf5eaa5f 100644 --- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx @@ -614,7 +614,7 @@ export default function McpSection({ item }: Props) { {configDialogProps && } From cfa09e3ebe59b01e14f5fdc2f6da6cf536e0848d Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 16:29:35 +0000 Subject: [PATCH 20/22] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Drop=20the=20Unre?= =?UTF-8?q?ad=20Web=20Search=20Info-Only=20Card=20Path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SidePanel/Agents/Tools/ToolCard.tsx | 18 +++------ .../SidePanel/Agents/Tools/hooks.ts | 18 --------- .../Tools/items/__tests__/catalog.spec.ts | 40 +++++++++++++------ .../SidePanel/Agents/Tools/items/catalog.ts | 8 ---- .../SidePanel/Agents/Tools/items/types.ts | 6 --- 5 files changed, 33 insertions(+), 57 deletions(-) diff --git a/client/src/components/SidePanel/Agents/Tools/ToolCard.tsx b/client/src/components/SidePanel/Agents/Tools/ToolCard.tsx index a4397c60184..9ba4be23c2a 100644 --- a/client/src/components/SidePanel/Agents/Tools/ToolCard.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ToolCard.tsx @@ -1,6 +1,6 @@ import { memo, useState } from 'react'; import { VerifiedIcon } from '@librechat/client'; -import { Check, Globe, Info, Settings, Star, User } from 'lucide-react'; +import { Check, Globe, Settings, Star, User } from 'lucide-react'; import type { TranslationKeys } from '~/hooks/useLocalize'; import type { AgentItem } from './items/types'; import { hasConfigurableSettings } from './items/configurable'; @@ -97,12 +97,6 @@ function ToolCardImpl({ const skill = item.kind === 'skill' ? item.skill : undefined; const isPublicSkill = skill?.isPublic === true; const isSharedSkill = skill != null && skill.author !== user?.id && Boolean(skill.authorName); - const showInfoOnly = - item.kind === 'builtin' && - item.id === 'web_search' && - !canConfigure && - onConfigure !== undefined; - const DetailIcon = canConfigure ? Settings : Info; return ( /* touch:h-36 with touch:pb-9 on the content: where a coarse pointer exists the @@ -194,18 +188,16 @@ function ToolCardImpl({
) : null} - {(canFavorite || canConfigure || showInfoOnly) && ( + {(canFavorite || canConfigure) && (
- {(canConfigure || showInfoOnly) && ( + {canConfigure && ( )} {canFavorite && ( diff --git a/client/src/components/SidePanel/Agents/Tools/hooks.ts b/client/src/components/SidePanel/Agents/Tools/hooks.ts index ad78248132e..73354fa3cbc 100644 --- a/client/src/components/SidePanel/Agents/Tools/hooks.ts +++ b/client/src/components/SidePanel/Agents/Tools/hooks.ts @@ -49,21 +49,6 @@ export function useBuiltinAuthMap(): Map { }, [data, isLoading]); } -/** - * Whether `web_search` uses USER_PROVIDED auth (a user-managed key). When false - * the deployment uses SYSTEM_DEFINED keys, so there is nothing for the user to - * configure. Shares the `useBuiltinAuthMap` React Query key, so it adds no - * request. Threaded into `buildCatalog` so the card/row affordance is decided - * synchronously (cog vs info) without a per-row hook. - */ -export function useWebSearchUserProvided(): boolean { - const { data } = useVerifyAgentToolAuth({ toolId: Tools.web_search }, { retry: 1 }); - return useMemo( - () => data?.authTypes?.some(([, authType]) => authType === AuthType.USER_PROVIDED) ?? false, - [data], - ); -} - /** * Resolves whether the Memory capability should be offered in the builder. * Mirrors the legacy `AgentConfig` gate: the admin must enable the `memory` @@ -176,7 +161,6 @@ export function useAgentItems({ permission: Permissions.USE, }); const showMemory = useShowMemory(); - const webSearchUserProvided = useWebSearchUserProvided(); const builtinAuthMap = useBuiltinAuthMap(); const toolsField = useWatch({ control, name: 'tools' }); @@ -211,7 +195,6 @@ export function useAgentItems({ fileSearch: hasFileSearchAccess, }, showMemory, - webSearchUserProvided, builtinAuthMap, }), [ @@ -226,7 +209,6 @@ export function useAgentItems({ hasFileSearchAccess, skillsPermission, showMemory, - webSearchUserProvided, builtinAuthMap, ], ); diff --git a/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts b/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts index 94c0945bc2d..67062642ac3 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts @@ -1,6 +1,7 @@ import { AgentCapabilities } from 'librechat-data-provider'; import type { BuildCatalogInputs } from '../catalog'; import { makePlugin, makeSkill, makeAction } from 'test/itemFactories'; +import { deriveSelectedItems } from '../selectors'; import { buildCatalog } from '../catalog'; const emptyInputs: BuildCatalogInputs = { @@ -47,19 +48,34 @@ describe('buildCatalog', () => { expect(buildCatalog({ ...emptyInputs, showMemory: true }).find(memoryId)).toBeDefined(); }); - test('flags web_search userProvidedAuth from the webSearchUserProvided input', () => { - const findWebSearch = (inputs: BuildCatalogInputs) => - buildCatalog(inputs).find( - (i) => i.kind === 'builtin' && i.id === AgentCapabilities.web_search, - ); - const base = { - ...emptyInputs, - agentsConfig: { capabilities: [AgentCapabilities.web_search] }, + test('offers no web search item, and so no switch setting, when capabilities or the role block it', () => { + const form = { + execute_code: false, + web_search: true, + file_search: false, + memory: false, + artifacts: undefined, + tools: [], + skills: [], + context_files: [], + knowledge_files: [], + code_files: [], }; - const userProvided = findWebSearch({ ...base, webSearchUserProvided: true }); - const systemDefined = findWebSearch({ ...base, webSearchUserProvided: false }); - expect(userProvided?.kind === 'builtin' && userProvided.userProvidedAuth).toBe(true); - expect(systemDefined?.kind === 'builtin' && systemDefined.userProvidedAuth).toBe(false); + const webSearchEnabled = { capabilities: [AgentCapabilities.web_search] }; + const selectedIds = (inputs: BuildCatalogInputs) => + deriveSelectedItems(form, buildCatalog(inputs), []).map((item) => item.id); + + expect(selectedIds(emptyInputs)).toEqual([]); + expect( + selectedIds({ + ...emptyInputs, + agentsConfig: webSearchEnabled, + permissions: { ...emptyInputs.permissions, webSearch: false }, + }), + ).toEqual([]); + expect(selectedIds({ ...emptyInputs, agentsConfig: webSearchEnabled })).toEqual([ + AgentCapabilities.web_search, + ]); }); test('surfaces ask_user_question as a BUILTIN (not a plugin) when the server lists it', () => { diff --git a/client/src/components/SidePanel/Agents/Tools/items/catalog.ts b/client/src/components/SidePanel/Agents/Tools/items/catalog.ts index 6080c9c31e8..5c7093a0530 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/catalog.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/catalog.ts @@ -56,12 +56,6 @@ export interface BuildCatalogInputs { * `agentsConfig.capabilities` here. */ showMemory?: boolean; - /** - * Whether `web_search` uses USER_PROVIDED auth (a user-managed key). Drives the - * cog-vs-info affordance on the web_search card/row: configurable only when a - * user key exists; SYSTEM_DEFINED deployments have nothing to configure. - */ - webSearchUserProvided?: boolean; } interface BuiltinDef { @@ -137,8 +131,6 @@ export function buildCatalog(inputs: BuildCatalogInputs): AgentItem[] { name: def.nameKey, description: def.descriptionKey, status: inputs.builtinAuthMap?.get(def.id) === true ? 'needs_setup' : undefined, - userProvidedAuth: - def.id === AgentCapabilities.web_search ? inputs.webSearchUserProvided === true : undefined, }); } diff --git a/client/src/components/SidePanel/Agents/Tools/items/types.ts b/client/src/components/SidePanel/Agents/Tools/items/types.ts index d3c563bb0d6..785a0a73a32 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/types.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/types.ts @@ -43,12 +43,6 @@ interface ItemBase { export interface BuiltinItem extends ItemBase { kind: 'builtin'; id: BuiltinId; - /** - * True when `web_search` auth is USER_PROVIDED (a user-managed key exists to - * configure). Undefined/false means SYSTEM_DEFINED — nothing to configure, so - * the card/row shows an info icon instead of a settings cog. - */ - userProvidedAuth?: boolean; } export interface ToolItem extends ItemBase { From 26026db8cea80ad8eaa0c8f6f5c316312bb5143a Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 16:29:39 +0000 Subject: [PATCH 21/22] =?UTF-8?q?=F0=9F=A7=AA=20test:=20Cover=20the=20Buil?= =?UTF-8?q?t-In=20Tool=20Switch=20Setting=20in=20the=20Agent=20Builder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/BuiltinSection.spec.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/BuiltinSection.spec.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/BuiltinSection.spec.tsx index ccc13227cbd..e24b9f1aab4 100644 --- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/BuiltinSection.spec.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/BuiltinSection.spec.tsx @@ -109,3 +109,23 @@ describe('BuiltinSection Run Code settings', () => { expect(screen.getByLabelText('com_ui_agent_git_email')).toBeInTheDocument(); }); }); + +describe('BuiltinSection user toggle', () => { + const toggleGroup = () => screen.queryByRole('radiogroup', { name: 'com_ui_tool_toggle_label' }); + + test.each(['web_search', 'execute_code', 'file_search'])( + 'offers the chat-user switch setting for %s', + (builtinId) => { + renderSection(builtinId); + expect(toggleGroup()).toBeInTheDocument(); + }, + ); + + test.each(['memory', 'artifacts', 'context'])( + 'offers no chat-user switch setting for %s', + (builtinId) => { + renderSection(builtinId); + expect(toggleGroup()).toBeNull(); + }, + ); +}); From c3725906672ecc439026ce51512ba6738b77f660 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Tue, 29 Sep 2026 16:40:51 +0000 Subject: [PATCH 22/22] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20fix:=20Drop=20Raw?= =?UTF-8?q?-Name=20MCP=20Placeholder=20and=20Wildcard=20Tokens=20When=20a?= =?UTF-8?q?=20Normalized=20Server=20Is=20Switched=20Off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/agentToolOptions.spec.ts | 15 ++++++++++++++ .../data-provider/src/agentToolOptions.ts | 20 ++++++++++++------- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/data-provider/src/agentToolOptions.spec.ts b/packages/data-provider/src/agentToolOptions.spec.ts index d1c90dd9092..c6ae177c62b 100644 --- a/packages/data-provider/src/agentToolOptions.spec.ts +++ b/packages/data-provider/src/agentToolOptions.spec.ts @@ -199,6 +199,21 @@ describe('applyAgentToolSwitches', () => { ); expect(result.tools).toEqual([]); }); + + it('drops raw-name placeholder and wildcard tokens of a normalized server', () => { + const agent = { + tools: ['sys__server__sys_mcp_My Docs', 'sys__all__sys_mcp_My Docs', 'search_mcp_My_Docs'], + tool_options: { [mcpServerToggleKey('My Docs')]: { user_toggle: 'on' as const } }, + }; + expect(applyAgentToolSwitches(agent, { mcp: [] }).tools).toEqual([]); + expect( + applyAgentToolSwitches({ ...agent, tools: ['sys__all__sys_mcp_My Docs'] }, { mcp: [] }).tools, + ).toEqual([]); + expect(applyAgentToolSwitches(agent, { mcp: ['My Docs'] })).toEqual({ + tools: agent.tools, + mcp: ['My_Docs'], + }); + }); }); describe('pickUserToggleOptions', () => { diff --git a/packages/data-provider/src/agentToolOptions.ts b/packages/data-provider/src/agentToolOptions.ts index 82e5adb109d..759ff9a3c36 100644 --- a/packages/data-provider/src/agentToolOptions.ts +++ b/packages/data-provider/src/agentToolOptions.ts @@ -8,7 +8,7 @@ import { type AgentToolOptions, type AllowedCaller, } from './types/tools'; -import { Constants, splitMCPToolKey, normalizeServerName } from './config'; +import { Constants, splitMCPToolKey, normalizeMCPToolKey, normalizeServerName } from './config'; const actionDomainSeparatorRegex = new RegExp(actionDomainSeparator, 'g'); @@ -111,9 +111,14 @@ export function pickUserToggleOptions( } /** Tool keys carry the normalized server name, which may itself contain the MCP - * delimiter; the switch servers' key names disambiguate the split. */ -function toolServerName(tool: string, keyServerNames: string[]): string | undefined { - return splitMCPToolKey(tool, keyServerNames)[1]; + * delimiter; the switch servers' key names disambiguate the split. Placeholder and + * wildcard tokens may still carry the raw configured name, so that is normalized first. */ +function toolServerName( + tool: string, + serverNames: string[], + keyServerNames: string[], +): string | undefined { + return splitMCPToolKey(normalizeMCPToolKey(tool, serverNames), keyServerNames)[1]; } /** The creator's switchable set and defaults. Locked or unattached tools never appear. */ @@ -129,7 +134,7 @@ export function getAgentToolSwitches(agent: SwitchableAgent): AgentToolSwitches } const serverNames = getMCPSwitchServerNames(options); const keyServerNames = serverNames.map(normalizeServerName); - const attached = new Set(tools.map((tool) => toolServerName(tool, keyServerNames))); + const attached = new Set(tools.map((tool) => toolServerName(tool, serverNames, keyServerNames))); serverNames.forEach((serverName, index) => { if (attached.has(keyServerNames[index])) { switches.mcp[serverName] = options[mcpServerToggleKey(serverName)]?.user_toggle === 'on'; @@ -155,7 +160,8 @@ export function applyAgentToolSwitches( } } const requestedServers = Array.isArray(requested?.mcp) ? requested.mcp : undefined; - const keyServerNames = getMCPSwitchServerNames(agent.tool_options).map(normalizeServerName); + const serverNames = getMCPSwitchServerNames(agent.tool_options); + const keyServerNames = serverNames.map(normalizeServerName); const offServers = new Set( Object.entries(mcp) .filter( @@ -167,7 +173,7 @@ export function applyAgentToolSwitches( const kept: string[] = []; const keptServers = new Set(); for (const tool of tools) { - const serverName = toolServerName(tool, keyServerNames); + const serverName = toolServerName(tool, serverNames, keyServerNames); if (dropped.has(tool) || (serverName != null && offServers.has(serverName))) { continue; }