Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/server/controllers/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ const getMCPTools = async (req, res) => {
authenticated: true,
authConfig: [],
tools: [],
deferLoading: serverConfig?.deferLoading === true,
};

// Set authentication config once for the server
Expand Down
1 change: 1 addition & 0 deletions api/server/services/MCP.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions api/server/services/ToolService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -864,6 +880,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
{
isBuiltInTool,
getOrFetchMCPServerTools,
getServerDeferLoading,
getActionToolDefinitions,
},
);
Expand Down Expand Up @@ -946,6 +963,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
{
isBuiltInTool,
getOrFetchMCPServerTools,
getServerDeferLoading,
getActionToolDefinitions,
},
);
Expand Down
2 changes: 2 additions & 0 deletions client/src/Providers/AgentPanelContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
}
Expand Down Expand Up @@ -128,6 +129,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
serverName: mcpServerName,
isConnected: connectionStatus?.[mcpServerName]?.connectionState === 'connected',
consumeOnly: serverConfig?.consumeOnly,
deferLoading: serverConfig?.deferLoading,
});
}

Expand Down
2 changes: 2 additions & 0 deletions client/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
13 changes: 8 additions & 5 deletions client/src/components/SidePanel/Agents/MCPTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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') || [];
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
}}
>
Expand Down Expand Up @@ -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)}
/>
))}
Expand Down
122 changes: 122 additions & 0 deletions client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
50 changes: 27 additions & 23 deletions client/src/hooks/Agents/useMCPToolOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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],
);

Expand All @@ -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 });
Expand Down Expand Up @@ -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],
);

Expand All @@ -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,
};
}
}

Expand Down
4 changes: 4 additions & 0 deletions librechat.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/mcp/registry/MCPServersRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const ADMIN_CONFIGURABLE_FIELDS = [
'startup',
'chatMenu',
'serverInstructions',
'deferLoading',
'customUserVars',
'timeout',
'sseReadTimeout',
Expand Down
Loading
Loading