Skip to content

Commit 406a63a

Browse files
authored
🗂️ refactor: Make MCPSubMenu consistent with MCPSelect (danny-avila#8650)
- Refactored MCPSelect and MCPSubMenu components to utilize a new custom hook, `useMCPServerManager`, for improved state management and server initialization logic. - Added functionality to handle simultaneous MCP server initialization requests, including cancellation and user notifications. - Updated translation files to include new messages for initialization cancellation. - Improved the configuration dialog handling for MCP servers, streamlining the user experience when managing server settings.
1 parent becf78d commit 406a63a

File tree

7 files changed

+503
-424
lines changed

7 files changed

+503
-424
lines changed

api/server/routes/mcp.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,8 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
331331

332332
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
333333

334-
const config = await loadCustomConfig();
334+
const printConfig = false;
335+
const config = await loadCustomConfig(printConfig);
335336
if (!config || !config.mcpServers || !config.mcpServers[serverName]) {
336337
return res.status(404).json({
337338
error: `MCP server '${serverName}' not found in configuration`,

client/src/components/Chat/Input/MCPSelect.tsx

Lines changed: 20 additions & 295 deletions
Original file line numberDiff line numberDiff line change
@@ -1,114 +1,21 @@
1-
import { useQueryClient } from '@tanstack/react-query';
2-
import { Constants, QueryKeys } from 'librechat-data-provider';
3-
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
4-
import React, { memo, useCallback, useState, useMemo, useRef } from 'react';
5-
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
6-
import MCPConfigDialog, { ConfigFieldDetail } from '~/components/ui/MCP/MCPConfigDialog';
7-
import { useMCPServerInitialization } from '~/hooks/MCP/useMCPServerInitialization';
1+
import React, { memo, useCallback } from 'react';
2+
import MCPConfigDialog from '~/components/ui/MCP/MCPConfigDialog';
83
import MCPServerStatusIcon from '~/components/ui/MCP/MCPServerStatusIcon';
9-
import { useToastContext, useBadgeRowContext } from '~/Providers';
104
import MultiSelect from '~/components/ui/MultiSelect';
115
import { MCPIcon } from '~/components/svg';
12-
import { useLocalize } from '~/hooks';
6+
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
137

148
function MCPSelect() {
15-
const localize = useLocalize();
16-
const { showToast } = useToastContext();
17-
const { mcpSelect, startupConfig } = useBadgeRowContext();
18-
const { mcpValues, setMCPValues, mcpToolDetails, isPinned } = mcpSelect;
19-
20-
// Get all configured MCP servers from config that allow chat menu
21-
const configuredServers = useMemo(() => {
22-
if (!startupConfig?.mcpServers) {
23-
return [];
24-
}
25-
return Object.entries(startupConfig.mcpServers)
26-
.filter(([, config]) => config.chatMenu !== false)
27-
.map(([serverName]) => serverName);
28-
}, [startupConfig?.mcpServers]);
29-
30-
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
31-
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
32-
const previousFocusRef = useRef<HTMLElement | null>(null);
33-
34-
const queryClient = useQueryClient();
35-
36-
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
37-
onSuccess: async () => {
38-
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
39-
40-
// tools so we dont leave tools available for use in chat if we revoke and thus kill mcp server
41-
// auth values so customUserVars flags are updated in customUserVarsSection
42-
// connection status so connection indicators are updated in the dropdown
43-
await Promise.all([
44-
queryClient.refetchQueries([QueryKeys.tools]),
45-
queryClient.refetchQueries([QueryKeys.mcpAuthValues]),
46-
queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]),
47-
]);
48-
},
49-
onError: (error: unknown) => {
50-
console.error('Error updating MCP auth:', error);
51-
showToast({
52-
message: localize('com_nav_mcp_vars_update_error'),
53-
status: 'error',
54-
});
55-
},
56-
});
57-
58-
// Use the shared initialization hook
59-
const { initializeServer, isInitializing, connectionStatus, cancelOAuthFlow, isCancellable } =
60-
useMCPServerInitialization({
61-
onSuccess: (serverName) => {
62-
// Add to selected values after successful initialization
63-
const currentValues = mcpValues ?? [];
64-
if (!currentValues.includes(serverName)) {
65-
setMCPValues([...currentValues, serverName]);
66-
}
67-
},
68-
onError: (serverName) => {
69-
// Find the tool/server configuration
70-
const tool = mcpToolDetails?.find((t) => t.name === serverName);
71-
const serverConfig = startupConfig?.mcpServers?.[serverName];
72-
const serverStatus = connectionStatus[serverName];
73-
74-
// Check if this server would show a config button
75-
const hasAuthConfig =
76-
(tool?.authConfig && tool.authConfig.length > 0) ||
77-
(serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0);
78-
79-
// Only open dialog if the server would have shown a config button
80-
// (disconnected/error states always show button, connected only shows if hasAuthConfig)
81-
const wouldShowButton =
82-
!serverStatus ||
83-
serverStatus.connectionState === 'disconnected' ||
84-
serverStatus.connectionState === 'error' ||
85-
(serverStatus.connectionState === 'connected' && hasAuthConfig);
86-
87-
if (!wouldShowButton) {
88-
return; // Don't open dialog if no button would be shown
89-
}
90-
91-
// Create tool object if it doesn't exist
92-
const configTool = tool || {
93-
name: serverName,
94-
pluginKey: `${Constants.mcp_prefix}${serverName}`,
95-
authConfig: serverConfig?.customUserVars
96-
? Object.entries(serverConfig.customUserVars).map(([key, config]) => ({
97-
authField: key,
98-
label: config.title,
99-
description: config.description,
100-
}))
101-
: [],
102-
authenticated: false,
103-
};
104-
105-
previousFocusRef.current = document.activeElement as HTMLElement;
106-
107-
// Open the config dialog on error
108-
setSelectedToolForConfig(configTool);
109-
setIsConfigModalOpen(true);
110-
},
111-
});
9+
const {
10+
configuredServers,
11+
mcpValues,
12+
isPinned,
13+
placeholderText,
14+
batchToggleServers,
15+
getServerStatusIconProps,
16+
getConfigDialogProps,
17+
localize,
18+
} = useMCPServerManager();
11219

11320
const renderSelectedValues = useCallback(
11421
(values: string[], placeholder?: string) => {
@@ -123,137 +30,9 @@ function MCPSelect() {
12330
[localize],
12431
);
12532

126-
const handleConfigSave = useCallback(
127-
(targetName: string, authData: Record<string, string>) => {
128-
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
129-
// Use the pluginKey directly since it's already in the correct format
130-
console.log(
131-
`[MCP Select] Saving config for ${targetName}, pluginKey: ${`${Constants.mcp_prefix}${targetName}`}`,
132-
);
133-
const payload: TUpdateUserPlugins = {
134-
pluginKey: `${Constants.mcp_prefix}${targetName}`,
135-
action: 'install',
136-
auth: authData,
137-
};
138-
updateUserPluginsMutation.mutate(payload);
139-
}
140-
},
141-
[selectedToolForConfig, updateUserPluginsMutation],
142-
);
143-
144-
const handleConfigRevoke = useCallback(
145-
(targetName: string) => {
146-
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
147-
// Use the pluginKey directly since it's already in the correct format
148-
const payload: TUpdateUserPlugins = {
149-
pluginKey: `${Constants.mcp_prefix}${targetName}`,
150-
action: 'uninstall',
151-
auth: {},
152-
};
153-
updateUserPluginsMutation.mutate(payload);
154-
155-
// Remove the server from selected values after revoke
156-
const currentValues = mcpValues ?? [];
157-
const filteredValues = currentValues.filter((name) => name !== targetName);
158-
setMCPValues(filteredValues);
159-
}
160-
},
161-
[selectedToolForConfig, updateUserPluginsMutation, mcpValues, setMCPValues],
162-
);
163-
164-
const handleSave = useCallback(
165-
(authData: Record<string, string>) => {
166-
if (selectedToolForConfig) {
167-
handleConfigSave(selectedToolForConfig.name, authData);
168-
}
169-
},
170-
[selectedToolForConfig, handleConfigSave],
171-
);
172-
173-
const handleRevoke = useCallback(() => {
174-
if (selectedToolForConfig) {
175-
handleConfigRevoke(selectedToolForConfig.name);
176-
}
177-
}, [selectedToolForConfig, handleConfigRevoke]);
178-
179-
const handleDialogOpenChange = useCallback((open: boolean) => {
180-
setIsConfigModalOpen(open);
181-
182-
// Restore focus when dialog closes
183-
if (!open && previousFocusRef.current) {
184-
// Use setTimeout to ensure the dialog has fully closed before restoring focus
185-
setTimeout(() => {
186-
if (previousFocusRef.current && typeof previousFocusRef.current.focus === 'function') {
187-
previousFocusRef.current.focus();
188-
}
189-
previousFocusRef.current = null;
190-
}, 0);
191-
}
192-
}, []);
193-
194-
// Get connection status for all MCP servers (now from hook)
195-
// Remove the duplicate useMCPConnectionStatusQuery since it's in the hook
196-
197-
// Modified setValue function that attempts to initialize disconnected servers
198-
const filteredSetMCPValues = useCallback(
199-
(values: string[]) => {
200-
// Separate connected and disconnected servers
201-
const connectedServers: string[] = [];
202-
const disconnectedServers: string[] = [];
203-
204-
values.forEach((serverName) => {
205-
const serverStatus = connectionStatus[serverName];
206-
if (serverStatus?.connectionState === 'connected') {
207-
connectedServers.push(serverName);
208-
} else {
209-
disconnectedServers.push(serverName);
210-
}
211-
});
212-
213-
// Only set connected servers as selected values
214-
setMCPValues(connectedServers);
215-
216-
// Attempt to initialize each disconnected server (once)
217-
disconnectedServers.forEach((serverName) => {
218-
initializeServer(serverName);
219-
});
220-
},
221-
[connectionStatus, setMCPValues, initializeServer],
222-
);
223-
22433
const renderItemContent = useCallback(
22534
(serverName: string, defaultContent: React.ReactNode) => {
226-
const tool = mcpToolDetails?.find((t) => t.name === serverName);
227-
const serverStatus = connectionStatus[serverName];
228-
const serverConfig = startupConfig?.mcpServers?.[serverName];
229-
230-
const handleConfigClick = (e: React.MouseEvent) => {
231-
e.stopPropagation();
232-
e.preventDefault();
233-
234-
previousFocusRef.current = document.activeElement as HTMLElement;
235-
236-
const configTool = tool || {
237-
name: serverName,
238-
pluginKey: `${Constants.mcp_prefix}${serverName}`,
239-
authConfig: serverConfig?.customUserVars
240-
? Object.entries(serverConfig.customUserVars).map(([key, config]) => ({
241-
authField: key,
242-
label: config.title,
243-
description: config.description,
244-
}))
245-
: [],
246-
authenticated: false,
247-
};
248-
setSelectedToolForConfig(configTool);
249-
setIsConfigModalOpen(true);
250-
};
251-
252-
const handleCancelClick = (e: React.MouseEvent) => {
253-
e.stopPropagation();
254-
e.preventDefault();
255-
cancelOAuthFlow(serverName);
256-
};
35+
const statusIconProps = getServerStatusIconProps(serverName);
25736

25837
// Common wrapper for the main content (check mark + text)
25938
// Ensures Check & Text are adjacent and the group takes available space.
@@ -267,22 +46,7 @@ function MCPSelect() {
26746
</button>
26847
);
26948

270-
// Check if this server has customUserVars to configure
271-
const hasCustomUserVars =
272-
serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0;
273-
274-
const statusIcon = (
275-
<MCPServerStatusIcon
276-
serverName={serverName}
277-
serverStatus={serverStatus}
278-
tool={tool}
279-
onConfigClick={handleConfigClick}
280-
isInitializing={isInitializing(serverName)}
281-
canCancel={isCancellable(serverName)}
282-
onCancel={handleCancelClick}
283-
hasCustomUserVars={hasCustomUserVars}
284-
/>
285-
);
49+
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
28650

28751
if (statusIcon) {
28852
return (
@@ -295,14 +59,7 @@ function MCPSelect() {
29559

29660
return mainContentWrapper;
29761
},
298-
[
299-
isInitializing,
300-
isCancellable,
301-
mcpToolDetails,
302-
cancelOAuthFlow,
303-
connectionStatus,
304-
startupConfig?.mcpServers,
305-
],
62+
[getServerStatusIconProps],
30663
);
30764

30865
// Don't render if no servers are selected and not pinned
@@ -315,14 +72,14 @@ function MCPSelect() {
31572
return null;
31673
}
31774

318-
const placeholderText =
319-
startupConfig?.interface?.mcpServers?.placeholder || localize('com_ui_mcp_servers');
75+
const configDialogProps = getConfigDialogProps();
76+
32077
return (
32178
<>
32279
<MultiSelect
32380
items={configuredServers}
32481
selectedValues={mcpValues ?? []}
325-
setSelectedValues={filteredSetMCPValues}
82+
setSelectedValues={batchToggleServers}
32683
defaultSelectedValues={mcpValues ?? []}
32784
renderSelectedValues={renderSelectedValues}
32885
renderItemContent={renderItemContent}
@@ -333,39 +90,7 @@ function MCPSelect() {
33390
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
33491
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
33592
/>
336-
{selectedToolForConfig && (
337-
<MCPConfigDialog
338-
serverName={selectedToolForConfig.name}
339-
serverStatus={connectionStatus[selectedToolForConfig.name]}
340-
isOpen={isConfigModalOpen}
341-
onOpenChange={handleDialogOpenChange}
342-
fieldsSchema={(() => {
343-
const schema: Record<string, ConfigFieldDetail> = {};
344-
if (selectedToolForConfig?.authConfig) {
345-
selectedToolForConfig.authConfig.forEach((field) => {
346-
schema[field.authField] = {
347-
title: field.label,
348-
description: field.description,
349-
};
350-
});
351-
}
352-
return schema;
353-
})()}
354-
initialValues={(() => {
355-
const initial: Record<string, string> = {};
356-
// Note: Actual initial values might need to be fetched if they are stored user-specifically
357-
if (selectedToolForConfig?.authConfig) {
358-
selectedToolForConfig.authConfig.forEach((field) => {
359-
initial[field.authField] = ''; // Or fetched value
360-
});
361-
}
362-
return initial;
363-
})()}
364-
onSave={handleSave}
365-
onRevoke={handleRevoke}
366-
isSubmitting={updateUserPluginsMutation.isLoading}
367-
/>
368-
)}
93+
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
36994
</>
37095
);
37196
}

0 commit comments

Comments
 (0)