LibreChat/client/src/components/SidePanel/MCP/MCPPanel.tsx
Danny Avila c0511b9a5f
🔧 fix: MCP Selection Persist and UI Flicker Issues (#9324)
* refactor: useMCPSelect

    - Add useGetMCPTools to use in useMCPSelect and elsewhere hooks for fetching MCP tools
    - remove memoized key
    - remove use of `useChatContext` and require conversationId as prop

* feat: Add MCPPanelContext and integrate conversationId as prop for useMCPSelect across components

- Introduced MCPPanelContext to manage conversationId state.
- Updated MCPSelect, MCPSubMenu, and MCPConfigDialog to accept conversationId as a prop.
- Modified ToolsDropdown and BadgeRow to pass conversationId to relevant components.
- Refactored MCPPanel to utilize MCPPanelProvider for context management.

* fix: remove nested ternary in ServerInitializationSection

- Replaced conditional operator with if-else statements for better readability in determining button text based on server initialization state and reinitialization status.

* refactor: wrap setValueWrap in useCallback for performance optimization

* refactor: streamline useMCPSelect by consolidating storageKey definition

* fix: prevent clearing selections on page refresh by tracking initial load completion

* refactor: simplify concern of useMCPSelect hook

* refactor: move ConfigFieldDetail interface to common types for better reusability, isolate usage of `useGetMCPTools`

* refactor: integrate mcpServerNames into BadgeRowContext and update ToolsDropdown and MCPSelect components
2025-08-28 00:44:49 -04:00

213 lines
7.1 KiB
TypeScript

import React, { useState, useMemo, useCallback } from 'react';
import { ChevronLeft } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Button, useToastContext } from '@librechat/client';
import { Constants, QueryKeys } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins } from 'librechat-data-provider';
import ServerInitializationSection from '~/components/MCP/ServerInitializationSection';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
import { MCPPanelProvider, useMCPPanelContext } from '~/Providers';
import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton';
import { useLocalize } from '~/hooks';
function MCPPanelContent() {
const localize = useLocalize();
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const { conversationId } = useMCPPanelContext();
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
const { data: connectionStatusData } = useMCPConnectionStatusQuery();
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
null,
);
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
onSuccess: async () => {
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
await Promise.all([
queryClient.refetchQueries([QueryKeys.tools]),
queryClient.refetchQueries([QueryKeys.mcpAuthValues]),
queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]),
]);
},
onError: (error: unknown) => {
console.error('Error updating MCP auth:', error);
showToast({
message: localize('com_nav_mcp_vars_update_error'),
status: 'error',
});
},
});
const mcpServerDefinitions = useMemo(() => {
if (!startupConfig?.mcpServers) {
return [];
}
return Object.entries(startupConfig.mcpServers).map(([serverName, config]) => ({
serverName,
iconPath: null,
config: {
...config,
customUserVars: config.customUserVars ?? {},
},
}));
}, [startupConfig?.mcpServers]);
const connectionStatus = useMemo(
() => connectionStatusData?.connectionStatus || {},
[connectionStatusData?.connectionStatus],
);
const handleServerClickToEdit = (serverName: string) => {
setSelectedServerNameForEditing(serverName);
};
const handleGoBackToList = () => {
setSelectedServerNameForEditing(null);
};
const handleConfigSave = useCallback(
(targetName: string, authData: Record<string, string>) => {
console.log(
`[MCP Panel] Saving config for ${targetName}, pluginKey: ${`${Constants.mcp_prefix}${targetName}`}`,
);
const payload: TUpdateUserPlugins = {
pluginKey: `${Constants.mcp_prefix}${targetName}`,
action: 'install',
auth: authData,
};
updateUserPluginsMutation.mutate(payload);
},
[updateUserPluginsMutation],
);
const handleConfigRevoke = useCallback(
(targetName: string) => {
const payload: TUpdateUserPlugins = {
pluginKey: `${Constants.mcp_prefix}${targetName}`,
action: 'uninstall',
auth: {},
};
updateUserPluginsMutation.mutate(payload);
},
[updateUserPluginsMutation],
);
if (startupConfigLoading) {
return <MCPPanelSkeleton />;
}
if (mcpServerDefinitions.length === 0) {
return (
<div className="p-4 text-center text-sm text-gray-500">
{localize('com_sidepanel_mcp_no_servers_with_vars')}
</div>
);
}
if (selectedServerNameForEditing) {
// Editing View
const serverBeingEdited = mcpServerDefinitions.find(
(s) => s.serverName === selectedServerNameForEditing,
);
if (!serverBeingEdited) {
// Fallback to list view if server not found
setSelectedServerNameForEditing(null);
return (
<div className="p-4 text-center text-sm text-gray-500">
{localize('com_ui_error')}: {localize('com_ui_mcp_server_not_found')}
</div>
);
}
const serverStatus = connectionStatus[selectedServerNameForEditing];
return (
<div className="h-auto max-w-full space-y-4 overflow-x-hidden py-2">
<Button variant="outline" onClick={handleGoBackToList} size="sm">
<ChevronLeft className="mr-1 h-4 w-4" />
{localize('com_ui_back')}
</Button>
<div className="mb-4">
<CustomUserVarsSection
serverName={selectedServerNameForEditing}
fields={serverBeingEdited.config.customUserVars}
onSave={(authData) => {
if (selectedServerNameForEditing) {
handleConfigSave(selectedServerNameForEditing, authData);
}
}}
onRevoke={() => {
if (selectedServerNameForEditing) {
handleConfigRevoke(selectedServerNameForEditing);
}
}}
isSubmitting={updateUserPluginsMutation.isLoading}
/>
</div>
<ServerInitializationSection
sidePanel={true}
conversationId={conversationId}
serverName={selectedServerNameForEditing}
requiresOAuth={serverStatus?.requiresOAuth || false}
hasCustomUserVars={
serverBeingEdited.config.customUserVars &&
Object.keys(serverBeingEdited.config.customUserVars).length > 0
}
/>
</div>
);
} else {
// Server List View
return (
<div className="h-auto max-w-full overflow-x-hidden py-2">
<div className="space-y-2">
{mcpServerDefinitions.map((server) => {
const serverStatus = connectionStatus[server.serverName];
const isConnected = serverStatus?.connectionState === 'connected';
return (
<div key={server.serverName} className="flex items-center gap-2">
<Button
variant="outline"
className="flex-1 justify-start dark:hover:bg-gray-700"
onClick={() => handleServerClickToEdit(server.serverName)}
>
<div className="flex items-center gap-2">
<span>{server.serverName}</span>
{serverStatus && (
<span
className={`rounded-xl px-2 py-0.5 text-xs ${
isConnected
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}`}
>
{serverStatus.connectionState}
</span>
)}
</div>
</Button>
</div>
);
})}
</div>
</div>
);
}
}
export default function MCPPanel() {
return (
<MCPPanelProvider>
<MCPPanelContent />
</MCPPanelProvider>
);
}