diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js index f5850638b25..29a76736526 100644 --- a/api/server/controllers/mcp.js +++ b/api/server/controllers/mcp.js @@ -149,6 +149,7 @@ const getMCPTools = async (req, res) => { authenticated: true, authConfig: [], tools: [], + deferLoading: serverConfig?.deferLoading === true, }; // Set authentication config once for the server diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 46971a416e2..34164589a0a 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -887,6 +887,7 @@ function createToolInstance({ }); toolInstance.mcp = true; toolInstance.mcpRawServerName = serverName; + toolInstance.mcpServerDeferLoading = capturedServerConfig?.deferLoading === true; // On Google/Vertex, propagate the union-flattened schema so definitions extracted // from this instance don't reach the Gemini converter with unsupported unions. toolInstance.mcpJsonSchema = isGoogle ? schema : parameters; diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 99054b1fca5..be9a0070053 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -791,6 +791,22 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to return result?.availableTools || null; }; + const getServerDeferLoading = async (userId, serverName) => { + try { + const serverConfig = + configServers?.[serverName] ?? + (await getMCPServersRegistry().getServerConfig(serverName, userId, configServers)); + return serverConfig?.deferLoading === true; + } catch (err) { + logger.warn( + `[Tool Definitions] MCP registry unavailable while resolving deferLoading for '${serverName}': ${ + err?.message ?? err + }. Treating as not deferred.`, + ); + return false; + } + }; + const getActionToolDefinitions = async (agentId, actionToolNames) => { const actionSets = (await loadActionSets({ agent_id: agentId })) ?? []; if (actionSets.length === 0) { @@ -864,6 +880,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to { isBuiltInTool, getOrFetchMCPServerTools, + getServerDeferLoading, getActionToolDefinitions, }, ); @@ -946,6 +963,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to { isBuiltInTool, getOrFetchMCPServerTools, + getServerDeferLoading, getActionToolDefinitions, }, ); diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index b0d74374b41..5a66a6b3501 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -98,6 +98,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) isConnected: connectionStatus?.[serverName]?.connectionState === 'connected', metadata, consumeOnly: serverConfig?.consumeOnly, + deferLoading: serverData.deferLoading ?? serverConfig?.deferLoading, }); } } @@ -128,6 +129,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) serverName: mcpServerName, isConnected: connectionStatus?.[mcpServerName]?.connectionState === 'connected', consumeOnly: serverConfig?.consumeOnly, + deferLoading: serverConfig?.deferLoading, }); } diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 9cdebffd2bf..e028bf777a9 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -212,6 +212,8 @@ export interface MCPServerInfo { isConfigured: boolean; isConnected: boolean; consumeOnly?: boolean; + /** Server-level default: when true, this server's tools defer by default */ + deferLoading?: boolean; metadata: t.TPlugin; } diff --git a/client/src/components/SidePanel/Agents/MCPTool.tsx b/client/src/components/SidePanel/Agents/MCPTool.tsx index 127fe55e609..99957e97864 100644 --- a/client/src/components/SidePanel/Agents/MCPTool.tsx +++ b/client/src/components/SidePanel/Agents/MCPTool.tsx @@ -60,6 +60,7 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) const currentServerName = serverInfo.serverName; const tools = serverInfo.tools || []; + const serverDefaultDefer = serverInfo.deferLoading ?? false; const getSelectedTools = () => { const formTools = getValues('tools') || []; @@ -82,7 +83,7 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) const selectedTools = getSelectedTools(); const isExpanded = accordionValue === currentServerName; - const allDeferred = areAllToolsDeferred(tools); + const allDeferred = areAllToolsDeferred(tools, serverDefaultDefer); const allProgrammatic = areAllToolsProgrammatic(tools); const statusIconProps = getServerStatusIconProps(currentServerName); @@ -213,13 +214,13 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) )} onClick={(e) => { e.stopPropagation(); - toggleDeferAll(tools); + toggleDeferAll(tools, serverDefaultDefer); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); - toggleDeferAll(tools); + toggleDeferAll(tools, serverDefaultDefer); } }} > @@ -328,12 +329,14 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) key={tool.tool_id} tool={tool} isSelected={selectedTools.includes(tool.tool_id)} - isDeferred={deferredToolsEnabled && isToolDeferred(tool.tool_id)} + isDeferred={ + deferredToolsEnabled && isToolDeferred(tool.tool_id, serverDefaultDefer) + } isProgrammatic={programmaticToolsEnabled && isToolProgrammatic(tool.tool_id)} deferredToolsEnabled={deferredToolsEnabled} programmaticToolsEnabled={programmaticToolsEnabled} onToggleSelect={() => toggleToolSelect(tool.tool_id)} - onToggleDefer={() => toggleToolDefer(tool.tool_id)} + onToggleDefer={() => toggleToolDefer(tool.tool_id, serverDefaultDefer)} onToggleProgrammatic={() => toggleToolProgrammatic(tool.tool_id)} /> ))} diff --git a/client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts b/client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts index caba94016ff..3d245295323 100644 --- a/client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts +++ b/client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts @@ -632,6 +632,128 @@ describe('useMCPToolOptions', () => { }); }); + describe('server-level deferLoading (three-state)', () => { + it('isToolDeferred inherits the server default when there is no explicit option', () => { + (useWatch as jest.Mock).mockReturnValue({}); + + const { result } = renderHook(() => useMCPToolOptions()); + + expect(result.current.isToolDeferred('tool1', true)).toBe(true); + expect(result.current.isToolDeferred('tool1', false)).toBe(false); + }); + + it('isToolDeferred lets an explicit false override an inherited true', () => { + (useWatch as jest.Mock).mockReturnValue({ + tool1: { defer_loading: false }, + }); + + const { result } = renderHook(() => useMCPToolOptions()); + + expect(result.current.isToolDeferred('tool1', true)).toBe(false); + }); + + it('isToolDeferred lets an explicit true override an inherited false', () => { + (useWatch as jest.Mock).mockReturnValue({ + tool1: { defer_loading: true }, + }); + + const { result } = renderHook(() => useMCPToolOptions()); + + expect(result.current.isToolDeferred('tool1', false)).toBe(true); + }); + + it('toggleToolDefer writes an explicit false when turning off an inherited-true tool', () => { + mockGetValues.mockReturnValue({}); + + const { result } = renderHook(() => useMCPToolOptions()); + + act(() => { + result.current.toggleToolDefer('tool1', true); + }); + + expect(mockSetValue).toHaveBeenCalledWith( + 'tool_options', + { tool1: { defer_loading: false } }, + { shouldDirty: true }, + ); + }); + + it('toggleToolDefer deletes the explicit key when toggling back to match the server default', () => { + mockGetValues.mockReturnValue({ + tool1: { defer_loading: false }, + }); + + const { result } = renderHook(() => useMCPToolOptions()); + + act(() => { + result.current.toggleToolDefer('tool1', true); + }); + + expect(mockSetValue).toHaveBeenCalledWith('tool_options', {}, { shouldDirty: true }); + }); + + it('areAllToolsDeferred honors inheritance from the server default', () => { + (useWatch as jest.Mock).mockReturnValue({ + tool1: { defer_loading: false }, + }); + + const { result } = renderHook(() => useMCPToolOptions()); + const tools = [createMockTool('tool1'), createMockTool('tool2')]; + + expect(result.current.areAllToolsDeferred(tools, true)).toBe(false); + }); + + it('areAllToolsDeferred returns true when every tool inherits a true server default', () => { + (useWatch as jest.Mock).mockReturnValue({}); + + const { result } = renderHook(() => useMCPToolOptions()); + const tools = [createMockTool('tool1'), createMockTool('tool2')]; + + expect(result.current.areAllToolsDeferred(tools, true)).toBe(true); + }); + + it('toggleDeferAll collapses to explicit false then back to the server default', () => { + (useWatch as jest.Mock).mockReturnValue({}); + mockGetValues.mockReturnValue({}); + + const { result } = renderHook(() => useMCPToolOptions()); + const tools = [createMockTool('tool1'), createMockTool('tool2')]; + + act(() => { + result.current.toggleDeferAll(tools, true); + }); + + expect(mockSetValue).toHaveBeenCalledWith( + 'tool_options', + { + tool1: { defer_loading: false }, + tool2: { defer_loading: false }, + }, + { shouldDirty: true }, + ); + }); + + it('toggleDeferAll deletes explicit keys when bringing tools back to a true server default', () => { + (useWatch as jest.Mock).mockReturnValue({ + tool1: { defer_loading: false }, + tool2: { defer_loading: false }, + }); + mockGetValues.mockReturnValue({ + tool1: { defer_loading: false }, + tool2: { defer_loading: false }, + }); + + const { result } = renderHook(() => useMCPToolOptions()); + const tools = [createMockTool('tool1'), createMockTool('tool2')]; + + act(() => { + result.current.toggleDeferAll(tools, true); + }); + + expect(mockSetValue).toHaveBeenCalledWith('tool_options', {}, { shouldDirty: true }); + }); + }); + describe('formToolOptions', () => { it('should return undefined when useWatch returns undefined', () => { (useWatch as jest.Mock).mockReturnValue(undefined); diff --git a/client/src/hooks/Agents/useMCPToolOptions.ts b/client/src/hooks/Agents/useMCPToolOptions.ts index 68cfb8f91b2..3e78801d38a 100644 --- a/client/src/hooks/Agents/useMCPToolOptions.ts +++ b/client/src/hooks/Agents/useMCPToolOptions.ts @@ -5,13 +5,13 @@ import type { AgentForm } from '~/common'; interface UseMCPToolOptionsReturn { formToolOptions: AgentToolOptions | undefined; - isToolDeferred: (toolId: string) => boolean; + isToolDeferred: (toolId: string, serverDefault?: boolean) => boolean; isToolProgrammatic: (toolId: string) => boolean; - toggleToolDefer: (toolId: string) => void; + toggleToolDefer: (toolId: string, serverDefault?: boolean) => void; toggleToolProgrammatic: (toolId: string) => void; - areAllToolsDeferred: (tools: AgentToolType[]) => boolean; + areAllToolsDeferred: (tools: AgentToolType[], serverDefault?: boolean) => boolean; areAllToolsProgrammatic: (tools: AgentToolType[]) => boolean; - toggleDeferAll: (tools: AgentToolType[]) => void; + toggleDeferAll: (tools: AgentToolType[], serverDefault?: boolean) => void; toggleProgrammaticAll: (tools: AgentToolType[]) => void; } @@ -20,7 +20,8 @@ export default function useMCPToolOptions(): UseMCPToolOptionsReturn { const formToolOptions = useWatch({ control, name: 'tool_options' }); const isToolDeferred = useCallback( - (toolId: string): boolean => formToolOptions?.[toolId]?.defer_loading === true, + (toolId: string, serverDefault = false): boolean => + formToolOptions?.[toolId]?.defer_loading ?? serverDefault, [formToolOptions], ); @@ -31,25 +32,26 @@ export default function useMCPToolOptions(): UseMCPToolOptionsReturn { ); const toggleToolDefer = useCallback( - (toolId: string) => { + (toolId: string, serverDefault = false) => { const currentOptions = getValues('tool_options') || {}; const currentToolOptions = currentOptions[toolId] || {}; - const newDeferred = !currentToolOptions.defer_loading; + const effective = currentToolOptions.defer_loading ?? serverDefault; + const target = !effective; const updatedOptions: AgentToolOptions = { ...currentOptions }; - if (newDeferred) { - updatedOptions[toolId] = { - ...currentToolOptions, - defer_loading: true, - }; - } else { + if (target === serverDefault) { const { defer_loading: _, ...restOptions } = currentToolOptions; if (Object.keys(restOptions).length === 0) { delete updatedOptions[toolId]; } else { updatedOptions[toolId] = restOptions; } + } else { + updatedOptions[toolId] = { + ...currentToolOptions, + defer_loading: target, + }; } setValue('tool_options', updatedOptions, { shouldDirty: true }); @@ -94,9 +96,11 @@ export default function useMCPToolOptions(): UseMCPToolOptionsReturn { ); const areAllToolsDeferred = useCallback( - (tools: AgentToolType[]): boolean => + (tools: AgentToolType[], serverDefault = false): boolean => tools.length > 0 && - tools.every((tool) => formToolOptions?.[tool.tool_id]?.defer_loading === true), + tools.every( + (tool) => (formToolOptions?.[tool.tool_id]?.defer_loading ?? serverDefault) === true, + ), [formToolOptions], ); @@ -111,26 +115,26 @@ export default function useMCPToolOptions(): UseMCPToolOptionsReturn { ); const toggleDeferAll = useCallback( - (tools: AgentToolType[]) => { + (tools: AgentToolType[], serverDefault = false) => { if (tools.length === 0) return; - const shouldDefer = !areAllToolsDeferred(tools); + const target = !areAllToolsDeferred(tools, serverDefault); const currentOptions = getValues('tool_options') || {}; const updatedOptions: AgentToolOptions = { ...currentOptions }; for (const tool of tools) { - if (shouldDefer) { - updatedOptions[tool.tool_id] = { - ...(updatedOptions[tool.tool_id] || {}), - defer_loading: true, - }; - } else { + if (target === serverDefault) { if (updatedOptions[tool.tool_id]) { delete updatedOptions[tool.tool_id].defer_loading; if (Object.keys(updatedOptions[tool.tool_id]).length === 0) { delete updatedOptions[tool.tool_id]; } } + } else { + updatedOptions[tool.tool_id] = { + ...(updatedOptions[tool.tool_id] || {}), + defer_loading: target, + }; } } diff --git a/librechat.example.yaml b/librechat.example.yaml index 968723925d1..47b7ae0572c 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -355,6 +355,10 @@ actions: # url: http://localhost:3001/sse # # proxy: "${MCP_PROXY_URL}" # optional outbound proxy (http/https/socks/socks5) # timeout: 60000 # 1 minute timeout for this server, this is the default timeout for MCP servers. +# # deferLoading: true # Defer every tool from this server by default: the model receives the +# # # `tool_search` tool plus a name-only listing instead of each tool's full +# # # schema. Saves context on large tool sets. A per-agent tool toggle overrides +# # # this. Requires the `deferred_tools` agent capability to be enabled. # puppeteer: # type: stdio # command: npx diff --git a/packages/api/src/mcp/registry/MCPServersRegistry.ts b/packages/api/src/mcp/registry/MCPServersRegistry.ts index 06e583608a3..0e2e8081891 100644 --- a/packages/api/src/mcp/registry/MCPServersRegistry.ts +++ b/packages/api/src/mcp/registry/MCPServersRegistry.ts @@ -42,6 +42,7 @@ const ADMIN_CONFIGURABLE_FIELDS = [ 'startup', 'chatMenu', 'serverInstructions', + 'deferLoading', 'customUserVars', 'timeout', 'sseReadTimeout', diff --git a/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts b/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts index 933ff7cec2a..75a5aaa3cbd 100644 --- a/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts +++ b/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts @@ -153,6 +153,22 @@ describe('MCPServersRegistry — ensureConfigServers', () => { expect(inspectSpy).toHaveBeenCalledTimes(1); }); + it('should lazy-init YAML server when admin overrides only the deferLoading field', async () => { + await registry.addServer('yaml_remote', sseConfig, 'CACHE'); + inspectSpy.mockClear(); + + const overrideConfig: t.MCPOptions = { + ...sseConfig, + deferLoading: true, + } as t.MCPOptions; + const result = await registry.ensureConfigServers({ + yaml_remote: overrideConfig, + }); + + expect(result).toHaveProperty('yaml_remote'); + expect(inspectSpy).toHaveBeenCalledTimes(1); + }); + it('should not re-init YAML server when only the difference is an inspector-derived field absent from rawConfig', async () => { const yamlWithInferred: t.MCPOptions = { ...sseConfig, diff --git a/packages/api/src/tools/classification.spec.ts b/packages/api/src/tools/classification.spec.ts index 66cdfd46114..d3279abe15f 100644 --- a/packages/api/src/tools/classification.spec.ts +++ b/packages/api/src/tools/classification.spec.ts @@ -2,6 +2,7 @@ import type { AgentToolOptions } from 'librechat-data-provider'; import type { GenericTool } from '@librechat/agents'; import type { LCToolRegistry } from './classification'; import { + extractMCPToolDefinition, buildToolRegistryFromAgentOptions, agentHasProgrammaticTools, buildToolClassification, @@ -436,4 +437,145 @@ describe('classification.ts', () => { expect(result.additionalTools.some((t) => t.name === 'tool_search')).toBe(true); }); }); + + describe('server-level deferLoading', () => { + const TOOL_A = 'list_files_mcp_serverX'; + const TOOL_B = 'read_file_mcp_serverY'; + + const createMCPTool = (name: string, serverDeferLoading?: boolean) => + ({ + name, + mcp: true, + mcpJsonSchema: { type: 'object', properties: {} }, + ...(serverDeferLoading ? { mcpServerDeferLoading: true } : {}), + }) as unknown as GenericTool; + + describe('extractMCPToolDefinition', () => { + it('case 9: carries the mcpServerDeferLoading stamp onto the definition', () => { + const def = extractMCPToolDefinition({ name: TOOL_A, mcpServerDeferLoading: true }); + expect(def.serverDeferLoading).toBe(true); + expect(def.serverName).toBe('serverX'); + }); + + it('case 9: omits serverDeferLoading when the tool is not stamped', () => { + const def = extractMCPToolDefinition({ name: TOOL_A }); + expect(def.serverDeferLoading).toBeUndefined(); + }); + }); + + describe('buildToolRegistryFromAgentOptions (saved-agent path)', () => { + it('case 1: server default applies when there is no per-tool option', () => { + const tools = [{ name: TOOL_A, description: 'A', serverDeferLoading: true }]; + const registry = buildToolRegistryFromAgentOptions(tools, {}); + + expect(registry.get(TOOL_A)?.defer_loading).toBe(true); + expect(agentHasDeferredTools(registry)).toBe(true); + }); + + it('case 3: explicit per-agent false overrides server true', () => { + const tools = [{ name: TOOL_A, serverDeferLoading: true }]; + const agentToolOptions: AgentToolOptions = { [TOOL_A]: { defer_loading: false } }; + const registry = buildToolRegistryFromAgentOptions(tools, agentToolOptions); + + expect(registry.get(TOOL_A)?.defer_loading).toBe(false); + }); + + it('case 4: explicit per-agent true with no server default', () => { + const tools = [{ name: TOOL_A }]; + const agentToolOptions: AgentToolOptions = { [TOOL_A]: { defer_loading: true } }; + const registry = buildToolRegistryFromAgentOptions(tools, agentToolOptions); + + expect(registry.get(TOOL_A)?.defer_loading).toBe(true); + }); + + it('case 5: no server default and no per-agent option resolves to false', () => { + const tools = [{ name: TOOL_A }]; + const registry = buildToolRegistryFromAgentOptions(tools, {}); + + expect(registry.get(TOOL_A)?.defer_loading).toBe(false); + }); + + it('case 6: mixed servers - only the stamped tool defers', () => { + const tools = [{ name: TOOL_A, serverDeferLoading: true }, { name: TOOL_B }]; + const registry = buildToolRegistryFromAgentOptions(tools, {}); + + expect(registry.get(TOOL_A)?.defer_loading).toBe(true); + expect(registry.get(TOOL_B)?.defer_loading).toBe(false); + }); + }); + + describe('buildToolClassification (ephemeral / event-driven path)', () => { + it('case 2 (headline): server default defers via the fallback branch when agentToolOptions is undefined', async () => { + const loadedTools: GenericTool[] = [createMCPTool(TOOL_A, true)]; + + const result = await buildToolClassification({ + loadedTools, + userId: 'user1', + agentId: 'agent1', + deferredToolsEnabled: true, + }); + + expect(result.toolRegistry?.get(TOOL_A)?.defer_loading).toBe(true); + expect(result.hasDeferredTools).toBe(true); + }); + + it('case 8: tool_search is injected when only a server default is present', async () => { + const loadedTools: GenericTool[] = [createMCPTool(TOOL_A, true)]; + + const result = await buildToolClassification({ + loadedTools, + userId: 'user1', + agentId: 'agent1', + deferredToolsEnabled: true, + }); + + expect(result.additionalTools.some((t) => t.name === 'tool_search')).toBe(true); + }); + + it('case 6: mixed servers - only the stamped tool defers', async () => { + const loadedTools: GenericTool[] = [createMCPTool(TOOL_A, true), createMCPTool(TOOL_B)]; + + const result = await buildToolClassification({ + loadedTools, + userId: 'user1', + agentId: 'agent1', + deferredToolsEnabled: true, + }); + + expect(result.toolRegistry?.get(TOOL_A)?.defer_loading).toBe(true); + expect(result.toolRegistry?.get(TOOL_B)?.defer_loading).toBe(false); + }); + + it('case 7: capability off clears the server-defaulted defer and skips tool_search', async () => { + const loadedTools: GenericTool[] = [createMCPTool(TOOL_A, true)]; + + const result = await buildToolClassification({ + loadedTools, + userId: 'user1', + agentId: 'agent1', + deferredToolsEnabled: false, + }); + + expect(result.toolRegistry?.get(TOOL_A)?.defer_loading).toBe(false); + expect(result.hasDeferredTools).toBe(false); + expect(result.additionalTools.some((t) => t.name === 'tool_search')).toBe(false); + }); + + it('case 3: explicit per-agent false overrides the server default stamp', async () => { + const loadedTools: GenericTool[] = [createMCPTool(TOOL_A, true)]; + const agentToolOptions: AgentToolOptions = { [TOOL_A]: { defer_loading: false } }; + + const result = await buildToolClassification({ + loadedTools, + userId: 'user1', + agentId: 'agent1', + agentToolOptions, + deferredToolsEnabled: true, + }); + + expect(result.toolRegistry?.get(TOOL_A)?.defer_loading).toBe(false); + expect(result.hasDeferredTools).toBe(false); + }); + }); + }); }); diff --git a/packages/api/src/tools/classification.ts b/packages/api/src/tools/classification.ts index eaf4ff09531..36dc2324457 100644 --- a/packages/api/src/tools/classification.ts +++ b/packages/api/src/tools/classification.ts @@ -30,6 +30,8 @@ export interface ToolDefinition { parameters?: JsonSchemaType; /** MCP server name extracted from tool name */ serverName?: string; + /** Server-level deferLoading default for this tool's MCP server. */ + serverDeferLoading?: boolean; } /** @@ -68,7 +70,10 @@ export function buildToolRegistryFromAgentOptions( ? agentOptions.allowed_callers : ['direct']; - const defer_loading = agentOptions?.defer_loading === true; + const defer_loading = + agentOptions?.defer_loading !== undefined + ? agentOptions.defer_loading + : tool.serverDeferLoading === true; const toolDef: LCTool = { name, @@ -99,6 +104,8 @@ interface MCPToolInstance { mcp?: boolean; /** Original JSON schema attached at MCP tool creation time */ mcpJsonSchema?: JsonSchemaType; + /** Server-level deferLoading default, stamped at tool creation time */ + mcpServerDeferLoading?: boolean; } /** @@ -124,6 +131,10 @@ export function extractMCPToolDefinition(tool: MCPToolInstance): ToolDefinition def.serverName = serverName; } + if (tool.mcpServerDeferLoading) { + def.serverDeferLoading = true; + } + return def; } @@ -167,6 +178,7 @@ function buildToolRegistry( description: toolDef.description, parameters: toolDef.parameters, serverName: toolDef.serverName, + defer_loading: toolDef.serverDeferLoading === true, toolType: 'mcp', }); } diff --git a/packages/api/src/tools/definitions.spec.ts b/packages/api/src/tools/definitions.spec.ts index 316bfcf44de..c9c3c4d99b3 100644 --- a/packages/api/src/tools/definitions.spec.ts +++ b/packages/api/src/tools/definitions.spec.ts @@ -827,5 +827,120 @@ describe('definitions.ts', () => { expect(registryEntry?.allowed_callers).toContain('direct'); }); }); + + describe('server-level deferLoading (event-driven parity)', () => { + const mcpAllTool = 'sys__all__sys_mcp_serverX'; + const serverTools = { + list_files_mcp_serverX: { + function: { + name: 'list_files_mcp_serverX', + description: 'List files', + parameters: { type: 'object', properties: {} }, + }, + }, + read_file_mcp_serverX: { + function: { + name: 'read_file_mcp_serverX', + description: 'Read a file', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + + it("defers all of a server's tools and injects tool_search when getServerDeferLoading returns true and there are no per-agent options", async () => { + mockGetOrFetchMCPServerTools.mockResolvedValue(serverTools); + const mockGetServerDeferLoading = jest.fn().mockResolvedValue(true); + + const result = await loadToolDefinitions( + { + userId: 'user-123', + agentId: 'agent-123', + tools: [mcpAllTool], + deferredToolsEnabled: true, + }, + { + getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools, + getServerDeferLoading: mockGetServerDeferLoading, + isBuiltInTool: mockIsBuiltInTool, + }, + ); + + expect(mockGetServerDeferLoading).toHaveBeenCalledWith('user-123', 'serverX'); + expect(result.hasDeferredTools).toBe(true); + expect(result.toolRegistry.get('list_files_mcp_serverX')?.defer_loading).toBe(true); + expect(result.toolRegistry.get('read_file_mcp_serverX')?.defer_loading).toBe(true); + expect(result.toolDefinitions.some((d) => d.name === 'tool_search')).toBe(true); + expect(result.toolRegistry.has('tool_search')).toBe(true); + }); + + it('does not defer when getServerDeferLoading returns false', async () => { + mockGetOrFetchMCPServerTools.mockResolvedValue(serverTools); + const mockGetServerDeferLoading = jest.fn().mockResolvedValue(false); + + const result = await loadToolDefinitions( + { + userId: 'user-123', + agentId: 'agent-123', + tools: [mcpAllTool], + deferredToolsEnabled: true, + }, + { + getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools, + getServerDeferLoading: mockGetServerDeferLoading, + isBuiltInTool: mockIsBuiltInTool, + }, + ); + + expect(result.hasDeferredTools).toBe(false); + expect(result.toolRegistry.get('list_files_mcp_serverX')?.defer_loading).toBe(false); + expect(result.toolDefinitions.some((d) => d.name === 'tool_search')).toBe(false); + }); + + it('lets an explicit per-agent false override the server default', async () => { + mockGetOrFetchMCPServerTools.mockResolvedValue(serverTools); + const mockGetServerDeferLoading = jest.fn().mockResolvedValue(true); + + const result = await loadToolDefinitions( + { + userId: 'user-123', + agentId: 'agent-123', + tools: [mcpAllTool], + deferredToolsEnabled: true, + toolOptions: { + list_files_mcp_serverX: { defer_loading: false }, + }, + }, + { + getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools, + getServerDeferLoading: mockGetServerDeferLoading, + isBuiltInTool: mockIsBuiltInTool, + }, + ); + + expect(result.toolRegistry.get('list_files_mcp_serverX')?.defer_loading).toBe(false); + expect(result.toolRegistry.get('read_file_mcp_serverX')?.defer_loading).toBe(true); + expect(result.hasDeferredTools).toBe(true); + }); + + it('treats a missing getServerDeferLoading dep as not deferred', async () => { + mockGetOrFetchMCPServerTools.mockResolvedValue(serverTools); + + const result = await loadToolDefinitions( + { + userId: 'user-123', + agentId: 'agent-123', + tools: [mcpAllTool], + deferredToolsEnabled: true, + }, + { + getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools, + isBuiltInTool: mockIsBuiltInTool, + }, + ); + + expect(result.hasDeferredTools).toBe(false); + expect(result.toolRegistry.get('list_files_mcp_serverX')?.defer_loading).toBe(false); + }); + }); }); }); diff --git a/packages/api/src/tools/definitions.ts b/packages/api/src/tools/definitions.ts index 4babe3ed81f..c313fe9be8b 100644 --- a/packages/api/src/tools/definitions.ts +++ b/packages/api/src/tools/definitions.ts @@ -53,6 +53,8 @@ export interface ActionToolDefinition { export interface LoadToolDefinitionsDeps { /** Gets MCP server tools - first checks cache, then initializes server if needed */ getOrFetchMCPServerTools: (userId: string, serverName: string) => Promise; + /** Resolves the server-level `deferLoading` default from the merged MCP config */ + getServerDeferLoading?: (userId: string, serverName: string) => Promise; /** Checks if a tool name is a known built-in tool */ isBuiltInTool: (toolName: string) => boolean; /** Loads action tool definitions (schemas) from OpenAPI specs */ @@ -88,7 +90,12 @@ export async function loadToolDefinitions( codeExecutionEnabled = false, provider, } = params; - const { getOrFetchMCPServerTools, isBuiltInTool, getActionToolDefinitions } = deps; + const { + getOrFetchMCPServerTools, + getServerDeferLoading, + isBuiltInTool, + getActionToolDefinitions, + } = deps; const isGoogle = provider === Providers.GOOGLE || provider === Providers.VERTEXAI; @@ -112,6 +119,7 @@ export async function loadToolDefinitions( } const mcpServerToolsCache = new Map(); + const mcpServerDeferLoadingCache = new Map(); const mcpToolDefs: ToolDefinition[] = []; const builtInToolDefs: ToolDefinition[] = []; let actionToolDefs: ToolDefinition[] = []; @@ -161,6 +169,10 @@ export async function loadToolDefinitions( if (!mcpServerToolsCache.has(serverName)) { const serverTools = await getOrFetchMCPServerTools(userId, serverName); mcpServerToolsCache.set(serverName, serverTools || {}); + const serverDeferLoading = getServerDeferLoading + ? await getServerDeferLoading(userId, serverName) + : false; + mcpServerDeferLoadingCache.set(serverName, serverDeferLoading); } const serverTools = mcpServerToolsCache.get(serverName); @@ -168,6 +180,8 @@ export async function loadToolDefinitions( continue; } + const serverDeferLoading = mcpServerDeferLoadingCache.get(serverName) === true; + if (toolName.startsWith(mcpAllPattern)) { for (const [actualToolName, toolDef] of Object.entries(serverTools)) { if (toolDef?.function) { @@ -176,6 +190,7 @@ export async function loadToolDefinitions( description: toolDef.function.description || undefined, parameters: buildMcpParameters(toolDef.function.parameters), serverName, + serverDeferLoading, }); } } @@ -189,6 +204,7 @@ export async function loadToolDefinitions( description: toolDef.function.description || undefined, parameters: buildMcpParameters(toolDef.function.parameters), serverName, + serverDeferLoading, }); } } @@ -207,6 +223,7 @@ export async function loadToolDefinitions( description: def.description, mcp: true as const, mcpJsonSchema: def.parameters, + mcpServerDeferLoading: def.serverDeferLoading, })) as unknown as GenericTool[]; const classificationResult = await buildToolClassification({ diff --git a/packages/data-provider/src/mcp.spec.ts b/packages/data-provider/src/mcp.spec.ts new file mode 100644 index 00000000000..a02b182e729 --- /dev/null +++ b/packages/data-provider/src/mcp.spec.ts @@ -0,0 +1,38 @@ +import { StdioOptionsSchema, SSEOptionsSchema, MCPOptionsSchema } from './mcp'; + +describe('mcp schema - deferLoading', () => { + const baseStdio = { command: 'node', args: ['server.js'] }; + + it('parses deferLoading: true on a stdio server', () => { + const result = StdioOptionsSchema.parse({ ...baseStdio, deferLoading: true }); + expect(result.deferLoading).toBe(true); + }); + + it('parses deferLoading: false on a stdio server', () => { + const result = StdioOptionsSchema.parse({ ...baseStdio, deferLoading: false }); + expect(result.deferLoading).toBe(false); + }); + + it('treats deferLoading as optional (absent -> undefined)', () => { + const result = StdioOptionsSchema.parse(baseStdio); + expect(result.deferLoading).toBeUndefined(); + }); + + it('rejects a non-boolean deferLoading', () => { + const result = StdioOptionsSchema.safeParse({ ...baseStdio, deferLoading: 'yes' }); + expect(result.success).toBe(false); + }); + + it('parses deferLoading on a remote (SSE) server', () => { + const result = SSEOptionsSchema.parse({ + url: 'https://example.com/sse', + deferLoading: true, + }); + expect(result.deferLoading).toBe(true); + }); + + it('parses deferLoading through the MCPOptions union', () => { + const result = MCPOptionsSchema.parse({ ...baseStdio, deferLoading: true }); + expect(result.deferLoading).toBe(true); + }); +}); diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index bf9953d51c6..562e5763e07 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -183,6 +183,14 @@ const BaseOptionsSchema = z.object({ * - string: Use custom instructions (overrides server-provided) */ serverInstructions: z.union([z.boolean(), z.string()]).optional(), + /** + * When true, every tool from this MCP server defaults to deferred loading: + * the model receives the `tool_search` tool plus a name-only listing instead + * of each tool's full schema. Sets the default for each tool's `defer_loading`; + * a per-agent `tool_options[toolId].defer_loading` (true OR false) overrides it. + * Requires the `deferred_tools` agent capability to be enabled. + */ + deferLoading: z.boolean().optional(), /** * Whether this server requires OAuth authentication * If not specified, will be auto-detected during construction diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index abe773fce2d..c324d290128 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -128,6 +128,8 @@ export type MCPServer = { authenticated: boolean; authConfig: s.TPluginAuthConfig[]; tools: MCPTool[]; + /** Server-level default: when true, all tools from this server defer by default */ + deferLoading?: boolean; }; export type MCPServersResponse = {