mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 09:20:15 +01:00
🔌 feat: MCP Reinitialization and OAuth in UI (#8598)
* ✨ feat: Add connection status endpoint for MCP servers
- Implemented a new endpoint to retrieve the connection status of all MCP servers without disconnecting idle connections.
- Enhanced MCPManager class with a method to get all user-specific connections.
* feat: add silencer arg to loadCustomConfig function to conditionally print config details
- Modified loadCustomConfig to accept a printConfig parameter that allows me to prevent the entire custom config being printed every time it is called
* fix: new status endpoint actually works now, changes to manager.ts to support it
- Updated the connection status endpoint to utilize Maps for app and user connections, rather than incorrectly treating them as objects.
- Introduced a new method + variable in MCPManager to track servers requiring OAuth discovered at startup.
- Stopped OAuth flow from continuing once detected during startup for a new connection
* refactor: Remove hasAuthConfig since we can get that on the frontend without needing to use the endpoint
* feat: Add MCP connection status query and query key for new endpoint
- Introduced a new query hook `useMCPConnectionStatusQuery` to fetch the connection status of MCP servers.
- Added request in data-service
- Defined the API endpoint for retrieving MCP connection status in api-endpoints.ts.
- Defined new types for MCP connection status responses in the types module.
- Added mcpConnectionStatus key
* feat: Enhance MCPSelect component with connection status and server configuration
- Added connection status handling for MCP servers using the new `useMCPConnectionStatusQuery` hook.
- Implemented logic to display appropriate status icons based on connection state and authentication configuration.
- Updated the server selection logic to utilize configured MCP servers from the startup configuration.
- Refactored the rendering of configuration buttons and status indicators for improved user interaction.
* refactor: move MCPConfigDialog to its own MCP subdir in ui and update import
* refactor: silence loadCustomConfig in status endpoint
* feat: Add optional pluginKey parameter to getUserPluginAuthValue
* feat: Add MCP authentication values endpoint and related queries
- Implemented a new endpoint to check authentication value flags for specific MCP servers, returning boolean indicators for each custom user variable.
- Added a corresponding query hook `useMCPAuthValuesQuery` to fetch authentication values from the frontend.
- Defined the API endpoint for retrieving MCP authentication values in api-endpoints.ts.
- Updated data-service to include a method for fetching MCP authentication values.
- Introduced new types for MCP authentication values responses in the types module.
- Added a new query key for MCP authentication values.
* feat: Localize MCPSelect component status labels and aria attributes
- Updated the MCPSelect component to use localized strings for connection status labels and aria attributes, enhancing accessibility and internationalization support.
- Added new translation keys for various connection states in the translation.json file.
* feat: Implement filtered MCP values selection based on connection status in MCPSelect
- Added a new `filteredSetMCPValues` function to ensure only connected servers are selectable in the MCPSelect component.
- Updated the rendering logic to visually indicate the connection status of servers by adjusting opacity.
- Enhanced accessibility by localizing the aria-label for the configuration button.
* feat: Add CustomUserVarsSection component for managing user variables
- Introduced a new `CustomUserVarsSection` component to allow users to configure custom variables for MCP servers.
- Integrated localization for user interface elements and added new translation keys for variable management.
- Added functionality to save and revoke user variables, with visual indicators for set/unset states.
* feat: Enhance MCPSelect and MCPConfigDialog with improved state management and UI updates
- Integrated `useQueryClient` to refetch queries for tools, authentication values, and connection status upon successful plugin updates in MCPSelect.
- Simplified plugin key handling by directly using the formatted plugin key in save and revoke operations.
- Updated MCPConfigDialog to include server status indicators and improved dialog content structure for better user experience.
- Added new translation key for active status in the localization files.
* feat: Enhance MCPConfigDialog with dynamic server status badges and localization updates
- Added a helper function to render status badges based on the connection state of the MCP server, improving user feedback on connection status.
- Updated the localization files to include new translation keys for connection states such as "Connecting" and "Offline".
- Refactored the dialog to utilize the new status rendering function for better code organization and readability.
* feat: Implement OAuth handling and server initialization in MCP reinitialize flow
- Added OAuth handling to the MCP reinitialize endpoint, allowing the server to capture and return OAuth URLs when required.
- Updated the MCPConfigDialog to include a new ServerInitializationSection for managing server initialization and OAuth flow.
- Enhanced the user experience by providing feedback on server status and OAuth requirements through localized messages.
- Introduced new translation keys for OAuth-related messages in the localization files.
- Refactored the MCPSelect component to remove unused authentication configuration props.
* feat: Make OAuth actually work / update after OAuth link authorized
- Improved the handling of OAuth flows in the MCP reinitialize process, allowing for immediate return when OAuth is initiated.
- Updated the UserController to extract server names from plugin keys for better logging and connection management.
- Enhanced the MCPSelect component to reflect authentication status based on OAuth requirements.
- Implemented polling for OAuth completion in the ServerInitializationSection to improve user feedback during the connection process.
- Refactored MCPManager to support new OAuth flow initiation logic and connection handling.
* refactor: Simplify MCPPanel component and enhance server status display
- Removed unused imports and state management related to user plugins and server reinitialization.
- Integrated connection status handling directly into the MCPPanel for improved user feedback.
- Updated the rendering logic to display server connection states with visual indicators.
- Refactored the editing view to utilize new components for server initialization and custom user variables management.
* chore: remove comments
* chore: remove unused translation key for MCP panel
* refactor: Rename returnOnOAuthInitiated to returnOnOAuth for clarity
* refactor: attempt initialize on server click
* feat: add cancel OAuth flow functionality and related UI updates
* refactor: move server status icon logic into its own component
* chore: remove old localization strings (makes more sense for icon labels to just use configure stirng since thats where it leads to)
* fix: fix accessibility issues with MCPSelect
* fix: add missing save/revoke mutation logic to MCPPanel
* styling: add margin to checkmark in MultiSelect
* fix: add back in customUserVars check to hide gear config icon for servers without customUserVars
---------
Co-authored-by: Dustin Healy <dustinhealy1@gmail.com>
Co-authored-by: Dustin Healy <54083382+dustinhealy@users.noreply.github.com>
This commit is contained in:
parent
62c3f135e7
commit
74d8a3824c
23 changed files with 1812 additions and 450 deletions
|
|
@ -1,60 +1,40 @@
|
|||
import { Constants } from 'librechat-data-provider';
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
useUpdateUserPluginsMutation,
|
||||
useReinitializeMCPServerMutation,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Constants, QueryKeys } from 'librechat-data-provider';
|
||||
import type { TUpdateUserPlugins } from 'librechat-data-provider';
|
||||
import { Button, Input, Label } from '~/components/ui';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import ServerInitializationSection from '~/components/ui/MCP/ServerInitializationSection';
|
||||
import CustomUserVarsSection from '~/components/ui/MCP/CustomUserVarsSection';
|
||||
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import MCPPanelSkeleton from './MCPPanelSkeleton';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface ServerConfigWithVars {
|
||||
serverName: string;
|
||||
config: {
|
||||
customUserVars: Record<string, { title: string; description: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export default function MCPPanel() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
|
||||
const { data: connectionStatusData } = useMCPConnectionStatusQuery();
|
||||
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [rotatingServers, setRotatingServers] = useState<Set<string>>(new Set());
|
||||
const reinitializeMCPMutation = useReinitializeMCPServerMutation();
|
||||
|
||||
const mcpServerDefinitions = useMemo(() => {
|
||||
if (!startupConfig?.mcpServers) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(startupConfig.mcpServers)
|
||||
.filter(
|
||||
([, serverConfig]) =>
|
||||
serverConfig.customUserVars && Object.keys(serverConfig.customUserVars).length > 0,
|
||||
)
|
||||
.map(([serverName, config]) => ({
|
||||
serverName,
|
||||
iconPath: null,
|
||||
config: {
|
||||
...config,
|
||||
customUserVars: config.customUserVars ?? {},
|
||||
},
|
||||
}));
|
||||
}, [startupConfig?.mcpServers]);
|
||||
|
||||
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
|
||||
onSuccess: () => {
|
||||
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) => {
|
||||
console.error('Error updating MCP custom user variables:', error);
|
||||
onError: (error: unknown) => {
|
||||
console.error('Error updating MCP auth:', error);
|
||||
showToast({
|
||||
message: localize('com_nav_mcp_vars_update_error'),
|
||||
status: 'error',
|
||||
|
|
@ -62,28 +42,23 @@ export default function MCPPanel() {
|
|||
},
|
||||
});
|
||||
|
||||
const handleSaveServerVars = useCallback(
|
||||
(serverName: string, updatedValues: Record<string, string>) => {
|
||||
const payload: TUpdateUserPlugins = {
|
||||
pluginKey: `${Constants.mcp_prefix}${serverName}`,
|
||||
action: 'install', // 'install' action is used to set/update credentials/variables
|
||||
auth: updatedValues,
|
||||
};
|
||||
updateUserPluginsMutation.mutate(payload);
|
||||
},
|
||||
[updateUserPluginsMutation],
|
||||
);
|
||||
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 handleRevokeServerVars = useCallback(
|
||||
(serverName: string) => {
|
||||
const payload: TUpdateUserPlugins = {
|
||||
pluginKey: `${Constants.mcp_prefix}${serverName}`,
|
||||
action: 'uninstall', // 'uninstall' action clears the variables
|
||||
auth: {}, // Empty auth for uninstall
|
||||
};
|
||||
updateUserPluginsMutation.mutate(payload);
|
||||
},
|
||||
[updateUserPluginsMutation],
|
||||
const connectionStatus = useMemo(
|
||||
() => connectionStatusData?.connectionStatus || {},
|
||||
[connectionStatusData?.connectionStatus],
|
||||
);
|
||||
|
||||
const handleServerClickToEdit = (serverName: string) => {
|
||||
|
|
@ -94,30 +69,31 @@ export default function MCPPanel() {
|
|||
setSelectedServerNameForEditing(null);
|
||||
};
|
||||
|
||||
const handleReinitializeServer = useCallback(
|
||||
async (serverName: string) => {
|
||||
setRotatingServers((prev) => new Set(prev).add(serverName));
|
||||
try {
|
||||
await reinitializeMCPMutation.mutateAsync(serverName);
|
||||
showToast({
|
||||
message: `MCP server '${serverName}' reinitialized successfully`,
|
||||
status: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reinitializing MCP server:', error);
|
||||
showToast({
|
||||
message: 'Failed to reinitialize MCP server',
|
||||
status: 'error',
|
||||
});
|
||||
} finally {
|
||||
setRotatingServers((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(serverName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
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);
|
||||
},
|
||||
[showToast, reinitializeMCPMutation],
|
||||
[updateUserPluginsMutation],
|
||||
);
|
||||
|
||||
const handleConfigRevoke = useCallback(
|
||||
(targetName: string) => {
|
||||
const payload: TUpdateUserPlugins = {
|
||||
pluginKey: `${Constants.mcp_prefix}${targetName}`,
|
||||
action: 'uninstall',
|
||||
auth: {},
|
||||
};
|
||||
updateUserPluginsMutation.mutate(payload);
|
||||
},
|
||||
[updateUserPluginsMutation],
|
||||
);
|
||||
|
||||
if (startupConfigLoading) {
|
||||
|
|
@ -148,6 +124,8 @@ export default function MCPPanel() {
|
|||
);
|
||||
}
|
||||
|
||||
const serverStatus = connectionStatus[selectedServerNameForEditing];
|
||||
|
||||
return (
|
||||
<div className="h-auto max-w-full overflow-x-hidden p-3">
|
||||
<Button
|
||||
|
|
@ -158,13 +136,33 @@ export default function MCPPanel() {
|
|||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
{localize('com_ui_back')}
|
||||
</Button>
|
||||
|
||||
<h3 className="mb-3 text-lg font-medium">
|
||||
{localize('com_sidepanel_mcp_variables_for', { '0': serverBeingEdited.serverName })}
|
||||
</h3>
|
||||
<MCPVariableEditor
|
||||
server={serverBeingEdited}
|
||||
onSave={handleSaveServerVars}
|
||||
onRevoke={handleRevokeServerVars}
|
||||
|
||||
{/* Server Initialization Section */}
|
||||
<div className="mb-4">
|
||||
<ServerInitializationSection
|
||||
serverName={selectedServerNameForEditing}
|
||||
requiresOAuth={serverStatus?.requiresOAuth || false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom User Variables Section */}
|
||||
<CustomUserVarsSection
|
||||
serverName={selectedServerNameForEditing}
|
||||
fields={serverBeingEdited.config.customUserVars}
|
||||
onSave={(authData) => {
|
||||
if (selectedServerNameForEditing) {
|
||||
handleConfigSave(selectedServerNameForEditing, authData);
|
||||
}
|
||||
}}
|
||||
onRevoke={() => {
|
||||
if (selectedServerNameForEditing) {
|
||||
handleConfigRevoke(selectedServerNameForEditing);
|
||||
}
|
||||
}}
|
||||
isSubmitting={updateUserPluginsMutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -174,124 +172,37 @@ export default function MCPPanel() {
|
|||
return (
|
||||
<div className="h-auto max-w-full overflow-x-hidden p-3">
|
||||
<div className="space-y-2">
|
||||
{mcpServerDefinitions.map((server) => (
|
||||
<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)}
|
||||
>
|
||||
{server.serverName}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleReinitializeServer(server.serverName)}
|
||||
className="px-2 py-1"
|
||||
title="Reinitialize MCP server"
|
||||
disabled={reinitializeMCPMutation.isLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${rotatingServers.has(server.serverName) ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{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 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Inner component for the form - remains the same
|
||||
interface MCPVariableEditorProps {
|
||||
server: ServerConfigWithVars;
|
||||
onSave: (serverName: string, updatedValues: Record<string, string>) => void;
|
||||
onRevoke: (serverName: string) => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function MCPVariableEditor({ server, onSave, onRevoke, isSubmitting }: MCPVariableEditorProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<Record<string, string>>({
|
||||
defaultValues: {}, // Initialize empty, will be reset by useEffect
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Always initialize with empty strings based on the schema
|
||||
const initialFormValues = Object.keys(server.config.customUserVars).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
reset(initialFormValues);
|
||||
}, [reset, server.config.customUserVars]);
|
||||
|
||||
const onFormSubmit = (data: Record<string, string>) => {
|
||||
onSave(server.serverName, data);
|
||||
};
|
||||
|
||||
const handleRevokeClick = () => {
|
||||
onRevoke(server.serverName);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-4 mt-2 space-y-4">
|
||||
{Object.entries(server.config.customUserVars).map(([key, details]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={`${server.serverName}-${key}`} className="text-sm font-medium">
|
||||
{details.title}
|
||||
</Label>
|
||||
<Controller
|
||||
name={key}
|
||||
control={control}
|
||||
defaultValue={''}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id={`${server.serverName}-${key}`}
|
||||
type="text"
|
||||
{...field}
|
||||
placeholder={localize('com_sidepanel_mcp_enter_value', { '0': details.title })}
|
||||
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{details.description && (
|
||||
<p
|
||||
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:text-blue-600 dark:[&_a]:text-blue-400 dark:[&_a]:hover:text-blue-300"
|
||||
dangerouslySetInnerHTML={{ __html: details.description }}
|
||||
/>
|
||||
)}
|
||||
{errors[key] && <p className="text-xs text-red-500">{errors[key]?.message}</p>}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
{Object.keys(server.config.customUserVars).length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleRevokeClick}
|
||||
className="bg-red-600 text-white hover:bg-red-700 dark:hover:bg-red-800"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{localize('com_ui_revoke')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-green-500 text-white hover:bg-green-600"
|
||||
disabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue