mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 01:40:15 +01:00
🔧 refactor: Integrate MCPPanel with new MCP components
- **Unified UI Components**: Replace custom MCPVariableEditor with CustomUserVarsSection and ServerInitializationSection for consistent design across all MCP interfaces - **Real-Time Status Indicators**: Add live connection status badges and set/unset authentication pills to match MCPConfigDialog functionality - **Enhanced OAuth Support**: Integrate ServerInitializationSection for proper OAuth flow handling in side panel
This commit is contained in:
parent
b1e346a225
commit
5e2b6e8eb5
6 changed files with 168 additions and 202 deletions
|
|
@ -223,7 +223,6 @@ const getAvailableTools = async (req, res) => {
|
|||
// Handle MCP servers with customUserVars (user-level auth required)
|
||||
if (serverConfig.customUserVars) {
|
||||
logger.warn(`[getAvailableTools] Processing user-level MCP server: ${serverName}`);
|
||||
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
||||
|
||||
// Build authConfig for MCP tools
|
||||
toolToAdd.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
|
||||
|
|
|
|||
|
|
@ -542,7 +542,6 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
|
|||
}
|
||||
|
||||
let userConnection = null;
|
||||
let oauthRequired = false;
|
||||
|
||||
try {
|
||||
userConnection = await mcpManager.getUserConnection({
|
||||
|
|
@ -558,7 +557,6 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
|
|||
},
|
||||
oauthStart: (authURL) => {
|
||||
// This will be called if OAuth is required
|
||||
oauthRequired = true;
|
||||
responseSent = true;
|
||||
logger.info(`[MCP Reinitialize] OAuth required for ${serverName}, auth URL: ${authURL}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@ function MCPSelect() {
|
|||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { mcpSelect, startupConfig } = useBadgeRowContext();
|
||||
const { mcpValues, setMCPValues, mcpToolDetails, isPinned } = mcpSelect;
|
||||
const { mcpValues, setMCPValues, isPinned } = mcpSelect;
|
||||
|
||||
// Get real connection status from MCPManager
|
||||
const { data: statusQuery } = useMCPConnectionStatusQuery();
|
||||
|
||||
const mcpServerStatuses = statusQuery?.connectionStatus || {};
|
||||
const mcpServerStatuses = useMemo(
|
||||
() => statusQuery?.connectionStatus || {},
|
||||
[statusQuery?.connectionStatus],
|
||||
);
|
||||
|
||||
console.log('mcpServerStatuses', mcpServerStatuses);
|
||||
console.log('statusQuery', statusQuery);
|
||||
|
|
@ -34,15 +36,10 @@ function MCPSelect() {
|
|||
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
|
||||
|
||||
// Fetch auth values for the selected server
|
||||
const { data: authValuesData } = useMCPAuthValuesQuery(selectedToolForConfig?.name || '', {
|
||||
enabled: isConfigModalOpen && !!selectedToolForConfig?.name,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
|
||||
onSuccess: async (data, variables) => {
|
||||
onSuccess: async () => {
|
||||
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||
|
||||
// // For 'uninstall' actions (revoke), remove the server from selected values
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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 React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
useUpdateUserPluginsMutation,
|
||||
useReinitializeMCPServerMutation,
|
||||
|
|
@ -9,11 +8,17 @@ import {
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import type { TUpdateUserPlugins } from 'librechat-data-provider';
|
||||
import { Button, Input, Label } from '~/components/ui';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
|
||||
import MCPPanelSkeleton from './MCPPanelSkeleton';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import {
|
||||
CustomUserVarsSection,
|
||||
ServerInitializationSection,
|
||||
type ConfigFieldDetail,
|
||||
} from '~/components/ui/MCP';
|
||||
|
||||
interface ServerConfigWithVars {
|
||||
serverName: string;
|
||||
|
|
@ -33,6 +38,13 @@ export default function MCPPanel() {
|
|||
const reinitializeMCPMutation = useReinitializeMCPServerMutation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get real connection status from MCPManager
|
||||
const { data: statusQuery } = useMCPConnectionStatusQuery();
|
||||
const mcpServerStatuses = useMemo(
|
||||
() => statusQuery?.connectionStatus || {},
|
||||
[statusQuery?.connectionStatus],
|
||||
);
|
||||
|
||||
const mcpServerDefinitions = useMemo(() => {
|
||||
if (!startupConfig?.mcpServers) {
|
||||
return [];
|
||||
|
|
@ -53,40 +65,16 @@ export default function MCPPanel() {
|
|||
}, [startupConfig?.mcpServers]);
|
||||
|
||||
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
|
||||
onSuccess: async (data, variables) => {
|
||||
onSuccess: async () => {
|
||||
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||
|
||||
// Refetch tools query to refresh authentication state in the dropdown
|
||||
queryClient.refetchQueries([QueryKeys.tools]);
|
||||
|
||||
// For 'uninstall' actions (revoke), remove the server from selected values
|
||||
if (variables.action === 'uninstall') {
|
||||
const serverName = variables.pluginKey.replace(Constants.mcp_prefix, '');
|
||||
// Note: MCPPanel doesn't directly manage selected values, but this ensures
|
||||
// the tools query is refreshed so MCPSelect will pick up the changes
|
||||
}
|
||||
|
||||
// Only reinitialize for 'install' actions (save), not 'uninstall' actions (revoke)
|
||||
if (variables.action === 'install') {
|
||||
// Extract server name from pluginKey (e.g., "mcp_myServer" -> "myServer")
|
||||
const serverName = variables.pluginKey.replace(Constants.mcp_prefix, '');
|
||||
|
||||
// Reinitialize the MCP server to pick up the new authentication values
|
||||
try {
|
||||
await reinitializeMCPMutation.mutateAsync(serverName);
|
||||
console.log(
|
||||
`[MCP Panel] Successfully reinitialized server ${serverName} after auth update`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[MCP Panel] Error reinitializing server ${serverName} after auth update:`,
|
||||
error,
|
||||
);
|
||||
// Don't show error toast to user as the auth update was successful
|
||||
}
|
||||
}
|
||||
// For 'uninstall' actions (revoke), the backend already disconnects the connections
|
||||
// so no additional action is needed here
|
||||
// Wait for all queries to refetch before resolving loading state
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries([QueryKeys.tools]),
|
||||
queryClient.refetchQueries([QueryKeys.tools]),
|
||||
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]),
|
||||
queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]),
|
||||
]);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
console.error('Error updating MCP auth:', error);
|
||||
|
|
@ -137,7 +125,7 @@ export default function MCPPanel() {
|
|||
|
||||
// Check if OAuth is required
|
||||
if (response.oauthRequired) {
|
||||
if (response.authorizationUrl) {
|
||||
if (response.authURL) {
|
||||
// Show OAuth URL to user
|
||||
showToast({
|
||||
message: `OAuth required for ${serverName}. Please visit the authorization URL.`,
|
||||
|
|
@ -145,7 +133,7 @@ export default function MCPPanel() {
|
|||
});
|
||||
|
||||
// Open OAuth URL in new window/tab
|
||||
window.open(response.authorizationUrl, '_blank', 'noopener,noreferrer');
|
||||
window.open(response.authURL, '_blank', 'noopener,noreferrer');
|
||||
|
||||
// Show a more detailed message with the URL
|
||||
setTimeout(() => {
|
||||
|
|
@ -177,16 +165,16 @@ export default function MCPPanel() {
|
|||
console.error('Error reinitializing MCP server:', error);
|
||||
|
||||
// Check if the error response contains OAuth information
|
||||
if (error?.response?.data?.oauthRequired) {
|
||||
const errorData = error.response.data;
|
||||
if (errorData.authorizationUrl) {
|
||||
if ((error as any)?.response?.data?.oauthRequired) {
|
||||
const errorData = (error as any).response.data;
|
||||
if (errorData.authURL) {
|
||||
showToast({
|
||||
message: `OAuth required for ${serverName}. Please visit the authorization URL.`,
|
||||
status: 'info',
|
||||
});
|
||||
|
||||
// Open OAuth URL in new window/tab
|
||||
window.open(errorData.authorizationUrl, '_blank', 'noopener,noreferrer');
|
||||
window.open(errorData.authURL, '_blank', 'noopener,noreferrer');
|
||||
|
||||
setTimeout(() => {
|
||||
showToast({
|
||||
|
|
@ -217,6 +205,50 @@ export default function MCPPanel() {
|
|||
[showToast, reinitializeMCPMutation],
|
||||
);
|
||||
|
||||
// Create save and revoke handlers with latest state
|
||||
const handleSave = useCallback(
|
||||
(updatedValues: Record<string, string>) => {
|
||||
if (selectedServerNameForEditing) {
|
||||
handleSaveServerVars(selectedServerNameForEditing, updatedValues);
|
||||
}
|
||||
},
|
||||
[selectedServerNameForEditing, handleSaveServerVars],
|
||||
);
|
||||
|
||||
const handleRevoke = useCallback(() => {
|
||||
if (selectedServerNameForEditing) {
|
||||
handleRevokeServerVars(selectedServerNameForEditing);
|
||||
}
|
||||
}, [selectedServerNameForEditing, handleRevokeServerVars]);
|
||||
|
||||
// Prepare data for MCPConfigDialog
|
||||
const selectedServer = useMemo(() => {
|
||||
if (!selectedServerNameForEditing) return null;
|
||||
return mcpServerDefinitions.find((s) => s.serverName === selectedServerNameForEditing);
|
||||
}, [selectedServerNameForEditing, mcpServerDefinitions]);
|
||||
|
||||
const fieldsSchema = useMemo(() => {
|
||||
if (!selectedServer) return {};
|
||||
const schema: Record<string, ConfigFieldDetail> = {};
|
||||
Object.entries(selectedServer.config.customUserVars).forEach(([key, value]) => {
|
||||
schema[key] = {
|
||||
title: value.title,
|
||||
description: value.description,
|
||||
};
|
||||
});
|
||||
return schema;
|
||||
}, [selectedServer]);
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
if (!selectedServer) return {};
|
||||
// Initialize with empty strings for all fields
|
||||
const values: Record<string, string> = {};
|
||||
Object.keys(selectedServer.config.customUserVars).forEach((key) => {
|
||||
values[key] = '';
|
||||
});
|
||||
return values;
|
||||
}, [selectedServer]);
|
||||
|
||||
if (startupConfigLoading) {
|
||||
return <MCPPanelSkeleton />;
|
||||
}
|
||||
|
|
@ -229,21 +261,11 @@ export default function MCPPanel() {
|
|||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
if (selectedServerNameForEditing && selectedServer) {
|
||||
// Editing View - use MCPConfigDialog-style layout but inline
|
||||
const serverStatus = mcpServerStatuses[selectedServerNameForEditing];
|
||||
const isConnected = serverStatus?.connected || false;
|
||||
const requiresOAuth = serverStatus?.requiresOAuth || false;
|
||||
|
||||
return (
|
||||
<div className="h-auto max-w-full overflow-x-hidden p-3">
|
||||
|
|
@ -255,30 +277,77 @@ 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 })}
|
||||
|
||||
{/* Header with status */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<h3 className="text-lg font-medium">
|
||||
{localize('com_sidepanel_mcp_variables_for', { '0': selectedServer.serverName })}
|
||||
</h3>
|
||||
<MCPVariableEditor
|
||||
server={serverBeingEdited}
|
||||
onSave={handleSaveServerVars}
|
||||
onRevoke={handleRevokeServerVars}
|
||||
{isConnected && (
|
||||
<div className="flex items-center gap-2 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900 dark:text-green-300">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<span>{localize('com_ui_active')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{Object.keys(fieldsSchema).length > 0
|
||||
? localize('com_ui_mcp_dialog_desc')
|
||||
: `Manage connection and settings for the ${selectedServer.serverName} MCP server.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content sections */}
|
||||
<div className="space-y-6">
|
||||
{/* Custom User Variables Section */}
|
||||
{Object.keys(fieldsSchema).length > 0 && (
|
||||
<div>
|
||||
<CustomUserVarsSection
|
||||
serverName={selectedServer.serverName}
|
||||
fields={fieldsSchema}
|
||||
onSave={handleSave}
|
||||
onRevoke={handleRevoke}
|
||||
isSubmitting={updateUserPluginsMutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server Initialization Section */}
|
||||
<ServerInitializationSection
|
||||
serverName={selectedServer.serverName}
|
||||
requiresOAuth={requiresOAuth}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Server List View
|
||||
return (
|
||||
<div className="h-auto max-w-full overflow-x-hidden p-3">
|
||||
<div className="space-y-2">
|
||||
{mcpServerDefinitions.map((server) => (
|
||||
{mcpServerDefinitions.map((server) => {
|
||||
const serverStatus = mcpServerStatuses[server.serverName];
|
||||
const isConnected = serverStatus?.connected || false;
|
||||
|
||||
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)}
|
||||
>
|
||||
{server.serverName}
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span>{server.serverName}</span>
|
||||
{isConnected && (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
{localize('com_ui_active')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -293,102 +362,10 @@ export default function MCPPanel() {
|
|||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import React, { useMemo, useCallback } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
|
||||
import { CustomUserVarsSection, ServerInitializationSection } from './';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
|
||||
import {
|
||||
OGDialog,
|
||||
|
|
@ -46,10 +44,9 @@ export default function MCPConfigDialog({
|
|||
serverName,
|
||||
}: MCPConfigDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get connection status to determine OAuth requirements with aggressive refresh
|
||||
const { data: statusQuery, refetch: refetchConnectionStatus } = useMCPConnectionStatusQuery({
|
||||
const { data: statusQuery } = useMCPConnectionStatusQuery({
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 0,
|
||||
|
|
|
|||
|
|
@ -967,8 +967,6 @@
|
|||
"com_ui_saving": "Saving...",
|
||||
"com_ui_set": "Set",
|
||||
"com_ui_unset": "Unset",
|
||||
"com_ui_configuration": "Configuration",
|
||||
"com_ui_mcp_auth_desc": "Configure authentication credentials for this MCP server.",
|
||||
"com_ui_authorization_url": "Authorization URL",
|
||||
"com_ui_continue_oauth": "Continue OAuth Flow",
|
||||
"com_ui_oauth_flow_desc": "Click the button above to continue the OAuth flow in a new tab.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue