🔧 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:
Dustin Healy 2025-07-21 07:47:33 -07:00 committed by Danny Avila
parent b1e346a225
commit 5e2b6e8eb5
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
6 changed files with 168 additions and 202 deletions

View file

@ -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]) => ({

View file

@ -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}`);

View file

@ -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

View file

@ -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>
);
}

View file

@ -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,

View file

@ -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.",