LibreChat/client/src/components/SidePanel/MCP/MCPPanel.tsx
Daniel Lew 1143f73f59
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
🔇 fix: Hide Button Icons from Screen Readers (#10776)
If you've got a screen reader that is reading out the whole page,
each icon button (i.e., `<button><SVG></button>`) will have both
the button's aria-label read out as well as the title from the
SVG (which is usually just "image").

Since we are pretty good about setting aria-labels, we should instead
use `aria-hidden="true"` on these images, since they are not useful
to be read out.

I don't consider this a comprehensive review of all icons in the app,
but I knocked out all the low hanging fruit in this commit.
2025-12-11 16:35:17 -05:00

229 lines
7.7 KiB
TypeScript

import React, { useState, useMemo, useCallback } from 'react';
import { ChevronLeft, Trash2 } 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 CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
import { MCPPanelProvider, useMCPPanelContext } from '~/Providers';
import { useLocalize, useMCPConnectionStatus } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton';
function MCPPanelContent() {
const localize = useLocalize();
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const { conversationId } = useMCPPanelContext();
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
const { connectionStatus } = useMCPConnectionStatus({
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
});
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.invalidateQueries([QueryKeys.mcpTools]),
queryClient.invalidateQueries([QueryKeys.mcpAuthValues]),
queryClient.invalidateQueries([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 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];
const isConnected = serverStatus?.connectionState === 'connected';
return (
<div className="h-auto max-w-full space-y-4 overflow-x-hidden py-2">
<Button
variant="outline"
onClick={handleGoBackToList}
size="sm"
aria-label={localize('com_ui_back')}
>
<ChevronLeft className="mr-1 h-4 w-4" aria-hidden="true" />
{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
}
/>
{serverStatus?.requiresOAuth && isConnected && (
<Button
className="w-full"
size="sm"
variant="destructive"
onClick={() => handleConfigRevoke(selectedServerNameForEditing)}
aria-label={localize('com_ui_oauth_revoke')}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_oauth_revoke')}
</Button>
)}
</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)}
aria-label={localize('com_ui_edit') + ' ' + 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>
);
}