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
9 changes: 9 additions & 0 deletions api/server/controllers/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const {
getMCPErrorResponse,
prepareMCPServerOAuthDeletion,
cleanupDeletedMCPServerOAuthUsers,
resolveDeferLoading,
} = require('@librechat/api');
const {
Constants,
Expand Down Expand Up @@ -157,6 +158,7 @@ const getMCPTools = async (req, res) => {
}

const mcpServers = {};
const deferSchemaChars = req.config?.mcpSettings?.deferSchemaChars ?? 0;
const oboIdentityContext = createAuthIdentityContext({
user: req.user,
tenantId: getTenantId(),
Expand Down Expand Up @@ -251,6 +253,13 @@ const getMCPTools = async (req, res) => {
* server-name prefix — the agent editor migrates legacy
* persisted ids only when this proves the same tool. */
...(toolData.serverToolName != null && { serverToolName: toolData.serverToolName }),
...(resolveDeferLoading(
undefined,
toolData.function.parameters,
deferSchemaChars,
) && {
deferredBySize: true,
}),
});
}
}
Expand Down
32 changes: 32 additions & 0 deletions api/server/routes/__tests__/mcp.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3591,6 +3591,38 @@ describe('MCP Routes', () => {
});
});

it('marks the tools the schema size rule defers', async () => {
const { Constants } = require('librechat-data-provider');
const small = `small${Constants.mcp_delimiter}user-server`;
const large = `large${Constants.mcp_delimiter}user-server`;
const tool = (name, parameters) => ({
type: 'function',
function: { name, description: name, parameters },
});
const serverTools = {
[small]: tool(small, { type: 'object' }),
[large]: tool(large, {
type: 'object',
properties: { body: { type: 'string', description: 'x'.repeat(500) } },
}),
};
mockRequestConfig = { mcpSettings: { deferSchemaChars: 200 } };
mockResolveAllMcpConfigs.mockResolvedValueOnce({
'user-server': { type: 'sse', url: 'https://user.example.com/sse' },
});
mockLoadMCPServerCatalogs.mockResolvedValueOnce({
serverTools: new Map([['user-server', serverTools]]),
serversWithoutTools: [],
});

const response = await request(app).get('/api/mcp/tools');

expect(response.status).toBe(200);
const tools = response.body.servers['user-server'].tools;
expect(tools.find((t) => t.pluginKey === large).deferredBySize).toBe(true);
expect(tools.find((t) => t.pluginKey === small)).not.toHaveProperty('deferredBySize');
});

