diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index 1637b8f7e1..cf213821f8 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -718,7 +718,7 @@ router.post( return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' }); } - const { success, message, oauthRequired, oauthUrl } = result; + const { success, message, oauthRequired, oauthUrl, connectionDeferred } = result; if (oauthRequired) { const flowId = getOAuthFlowId(user.id, serverName); @@ -731,6 +731,7 @@ router.post( oauthUrl, serverName, oauthRequired, + connectionDeferred, }); } catch (error) { logger.error('[MCP Reinitialize] Unexpected error', error); diff --git a/api/server/services/Tools/mcp.js b/api/server/services/Tools/mcp.js index e28d1caa77..eab23fb7f9 100644 --- a/api/server/services/Tools/mcp.js +++ b/api/server/services/Tools/mcp.js @@ -139,6 +139,10 @@ async function reinitMCPServer({ return { availableTools: null, success: true, + /** Lets clients distinguish "connection deferred to a chat turn" from a + * plain success with no tools, e.g. to attach the server at the server + * level instead of waiting for a tool list that never arrives. */ + connectionDeferred: true, message: `MCP server '${serverName}' uses request-scoped placeholders; connection will be established on first use in a chat turn`, oauthRequired: false, serverName, diff --git a/api/server/services/Tools/mcp.spec.js b/api/server/services/Tools/mcp.spec.js index 8c2042cf63..730d9cffb8 100644 --- a/api/server/services/Tools/mcp.spec.js +++ b/api/server/services/Tools/mcp.spec.js @@ -213,6 +213,7 @@ describe('reinitMCPServer — runtime BODY placeholder pre-check (issue #14074)' expect(result).toMatchObject({ availableTools: null, success: true, + connectionDeferred: true, tools: null, oauthRequired: false, serverName, @@ -240,7 +241,7 @@ describe('reinitMCPServer — runtime BODY placeholder pre-check (issue #14074)' fetchTools: jest.fn().mockResolvedValue([]), }); - await reinitMCPServer({ + const result = await reinitMCPServer({ user, serverName, serverConfig, @@ -249,6 +250,7 @@ describe('reinitMCPServer — runtime BODY placeholder pre-check (issue #14074)' }); expect(mockGetConnection).toHaveBeenCalledTimes(1); + expect(result.connectionDeferred).toBeUndefined(); }); it('reports missing customUserVars before deferring on body placeholders', async () => { 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 345fe8a357..0cd6fd2aca 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 @@ -1,5 +1,5 @@ import '@testing-library/jest-dom/extend-expect'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; import type { McpItem } from '../../items/types'; import McpSection from '../sections/McpSection'; @@ -7,6 +7,7 @@ import McpSection from '../sections/McpSection'; const mockSetValue = jest.fn(); const mockGetValues = jest.fn((): string[] => []); const mockInitializeServer = jest.fn(); +const mockIsConnectionDeferred = jest.fn((): boolean => false); jest.mock('react-hook-form', () => ({ useFormContext: () => ({ control: {}, setValue: mockSetValue, getValues: mockGetValues }), @@ -34,6 +35,8 @@ jest.mock('~/hooks', () => ({ getServerStatusIconProps: () => null, getConfigDialogProps: () => null, initializeServer: mockInitializeServer, + isConnectionDeferred: mockIsConnectionDeferred, + resetConnectionDeferred: jest.fn(), getOAuthUrl: () => undefined, isCancellable: () => false, cancelOAuthFlow: jest.fn(), @@ -132,6 +135,8 @@ describe('McpSection', () => { mockSetValue.mockClear(); mockGetValues.mockReturnValue([]); mockInitializeServer.mockReset(); + mockIsConnectionDeferred.mockReset(); + mockIsConnectionDeferred.mockReturnValue(false); }); test('renders one row per tool', () => { @@ -212,4 +217,76 @@ describe('McpSection', () => { render(); expect(screen.getByText('com_ui_tools_mcp_no_tools')).toBeInTheDocument(); }); + + test('deferred connect attaches the whole server via the mcp_all wildcard', async () => { + // Request-scoped servers (runtime {{LIBRECHAT_BODY_*}} placeholders) defer + // their connection to the next chat turn, so no tool list arrives here — + // Connect should attach the server-wide wildcard instead of waiting. + mockInitializeServer.mockResolvedValue({ success: true, connectionDeferred: true }); + mockIsConnectionDeferred.mockReturnValue(true); + const empty: McpItem = { + ...item, + server: { ...item.server, tools: [] } as never, + toolCount: 0, + }; + render(); + fireEvent.click(screen.getByText('com_nav_mcp_connect_server')); + await waitFor(() => + expect(mockSetValue).toHaveBeenCalledWith( + 'tools', + ['sys__server__sys_mcp_srv', 'sys__all__sys_mcp_srv'], + expect.objectContaining({ shouldDirty: true }), + ), + ); + }); + + test('deferred connect does not duplicate an already-attached wildcard', async () => { + mockInitializeServer.mockResolvedValue({ success: true, connectionDeferred: true }); + mockIsConnectionDeferred.mockReturnValue(true); + mockGetValues.mockReturnValue(['sys__server__sys_mcp_srv', 'sys__all__sys_mcp_srv']); + const empty: McpItem = { + ...item, + server: { ...item.server, tools: [] } as never, + toolCount: 0, + }; + render(); + fireEvent.click(screen.getByText('com_nav_mcp_connect_server')); + await waitFor(() => expect(mockInitializeServer).toHaveBeenCalled()); + expect(mockSetValue).not.toHaveBeenCalled(); + }); + + test('wildcard attachment shows every enumerable tool as selected', () => { + // The mcp_all wildcard grants every tool at runtime; if the server's tools + // become enumerable, the display must reflect that instead of showing + // unchecked boxes while runtime grants everything. + mockGetValues.mockReturnValue(['sys__all__sys_mcp_srv']); + render(); + expect(screen.getByTestId('tool-mcp:srv:a')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('tool-mcp:srv:b')).toHaveAttribute('aria-pressed', 'true'); + }); + + test('touching a selection converts the wildcard to concrete tool ids', () => { + // With a wildcard attached and tools enumerable, deselecting one tool must + // rewrite the form with the remaining concrete ids and drop the wildcard — + // otherwise runtime would still grant every tool while the UI shows a subset. + mockGetValues.mockReturnValue(['sys__all__sys_mcp_srv']); + render(); + fireEvent.click(screen.getByTestId('tool-mcp:srv:a')); + expect(mockSetValue).toHaveBeenCalledWith( + 'tools', + ['sys__server__sys_mcp_srv', 'mcp:srv:b'], + expect.objectContaining({ shouldDirty: true }), + ); + }); + + test('shows the runtime-tools hint when attached via the wildcard', () => { + mockGetValues.mockReturnValue(['sys__all__sys_mcp_srv']); + const empty: McpItem = { + ...item, + server: { ...item.server, tools: [] } as never, + toolCount: 0, + }; + render(); + expect(screen.getByText('com_ui_tools_mcp_runtime_tools')).toBeInTheDocument(); + }); }); 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 660c669c28..3009662b61 100644 --- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx @@ -13,9 +13,9 @@ import { useMCPToolOptions, } from '~/hooks'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; +import { mcpAllToken, mcpServerToken } from '../../items/selectors'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; import McpOAuthDialog from '~/components/MCP/McpOAuthDialog'; -import { mcpServerToken } from '../../items/selectors'; import { useAgentPanelContext } from '~/Providers'; import { getIconForItem } from '../../items/icons'; import MCPToolItem from '../../../MCPToolItem'; @@ -61,8 +61,14 @@ interface Props { export default function McpSection({ item }: Props) { const localize = useLocalize(); const { control, getValues, setValue } = useFormContext(); - const { getServerStatusIconProps, getConfigDialogProps, initializeServer, getOAuthUrl } = - useMCPServerManager(); + const { + getServerStatusIconProps, + getConfigDialogProps, + initializeServer, + isConnectionDeferred, + resetConnectionDeferred, + getOAuthUrl, + } = useMCPServerManager(); const [oauthOpen, setOauthOpen] = useState(false); const [oauthUrl, setOauthUrl] = useState(null); const [prevConnected, setPrevConnected] = useState(false); @@ -85,6 +91,7 @@ export default function McpSection({ item }: Props) { const serverName = item.server.serverName; const serverToken = mcpServerToken(serverName); + const serverAllToken = mcpAllToken(serverName); /** Live server data — `item.server` is a snapshot from card click and goes stale once * the MCP query refetches (e.g., after a server connects), so read from the live map. */ const liveServer = mcpServersMap.get(serverName) ?? item.server; @@ -94,22 +101,36 @@ export default function McpSection({ item }: Props) { /** Subscribe to the tools field so selection toggles re-render this section. * `getValues` is a non-reactive read and left the checkboxes visually stale. */ const formTools = (useWatch({ control, name: 'tools' }) ?? []) as string[]; + /** Attached via the server-wide `mcp_all` wildcard — used by request-scoped + * servers whose tools resolve at chat-turn time and can't be listed here. */ + const isWildcardAttached = formTools.includes(serverAllToken); + /** The `mcp_all` wildcard grants every server tool at runtime, so when the + * server's tools ARE enumerable (e.g. it stopped being request-scoped), fold + * the wildcard into the display as "all selected" — otherwise the dialog + * would show unchecked boxes while runtime grants everything. Any selection + * interaction then rewrites the form with concrete tool ids (the wildcard is + * stripped by `updateFormTools`), converting the attachment on first touch. */ const getSelectedTools = (): string[] => - tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id); + isWildcardAttached + ? tools.map((t) => t.tool_id) + : tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id); /** Replace this server's tool selection while keeping the server attached: the * placeholder token is always rewritten, so deselect-all leaves the server - * pinned with zero tools; only an explicit remove detaches it. */ + * pinned with zero tools; only an explicit remove detaches it. The `mcp_all` + * wildcard is also stripped unless explicitly re-passed in `next`, so a + * per-tool selection always supersedes a stale wildcard (e.g. after a server + * stops being request-scoped and its tools become enumerable). */ const updateFormTools = useCallback( (next: string[]) => { const current = (getValues('tools') ?? []) as string[]; const otherTools = current.filter( - (t) => t !== serverToken && !tools.some((st) => st.tool_id === t), + (t) => t !== serverToken && t !== serverAllToken && !tools.some((st) => st.tool_id === t), ); setValue('tools', [...otherTools, serverToken, ...next], { shouldDirty: true }); }, - [getValues, setValue, serverToken, tools], + [getValues, setValue, serverToken, serverAllToken, tools], ); const toggleToolSelect = (toolId: string) => { @@ -155,14 +176,42 @@ export default function McpSection({ item }: Props) { /** Connecting from this dialog implies the user wants the server's tools: * once the connection settles and the tools arrive (query refetch for direct * connects, polling for OAuth), select them all — an effect because both - * signals come from external systems, not from anything rendered here. */ + * signals come from external systems, not from anything rendered here. + * + * Request-scoped servers (runtime `{{LIBRECHAT_BODY_*}}` placeholders) defer + * their connection to the next chat turn, so no tool list will ever arrive — + * attach the whole server via the `mcp_all` wildcard instead; the backend + * resolves it into the server's full tool set at turn time. Keying on the + * manager's init state (not the awaited response) also covers connects that + * happen behind the customUserVars config dialog, which this component does + * not await. */ + const serverDeferred = isConnectionDeferred(serverName); useEffect(() => { - if (!autoSelectPending || !isConnected || !hasTools) { + if (!autoSelectPending) { + return; + } + if (serverDeferred && !hasTools) { + setAutoSelectPending(false); + if (!isWildcardAttached) { + updateFormTools([serverAllToken]); + } + return; + } + if (!isConnected || !hasTools) { return; } setAutoSelectPending(false); updateFormTools(tools.map((t) => t.tool_id)); - }, [autoSelectPending, isConnected, hasTools, tools, updateFormTools]); + }, [ + autoSelectPending, + serverDeferred, + isConnected, + hasTools, + tools, + updateFormTools, + isWildcardAttached, + serverAllToken, + ]); /** Connect inline from this first dialog. Servers with custom user variables are * routed to the config dialog (which sets the vars and initializes); others @@ -171,6 +220,11 @@ export default function McpSection({ item }: Props) { const handleConnect = async (e: MouseEvent) => { setAutoSelectPending(true); if (statusIconProps != null && statusIconProps.hasCustomUserVars) { + /** A stale deferred flag from an earlier attempt must not fire the + * auto-attach effect while the config dialog is open — only this + * attempt's outcome (recorded on save → initialize) counts. The direct + * path below needs no reset: initializeServer clears it up front. */ + resetConnectionDeferred(serverName); statusIconProps.onConfigClick(e); return; } @@ -361,7 +415,9 @@ export default function McpSection({ item }: Props) {

- {localize('com_ui_tools_mcp_no_tools')} + {localize( + isWildcardAttached ? 'com_ui_tools_mcp_runtime_tools' : 'com_ui_tools_mcp_no_tools', + )}

diff --git a/client/src/components/SidePanel/Agents/Tools/items/selectors.ts b/client/src/components/SidePanel/Agents/Tools/items/selectors.ts index b626f4d7e1..c1082f8dd2 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/selectors.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/selectors.ts @@ -65,6 +65,18 @@ export function mcpServerToken(serverName: string): string { return `${Constants.mcp_server}${Constants.mcp_delimiter}${serverName}`; } +/** + * Server-wide wildcard token (`sys__all__sys_mcp_`). Unlike the + * UI-only `mcp_server` placeholder (skipped at runtime), `mcp_all` is resolved + * by the backend into ALL of the server's tools at chat-turn time. Used for + * request-scoped servers (runtime `{{LIBRECHAT_BODY_*}}` placeholders) whose + * tools cannot be enumerated outside a chat turn, so per-tool selection is + * impossible and the server must be attached as a whole. + */ +export function mcpAllToken(serverName: string): string { + return `${Constants.mcp_all}${Constants.mcp_delimiter}${serverName}`; +} + /** * Whether a form `tools` token references the given MCP server, across every * format ever persisted: the server placeholder token, the raw server name, diff --git a/client/src/hooks/MCP/useMCPServerManager.ts b/client/src/hooks/MCP/useMCPServerManager.ts index 6126545c35..c5bc7ba022 100644 --- a/client/src/hooks/MCP/useMCPServerManager.ts +++ b/client/src/hooks/MCP/useMCPServerManager.ts @@ -345,9 +345,17 @@ export function useMCPServerManager({ const initializeServer = useCallback( async (serverName: string, autoOpenOAuth: boolean = true) => { - updateServerInitState(serverName, { isInitializing: true }); + /** connectionDeferred is reset up front so a stale value from a previous + * attempt can never be mistaken for this attempt's outcome. */ + updateServerInitState(serverName, { isInitializing: true, connectionDeferred: false }); try { const response = await reinitializeMutation.mutateAsync(serverName); + /** Record whether this attempt deferred to a chat turn (request-scoped + * server) so consumers that didn't await this call — e.g. the agent + * builder behind the customUserVars config dialog — can react to it. */ + updateServerInitState(serverName, { + connectionDeferred: Boolean(response.connectionDeferred), + }); if (!response.success) { showToast({ message: localize('com_ui_mcp_init_failed', { 0: serverName }), @@ -456,6 +464,23 @@ export function useMCPServerManager({ [serverInitStates], ); + const isConnectionDeferred = useCallback( + (serverName: string) => { + return getServerInitState(serverInitStates, serverName).connectionDeferred; + }, + [serverInitStates], + ); + + /** Clear a recorded deferred outcome without starting a new attempt — used + * before routing into the customUserVars config dialog so a stale flag from + * an earlier attempt can't trigger consumers while the dialog is open. */ + const resetConnectionDeferred = useCallback( + (serverName: string) => { + updateServerInitState(serverName, { connectionDeferred: false }); + }, + [updateServerInitState], + ); + const getOAuthUrl = useCallback( (serverName: string) => { return getServerInitState(serverInitStates, serverName).oauthUrl; @@ -678,6 +703,8 @@ export function useMCPServerManager({ cancelOAuthFlow, isInitializing, isCancellable, + isConnectionDeferred, + resetConnectionDeferred, getOAuthUrl, mcpValues, setMCPValues, diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index a5976e8873..1aa74d4538 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1914,6 +1914,7 @@ "com_ui_tools_marketplace_search": "Search tools…", "com_ui_tools_mcp_deselect_all": "Deselect all", "com_ui_tools_mcp_no_tools": "This server has not exposed any tools yet.", + "com_ui_tools_mcp_runtime_tools": "All of this server's tools are attached; they are resolved at runtime, during chat.", "com_ui_tools_mcp_select_all": "Select all", "com_ui_tools_mcp_status_unconfigured": "Needs configuration", "com_ui_tools_mcp_tools_section": "Tools in this server", diff --git a/client/src/store/mcp.ts b/client/src/store/mcp.ts index 793e1cebd0..f4c44a6117 100644 --- a/client/src/store/mcp.ts +++ b/client/src/store/mcp.ts @@ -39,6 +39,11 @@ export interface MCPServerInitState { isCancellable: boolean; oauthUrl: string | null; oauthStartTime: number | null; + /** Last initialize attempt reported a request-scoped server whose connection + * is deferred to the next chat turn (runtime body placeholders) — its tools + * cannot be enumerated up front. Consumers attach such servers wholesale via + * the `mcp_all` wildcard instead of waiting for a tool list. */ + connectionDeferred: boolean; } const defaultServerInitState: MCPServerInitState = { @@ -46,6 +51,7 @@ const defaultServerInitState: MCPServerInitState = { isCancellable: false, oauthUrl: null, oauthStartTime: null, + connectionDeferred: false, }; /** diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index 509a3d8ce9..cd85462ecb 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -340,6 +340,9 @@ export const useReinitializeMCPServerMutation = (): UseMutationResult< serverName: string; oauthRequired?: boolean; oauthUrl?: string; + /** True when the server uses request-scoped placeholders and the connection + * was deferred to the next chat turn (tools are not enumerable up front). */ + connectionDeferred?: boolean; }, unknown, string,