it('renders an authoritative empty catalog as a configured server', async () => {
const serverConfig = { type: 'sse', url: 'https://empty.example.com/sse' };
mockResolveAllMcpConfigs.mockResolvedValueOnce({ 'empty-server': serverConfig });
Expand Down
3 changes: 3 additions & 0 deletions api/server/services/ToolService.js
Original file line number Diff line number Diff line change
Expand Up @@ -1321,6 +1321,7 @@ async function loadToolDefinitionsWrapper({
tools: defsFilteredTools,
toolOptions: agent.tool_options,
deferredToolsEnabled,
deferSchemaChars: appConfig?.mcpSettings?.deferSchemaChars,
programmaticToolsEnabled,
codeExecutionEnabled,
codeExecutionContext: resolvedCodeExecutionContext,
Expand Down Expand Up @@ -1420,6 +1421,7 @@ async function loadToolDefinitionsWrapper({
tools: defsFilteredTools,
toolOptions: agent.tool_options,
deferredToolsEnabled,
deferSchemaChars: appConfig?.mcpSettings?.deferSchemaChars,
programmaticToolsEnabled,
codeExecutionEnabled,
codeExecutionContext: resolvedCodeExecutionContext,
Expand Down Expand Up @@ -1817,6 +1819,7 @@ async function loadAgentTools({
agentToolOptions: agent.tool_options,
gitIdentity: agent.git_identity,
deferredToolsEnabled,
deferSchemaChars: appConfig?.mcpSettings?.deferSchemaChars,
programmaticToolsEnabled,
codeExecutionEnabled,
codeEnvironments: appConfig?.endpoints?.agents?.statefulCodeSessions?.environments,
Expand Down
9 changes: 8 additions & 1 deletion client/src/components/SidePanel/Agents/MCPToolItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface MCPToolItemProps {
tool: AgentToolType;
isSelected: boolean;
isDeferred: boolean;
/** Deferred by `mcpSettings.deferSchemaChars` rather than by a stored option. */
deferredBySize?: boolean;
isProgrammatic: boolean;
isBackground: boolean;
isIntent: boolean;
Expand All @@ -34,6 +36,7 @@ export default function MCPToolItem({
tool,
isSelected,
isDeferred,
deferredBySize = false,
onToggleDefer,
onToggleSelect,
isProgrammatic,
Expand Down Expand Up @@ -87,7 +90,11 @@ export default function MCPToolItem({
icon={Clock}
pressed={isDeferred}
label={localize('com_ui_mcp_defer_loading')}
tooltip={localize('com_ui_mcp_click_to_defer')}
tooltip={localize(
deferredBySize && isDeferred
? 'com_ui_mcp_deferred_by_size'
: 'com_ui_mcp_click_to_defer',
)}
activeBorderClass="border-series-4"
onToggle={onToggleDefer}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,11 @@ export default function McpSection({ item }: Props) {
key={tool.tool_id}
tool={tool}
isSelected={selectedTools.includes(tool.tool_id)}
isDeferred={deferredToolsEnabled && isToolDeferred(tool.tool_id)}
isDeferred={
deferredToolsEnabled &&
isToolDeferred(tool.tool_id, tool.metadata?.deferredBySize === true)
}
deferredBySize={tool.metadata?.deferredBySize === true}
isProgrammatic={programmaticToolsEnabled && isToolProgrammatic(tool.tool_id)}
isBackground={backgroundToolsEnabled && isToolBackground(tool.tool_id)}
isIntent={
Expand All @@ -584,7 +588,9 @@ export default function McpSection({ item }: Props) {
backgroundToolsEnabled={backgroundToolsEnabled}
toolIntentsEnabled={toolIntentsEnabled}
onToggleSelect={() => toggleToolSelect(tool.tool_id)}
onToggleDefer={() => toggleToolDefer(tool.tool_id)}
onToggleDefer={() =>
toggleToolDefer(tool.tool_id, tool.metadata?.deferredBySize === true)
}
onToggleProgrammatic={() => toggleToolProgrammatic(tool.tool_id)}
onToggleBackground={() => toggleToolBackground(tool.tool_id)}
onToggleIntent={() => toggleToolIntent(tool.tool_id)}
Expand Down
79 changes: 79 additions & 0 deletions client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,85 @@ describe('useMCPToolOptions', () => {
});
});

describe('tools deferred by schema size', () => {
const sizeDeferredTool = (toolId: string): AgentToolType => ({
...createMockTool(toolId),
metadata: { ...createMockTool(toolId).metadata, deferredBySize: true },
});

it('shows a tool deferred by size as deferred when nothing is stored', () => {
(useWatch as jest.Mock).mockReturnValue({});

const { result } = renderHook(() => useMCPToolOptions());

expect(result.current.isToolDeferred('tool1', true)).toBe(true);
expect(result.current.isToolDeferred('tool1')).toBe(false);
});

it('shows a tool pinned open by an explicit false as loaded', () => {
(useWatch as jest.Mock).mockReturnValue({ tool1: { defer_loading: false } });

const { result } = renderHook(() => useMCPToolOptions());

expect(result.current.isToolDeferred('tool1', true)).toBe(false);
});

it('keeps a tool deferred by size loaded by storing an explicit false', () => {
mockGetValues.mockReturnValue({});

const { result } = renderHook(() => useMCPToolOptions());

act(() => {
result.current.toggleToolDefer('tool1', true);
});

expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{ tool1: { defer_loading: false } },
{ shouldDirty: true },
);
});

it('returns a pinned tool to the size rule by clearing the stored choice', () => {
mockGetValues.mockReturnValue({ tool1: { defer_loading: false } });

const { result } = renderHook(() => useMCPToolOptions());

act(() => {
result.current.toggleToolDefer('tool1', true);
});

expect(mockSetValue).toHaveBeenCalledWith('tool_options', {}, { shouldDirty: true });
});

it('counts size-deferred tools when deciding whether all tools are deferred', () => {
(useWatch as jest.Mock).mockReturnValue({ tool2: { defer_loading: true } });

const { result } = renderHook(() => useMCPToolOptions());

expect(
result.current.areAllToolsDeferred([sizeDeferredTool('tool1'), createMockTool('tool2')]),
).toBe(true);
});

it('undefers all by pinning size-deferred tools and clearing the rest', () => {
(useWatch as jest.Mock).mockReturnValue({ tool2: { defer_loading: true } });
mockGetValues.mockReturnValue({ tool2: { defer_loading: true } });

const { result } = renderHook(() => useMCPToolOptions());

act(() => {
result.current.toggleDeferAll([sizeDeferredTool('tool1'), createMockTool('tool2')]);
});

expect(mockSetValue).toHaveBeenCalledWith(
'tool_options',
{ tool1: { defer_loading: false } },
{ shouldDirty: true },
);
});
});

describe('toggleToolProgrammatic', () => {
it('should enable programmatic calling for a tool with no existing options', () => {
mockGetValues.mockReturnValue({});
Expand Down
88 changes: 81 additions & 7 deletions client/src/hooks/Agents/useMCPToolOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ interface ToolOptionsFormContext {

interface UseMCPToolOptionsReturn {
formToolOptions: AgentToolOptions | undefined;
isToolDeferred: (toolId: string) => boolean;
isToolDeferred: (toolId: string, deferredBySize?: boolean) => boolean;
isToolProgrammatic: (toolId: string) => boolean;
isToolBackground: (toolId: string) => boolean;
isToolIntent: (toolId: string) => boolean;
isToolProgrammaticOnly: (toolId: string) => boolean;
toggleToolDefer: (toolId: string) => void;
toggleToolDefer: (toolId: string, deferredBySize?: boolean) => void;
toggleToolProgrammatic: (toolId: string) => void;
toggleToolBackground: (toolId: string) => void;
toggleToolIntent: (toolId: string) => void;
Expand Down Expand Up @@ -69,6 +69,36 @@ export function withBooleanOption(
return updatedOptions;
}

/**
* Defer is the one flag a server rule can turn on: a tool whose schema is over
* `mcpSettings.deferSchemaChars` defers with no stored option. Keeping such a
* tool loaded stores an explicit `false`; deferring it again clears that, so
* the tool follows the rule. Other tools store exactly what they did before.
*/
export function withDeferOption(
options: AgentToolOptions,
toolId: string,
defer: boolean,
deferredBySize: boolean,
): AgentToolOptions {
if (!deferredBySize) {
return withBooleanOption(options, toolId, 'defer_loading', defer);
}
if (defer) {
return withBooleanOption(options, toolId, 'defer_loading', false);
}
return { ...options, [toolId]: { ...options[toolId], defer_loading: false } };
}

export function isDeferred(
options: AgentToolOptions | undefined,
toolId: string,
deferredBySize = false,
): boolean {
const explicit = options?.[toolId]?.defer_loading;
return explicit === true || (explicit == null && deferredBySize);
}

/**
* Counterpart of {@link withBooleanOption} for flags whose ABSENCE means
* default-on (background-native code execution): enabling clears the entry so
Expand Down Expand Up @@ -149,7 +179,51 @@ export default function useMCPToolOptions(): UseMCPToolOptionsReturn {
const formToolOptions = useWatch({ control, name: 'tool_options' });
const formContext: ToolOptionsFormContext = { formToolOptions, getValues, setValue };

const defer = useBooleanToolOption('defer_loading', formContext);
const isToolDeferred = useCallback(
(toolId: string, deferredBySize = false): boolean =>
isDeferred(formToolOptions, toolId, deferredBySize),
[formToolOptions],
);

const toggleToolDefer = useCallback(
(toolId: string, deferredBySize = false) => {
const currentOptions = getValues('tool_options') || {};
const defer = !isDeferred(currentOptions, toolId, deferredBySize);
setValue('tool_options', withDeferOption(currentOptions, toolId, defer, deferredBySize), {
shouldDirty: true,
});
},
[getValues, setValue],
);

const areAllToolsDeferred = useCallback(
(tools: AgentToolType[]): boolean =>
tools.length > 0 &&
tools.every((tool) =>
isDeferred(formToolOptions, tool.tool_id, tool.metadata?.deferredBySize === true),
),
[formToolOptions],
);

const toggleDeferAll = useCallback(
(tools: AgentToolType[]) => {
if (tools.length === 0) {
return;
}
const defer = !areAllToolsDeferred(tools);
let updatedOptions = getValues('tool_options') || {};
for (const tool of tools) {
updatedOptions = withDeferOption(
updatedOptions,
tool.tool_id,
defer,
tool.metadata?.deferredBySize === true,
);
}
setValue('tool_options', updatedOptions, { shouldDirty: true });
},
[areAllToolsDeferred, getValues, setValue],
);
const background = useBooleanToolOption('run_in_background', formContext);
const intent = useBooleanToolOption('describe_intent', formContext);

Expand Down Expand Up @@ -257,20 +331,20 @@ export default function useMCPToolOptions(): UseMCPToolOptionsReturn {

return {
formToolOptions,
isToolDeferred: defer.isSet,
isToolDeferred,
isToolProgrammatic,
isToolBackground: background.isSet,
isToolIntent: intent.isSet,
isToolProgrammaticOnly,
toggleToolDefer: defer.toggle,
toggleToolDefer,
toggleToolProgrammatic,
toggleToolBackground: background.toggle,
toggleToolIntent: intent.toggle,
areAllToolsDeferred: defer.areAllSet,
areAllToolsDeferred,
areAllToolsProgrammatic,
areAllToolsBackground: background.areAllSet,
areAllToolsIntent: intent.areAllSet,
toggleDeferAll: defer.toggleAll,
toggleDeferAll,
toggleProgrammaticAll,
toggleBackgroundAll: background.toggleAll,
toggleIntentAll: intent.toggleAll,
Expand Down
1 change: 1 addition & 0 deletions client/src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1699,6 +1699,7 @@
"com_ui_mcp_defer": "Defer",
"com_ui_mcp_defer_all": "Defer all tools",
"com_ui_mcp_defer_loading": "Defer loading",
"com_ui_mcp_deferred_by_size": "Deferred automatically because this tool's schema is larger than the server's size limit. Click to keep it loaded instead.",
"com_ui_mcp_dialog_title": "Configure Variables for {{serverName}}. Server Status: {{status}}",
"com_ui_mcp_domain_not_allowed": "The MCP server domain is not in the allowed domains list. Please contact your administrator.",
"com_ui_mcp_enter_var": "Enter value for {{0}}",
Expand Down
7 changes: 7 additions & 0 deletions librechat.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,13 @@ actions:
# - 'host.docker.internal:8080'
# - '127.0.0.1:8080'

# # Argument-schema size, in characters of its JSON, above which an MCP tool
# # starts deferred: the model sees only its name and loads the schema through
# # tool_search when it needs it. Needs the agents `deferred_tools` capability.
# # A per-tool defer_loading toggle still wins. 0, the default, leaves every
# # tool exactly as its own options configure it.
# deferSchemaChars: 4096

# Example MCP Servers Object Structure
# mcpServers:
# everything:
Expand Down
Loading
Loading