mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-23 11:50:14 +01:00
🗝️ feat: User Provided Credentials for MCP Servers (#7980)
* 🗝️ feat: Per-User Credentials for MCP Servers
chore: add aider to gitignore
feat: fill custom variables to MCP server
feat: replace placeholders with custom user MCP variables
feat: handle MCP install/uninstall (uses pluginauths)
feat: add MCP custom variables dialog to MCPSelect
feat: add MCP custom variables dialog to the side panel
feat: do not require to fill MCP credentials for in tools dialog
feat: add translations keys (en+cs) for custom MCP variables
fix: handle LIBRECHAT_USER_ID correctly during MCP var replacement
style: remove unused MCP translation keys
style: fix eslint for MCP custom vars
chore: move aider gitignore to AI section
* feat: Add Plugin Authentication Methods to data-schemas
* refactor: Replace PluginAuth model methods with new utility functions for improved code organization and maintainability
* refactor: Move IPluginAuth interface to types directory for better organization and update pluginAuth schema to use the new import
* refactor: Remove unused getUsersPluginsAuthValuesMap function and streamline PluginService.js; add new getPluginAuthMap function for improved plugin authentication handling
* chore: fix typing for optional tools property with GenericTool[] type
* chore: update librechat-data-provider version to 0.7.88
* refactor: optimize getUserMCPAuthMap function by reducing variable usage and improving server key collection logic
* refactor: streamline MCP tool creation by removing customUserVars parameter and enhancing user-specific authentication handling to avoid closure encapsulation
* refactor: extract processSingleValue function to streamline MCP environment variable processing and enhance readability
* refactor: enhance MCP tool processing logic by simplifying conditions and improving authentication handling for custom user variables
* ci: fix action tests
* chore: fix imports, remove comments
* chore: remove non-english translations
* fix: remove newline at end of translation.json file
---------
Co-authored-by: Aleš Kůtek <kutekales@gmail.com>
This commit is contained in:
parent
8b15bb2ed6
commit
3e4b01de82
36 changed files with 1536 additions and 166 deletions
|
|
@ -1,13 +1,31 @@
|
|||
import React, { memo, useRef, useMemo, useEffect, useCallback } from 'react';
|
||||
import React, { memo, useRef, useMemo, useEffect, useCallback, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import type { TPlugin, TPluginAuthConfig, TUpdateUserPlugins } from 'librechat-data-provider';
|
||||
import MCPConfigDialog, { type ConfigFieldDetail } from '~/components/ui/MCPConfigDialog';
|
||||
import { useAvailableToolsQuery } from '~/data-provider';
|
||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||
import MultiSelect from '~/components/ui/MultiSelect';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import MCPIcon from '~/components/ui/MCPIcon';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface McpServerInfo {
|
||||
name: string;
|
||||
pluginKey: string;
|
||||
authConfig?: TPluginAuthConfig[];
|
||||
authenticated?: boolean;
|
||||
}
|
||||
|
||||
// Helper function to extract mcp_serverName from a full pluginKey like action_mcp_serverName
|
||||
const getBaseMCPPluginKey = (fullPluginKey: string): string => {
|
||||
const parts = fullPluginKey.split(Constants.mcp_delimiter);
|
||||
return Constants.mcp_prefix + parts[parts.length - 1];
|
||||
};
|
||||
|
||||
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||
if (rawCurrentValue) {
|
||||
try {
|
||||
|
|
@ -24,20 +42,45 @@ const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
|||
|
||||
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
const hasSetFetched = useRef<string | null>(null);
|
||||
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||
const [selectedToolForConfig, setSelectedToolForConfig] = useState<McpServerInfo | null>(null);
|
||||
|
||||
const { data: mcpServerSet, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
select: (data) => {
|
||||
const serverNames = new Set<string>();
|
||||
const { data: mcpToolDetails, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
select: (data: TPlugin[]) => {
|
||||
const mcpToolsMap = new Map<string, McpServerInfo>();
|
||||
data.forEach((tool) => {
|
||||
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
|
||||
if (isMCP && tool.chatMenu !== false) {
|
||||
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
||||
serverNames.add(parts[parts.length - 1]);
|
||||
const serverName = parts[parts.length - 1];
|
||||
if (!mcpToolsMap.has(serverName)) {
|
||||
mcpToolsMap.set(serverName, {
|
||||
name: serverName,
|
||||
pluginKey: tool.pluginKey,
|
||||
authConfig: tool.authConfig,
|
||||
authenticated: tool.authenticated,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return serverNames;
|
||||
return Array.from(mcpToolsMap.values());
|
||||
},
|
||||
});
|
||||
|
||||
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
|
||||
onSuccess: () => {
|
||||
setIsConfigModalOpen(false);
|
||||
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
console.error('Error updating MCP auth:', error);
|
||||
showToast({
|
||||
message: localize('com_nav_mcp_vars_update_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -76,12 +119,12 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
|||
return;
|
||||
}
|
||||
hasSetFetched.current = key;
|
||||
if ((mcpServerSet?.size ?? 0) > 0) {
|
||||
setMCPValues(mcpValues.filter((mcp) => mcpServerSet?.has(mcp)));
|
||||
if ((mcpToolDetails?.length ?? 0) > 0) {
|
||||
setMCPValues(mcpValues.filter((mcp) => mcpToolDetails?.some((tool) => tool.name === mcp)));
|
||||
return;
|
||||
}
|
||||
setMCPValues([]);
|
||||
}, [isFetched, setMCPValues, mcpServerSet, key, mcpValues]);
|
||||
}, [isFetched, setMCPValues, mcpToolDetails, key, mcpValues]);
|
||||
|
||||
const renderSelectedValues = useCallback(
|
||||
(values: string[], placeholder?: string) => {
|
||||
|
|
@ -96,28 +139,140 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
|||
[localize],
|
||||
);
|
||||
|
||||
const mcpServers = useMemo(() => {
|
||||
return Array.from(mcpServerSet ?? []);
|
||||
}, [mcpServerSet]);
|
||||
const mcpServerNames = useMemo(() => {
|
||||
return (mcpToolDetails ?? []).map((tool) => tool.name);
|
||||
}, [mcpToolDetails]);
|
||||
|
||||
if (!mcpServerSet || mcpServerSet.size === 0) {
|
||||
const handleConfigSave = useCallback(
|
||||
(targetName: string, authData: Record<string, string>) => {
|
||||
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
|
||||
const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey);
|
||||
|
||||
const payload: TUpdateUserPlugins = {
|
||||
pluginKey: basePluginKey,
|
||||
action: 'install',
|
||||
auth: authData,
|
||||
};
|
||||
updateUserPluginsMutation.mutate(payload);
|
||||
}
|
||||
},
|
||||
[selectedToolForConfig, updateUserPluginsMutation],
|
||||
);
|
||||
|
||||
const handleConfigRevoke = useCallback(
|
||||
(targetName: string) => {
|
||||
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
|
||||
const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey);
|
||||
|
||||
const payload: TUpdateUserPlugins = {
|
||||
pluginKey: basePluginKey,
|
||||
action: 'uninstall',
|
||||
auth: {},
|
||||
};
|
||||
updateUserPluginsMutation.mutate(payload);
|
||||
}
|
||||
},
|
||||
[selectedToolForConfig, updateUserPluginsMutation],
|
||||
);
|
||||
|
||||
const renderItemContent = useCallback(
|
||||
(serverName: string, defaultContent: React.ReactNode) => {
|
||||
const tool = mcpToolDetails?.find((t) => t.name === serverName);
|
||||
const hasAuthConfig = tool?.authConfig && tool.authConfig.length > 0;
|
||||
|
||||
// Common wrapper for the main content (check mark + text)
|
||||
// Ensures Check & Text are adjacent and the group takes available space.
|
||||
const mainContentWrapper = (
|
||||
<div className="flex flex-grow items-center">{defaultContent}</div>
|
||||
);
|
||||
|
||||
if (tool && hasAuthConfig) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{mainContentWrapper}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setSelectedToolForConfig(tool);
|
||||
setIsConfigModalOpen(true);
|
||||
}}
|
||||
className="ml-2 flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
aria-label={`Configure ${serverName}`}
|
||||
>
|
||||
<Settings2 className={`h-4 w-4 ${tool.authenticated ? 'text-green-500' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// For items without a settings icon, return the consistently wrapped main content.
|
||||
return mainContentWrapper;
|
||||
},
|
||||
[mcpToolDetails, setSelectedToolForConfig, setIsConfigModalOpen],
|
||||
);
|
||||
|
||||
if (!mcpToolDetails || mcpToolDetails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
items={mcpServers ?? []}
|
||||
selectedValues={mcpValues ?? []}
|
||||
setSelectedValues={setMCPValues}
|
||||
defaultSelectedValues={mcpValues ?? []}
|
||||
renderSelectedValues={renderSelectedValues}
|
||||
placeholder={localize('com_ui_mcp_servers')}
|
||||
popoverClassName="min-w-fit"
|
||||
className="badge-icon min-w-fit"
|
||||
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
||||
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
||||
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
||||
/>
|
||||
<>
|
||||
<MultiSelect
|
||||
items={mcpServerNames}
|
||||
selectedValues={mcpValues ?? []}
|
||||
setSelectedValues={setMCPValues}
|
||||
defaultSelectedValues={mcpValues ?? []}
|
||||
renderSelectedValues={renderSelectedValues}
|
||||
renderItemContent={renderItemContent}
|
||||
placeholder={localize('com_ui_mcp_servers')}
|
||||
popoverClassName="min-w-fit"
|
||||
className="badge-icon min-w-fit"
|
||||
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
||||
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
||||
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
||||
/>
|
||||
{selectedToolForConfig && (
|
||||
<MCPConfigDialog
|
||||
isOpen={isConfigModalOpen}
|
||||
onOpenChange={setIsConfigModalOpen}
|
||||
serverName={selectedToolForConfig.name}
|
||||
fieldsSchema={(() => {
|
||||
const schema: Record<string, ConfigFieldDetail> = {};
|
||||
if (selectedToolForConfig?.authConfig) {
|
||||
selectedToolForConfig.authConfig.forEach((field) => {
|
||||
schema[field.authField] = {
|
||||
title: field.label,
|
||||
description: field.description,
|
||||
};
|
||||
});
|
||||
}
|
||||
return schema;
|
||||
})()}
|
||||
initialValues={(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
// Note: Actual initial values might need to be fetched if they are stored user-specifically
|
||||
if (selectedToolForConfig?.authConfig) {
|
||||
selectedToolForConfig.authConfig.forEach((field) => {
|
||||
initial[field.authField] = ''; // Or fetched value
|
||||
});
|
||||
}
|
||||
return initial;
|
||||
})()}
|
||||
onSave={(authData) => {
|
||||
if (selectedToolForConfig) {
|
||||
handleConfigSave(selectedToolForConfig.name, authData);
|
||||
}
|
||||
}}
|
||||
onRevoke={() => {
|
||||
if (selectedToolForConfig) {
|
||||
handleConfigRevoke(selectedToolForConfig.name);
|
||||
}
|
||||
}}
|
||||
isSubmitting={updateUserPluginsMutation.isLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
253
client/src/components/SidePanel/MCP/MCPPanel.tsx
Normal file
253
client/src/components/SidePanel/MCP/MCPPanel.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TUpdateUserPlugins } from 'librechat-data-provider';
|
||||
import { Button, Input, Label } from '~/components/ui';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import MCPPanelSkeleton from './MCPPanelSkeleton';
|
||||
import { useToastContext } from '~/Providers';
|
||||
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 { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
|
||||
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
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: () => {
|
||||
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error updating MCP custom user variables:', error);
|
||||
showToast({
|
||||
message: localize('com_nav_mcp_vars_update_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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 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 handleServerClickToEdit = (serverName: string) => {
|
||||
setSelectedServerNameForEditing(serverName);
|
||||
};
|
||||
|
||||
const handleGoBackToList = () => {
|
||||
setSelectedServerNameForEditing(null);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-auto max-w-full overflow-x-hidden p-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGoBackToList}
|
||||
className="mb-3 flex items-center px-3 py-2 text-sm"
|
||||
>
|
||||
<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}
|
||||
isSubmitting={updateUserPluginsMutation.isLoading}
|
||||
/>
|
||||
</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) => (
|
||||
<Button
|
||||
key={server.serverName}
|
||||
variant="outline"
|
||||
className="w-full justify-start dark:hover:bg-gray-700"
|
||||
onClick={() => handleServerClickToEdit(server.serverName)}
|
||||
>
|
||||
{server.serverName}
|
||||
</Button>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
21
client/src/components/SidePanel/MCP/MCPPanelSkeleton.tsx
Normal file
21
client/src/components/SidePanel/MCP/MCPPanelSkeleton.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import { Skeleton } from '~/components/ui';
|
||||
|
||||
export default function MCPPanelSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 p-2">
|
||||
{[1, 2].map((serverIdx) => (
|
||||
<div key={serverIdx} className="space-y-4">
|
||||
<Skeleton className="h-6 w-1/3 rounded-lg" /> {/* Server Name */}
|
||||
{[1, 2].map((varIdx) => (
|
||||
<div key={varIdx} className="space-y-2">
|
||||
<Skeleton className="h-5 w-1/4 rounded-lg" /> {/* Variable Title */}
|
||||
<Skeleton className="h-8 w-full rounded-lg" /> {/* Input Field */}
|
||||
<Skeleton className="h-4 w-2/3 rounded-lg" /> {/* Description */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { Constants, isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import type {
|
||||
|
|
@ -125,16 +125,23 @@ function ToolSelectDialog({
|
|||
const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey);
|
||||
setSelectedPlugin(getAvailablePluginFromKey);
|
||||
|
||||
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
|
||||
const isMCPTool = pluginKey.includes(Constants.mcp_delimiter);
|
||||
|
||||
if (authConfig && authConfig.length > 0 && !authenticated) {
|
||||
setShowPluginAuthForm(true);
|
||||
if (isMCPTool) {
|
||||
// MCP tools have their variables configured elsewhere (e.g., MCPPanel or MCPSelect),
|
||||
// so we directly proceed to install without showing the auth form.
|
||||
handleInstall({ pluginKey, action: 'install', auth: {} });
|
||||
} else {
|
||||
handleInstall({
|
||||
pluginKey,
|
||||
action: 'install',
|
||||
auth: {},
|
||||
});
|
||||
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
|
||||
if (authConfig && authConfig.length > 0 && !authenticated) {
|
||||
setShowPluginAuthForm(true);
|
||||
} else {
|
||||
handleInstall({
|
||||
pluginKey,
|
||||
action: 'install',
|
||||
auth: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
122
client/src/components/ui/MCPConfigDialog.tsx
Normal file
122
client/src/components/ui/MCPConfigDialog.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Input, Label, OGDialog, Button } from '~/components/ui';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export interface ConfigFieldDetail {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface MCPConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
fieldsSchema: Record<string, ConfigFieldDetail>;
|
||||
initialValues: Record<string, string>;
|
||||
onSave: (updatedValues: Record<string, string>) => void;
|
||||
isSubmitting?: boolean;
|
||||
onRevoke?: () => void;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
export default function MCPConfigDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
fieldsSchema,
|
||||
initialValues,
|
||||
onSave,
|
||||
isSubmitting = false,
|
||||
onRevoke,
|
||||
serverName,
|
||||
}: MCPConfigDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, _ },
|
||||
} = useForm<Record<string, string>>({
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
reset(initialValues);
|
||||
}
|
||||
}, [isOpen, initialValues, reset]);
|
||||
|
||||
const onFormSubmit = (data: Record<string, string>) => {
|
||||
onSave(data);
|
||||
};
|
||||
|
||||
const handleRevoke = () => {
|
||||
if (onRevoke) {
|
||||
onRevoke();
|
||||
}
|
||||
};
|
||||
|
||||
const dialogTitle = localize('com_ui_configure_mcp_variables_for', { 0: serverName });
|
||||
const dialogDescription = localize('com_ui_mcp_dialog_desc');
|
||||
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogTemplate
|
||||
className="sm:max-w-lg"
|
||||
title={dialogTitle}
|
||||
description={dialogDescription}
|
||||
headerClassName="px-6 pt-6 pb-4"
|
||||
main={
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4 px-6 pb-2">
|
||||
{Object.entries(fieldsSchema).map(([key, details]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key} className="text-sm font-medium">
|
||||
{details.title}
|
||||
</Label>
|
||||
<Controller
|
||||
name={key}
|
||||
control={control}
|
||||
defaultValue={initialValues[key] || ''}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id={key}
|
||||
type="text"
|
||||
{...field}
|
||||
placeholder={localize('com_ui_mcp_enter_var', { 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>
|
||||
))}
|
||||
</form>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: handleSubmit(onFormSubmit),
|
||||
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
|
||||
selectText: isSubmitting ? localize('com_ui_saving') : localize('com_ui_save'),
|
||||
}}
|
||||
buttons={
|
||||
onRevoke && (
|
||||
<Button
|
||||
onClick={handleRevoke}
|
||||
className="bg-red-600 text-white hover:bg-red-700 dark:hover:bg-red-800"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{localize('com_ui_revoke')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
footerClassName="flex justify-end gap-2 px-6 pb-6 pt-2"
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,11 @@ interface MultiSelectProps<T extends string> {
|
|||
selectItemsClassName?: string;
|
||||
selectedValues: T[];
|
||||
setSelectedValues: (values: T[]) => void;
|
||||
renderItemContent?: (
|
||||
value: T,
|
||||
defaultContent: React.ReactNode,
|
||||
isSelected: boolean,
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
function defaultRender<T extends string>(values: T[], placeholder?: string) {
|
||||
|
|
@ -54,9 +59,9 @@ export default function MultiSelect<T extends string>({
|
|||
selectItemsClassName,
|
||||
selectedValues = [],
|
||||
setSelectedValues,
|
||||
renderItemContent,
|
||||
}: MultiSelectProps<T>) {
|
||||
const selectRef = useRef<HTMLButtonElement>(null);
|
||||
// const [selectedValues, setSelectedValues] = React.useState<T[]>(defaultSelectedValues);
|
||||
|
||||
const handleValueChange = (values: T[]) => {
|
||||
setSelectedValues(values);
|
||||
|
|
@ -105,23 +110,33 @@ export default function MultiSelect<T extends string>({
|
|||
popoverClassName,
|
||||
)}
|
||||
>
|
||||
{items.map((value) => (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-2 py-1.5 hover:cursor-pointer',
|
||||
'scroll-m-1 outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
'w-full min-w-0 text-sm',
|
||||
itemClassName,
|
||||
)}
|
||||
>
|
||||
<SelectItemCheck className="text-primary" />
|
||||
<span className="truncate">{value}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
{items.map((value) => {
|
||||
const defaultContent = (
|
||||
<>
|
||||
<SelectItemCheck className="text-primary" />
|
||||
<span className="truncate">{value}</span>
|
||||
</>
|
||||
);
|
||||
const isCurrentItemSelected = selectedValues.includes(value);
|
||||
return (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-2 py-1.5 hover:cursor-pointer',
|
||||
'scroll-m-1 outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
'w-full min-w-0 text-sm',
|
||||
itemClassName,
|
||||
)}
|
||||
>
|
||||
{renderItemContent
|
||||
? renderItemContent(value, defaultContent, isCurrentItemSelected)
|
||||
: defaultContent}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch';
|
|||
import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
|
||||
import Parameters from '~/components/SidePanel/Parameters/Panel';
|
||||
import FilesPanel from '~/components/SidePanel/Files/Panel';
|
||||
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
|
||||
import { Blocks, AttachmentIcon } from '~/components/svg';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import MCPIcon from '~/components/ui/MCPIcon';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
|
||||
export default function useSideNavLinks({
|
||||
|
|
@ -59,6 +62,7 @@ export default function useSideNavLinks({
|
|||
permissionType: PermissionTypes.AGENTS,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
const Links = useMemo(() => {
|
||||
const links: NavLink[] = [];
|
||||
|
|
@ -149,6 +153,21 @@ export default function useSideNavLinks({
|
|||
});
|
||||
}
|
||||
|
||||
if (
|
||||
startupConfig?.mcpServers &&
|
||||
Object.values(startupConfig.mcpServers).some(
|
||||
(server) => server.customUserVars && Object.keys(server.customUserVars).length > 0,
|
||||
)
|
||||
) {
|
||||
links.push({
|
||||
title: 'com_nav_setting_mcp',
|
||||
label: '',
|
||||
icon: MCPIcon,
|
||||
id: 'mcp-settings',
|
||||
Component: MCPPanel,
|
||||
});
|
||||
}
|
||||
|
||||
links.push({
|
||||
title: 'com_sidepanel_hide_panel',
|
||||
label: '',
|
||||
|
|
@ -171,6 +190,7 @@ export default function useSideNavLinks({
|
|||
hasAccessToBookmarks,
|
||||
hasAccessToCreateAgents,
|
||||
hidePanel,
|
||||
startupConfig,
|
||||
]);
|
||||
|
||||
return Links;
|
||||
|
|
|
|||
|
|
@ -423,6 +423,8 @@
|
|||
"com_nav_log_out": "Log out",
|
||||
"com_nav_long_audio_warning": "Longer texts will take longer to process.",
|
||||
"com_nav_maximize_chat_space": "Maximize chat space",
|
||||
"com_nav_mcp_vars_update_error": "Error updating MCP custom user variables: {{0}}",
|
||||
"com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.",
|
||||
"com_nav_modular_chat": "Enable switching Endpoints mid-conversation",
|
||||
"com_nav_my_files": "My Files",
|
||||
"com_nav_not_supported": "Not Supported",
|
||||
|
|
@ -447,6 +449,7 @@
|
|||
"com_nav_setting_chat": "Chat",
|
||||
"com_nav_setting_data": "Data controls",
|
||||
"com_nav_setting_general": "General",
|
||||
"com_nav_setting_mcp": "MCP Settings",
|
||||
"com_nav_setting_personalization": "Personalization",
|
||||
"com_nav_setting_speech": "Speech",
|
||||
"com_nav_settings": "Settings",
|
||||
|
|
@ -480,8 +483,15 @@
|
|||
"com_sidepanel_conversation_tags": "Bookmarks",
|
||||
"com_sidepanel_hide_panel": "Hide Panel",
|
||||
"com_sidepanel_manage_files": "Manage Files",
|
||||
"com_sidepanel_mcp_enter_value": "Enter value for {{0}}",
|
||||
"com_sidepanel_mcp_no_servers_with_vars": "No MCP servers with configurable variables.",
|
||||
"com_sidepanel_mcp_variables_for": "MCP Variables for {{0}}",
|
||||
"com_sidepanel_parameters": "Parameters",
|
||||
"com_sources_image_alt": "Search result image",
|
||||
"com_ui_configure_mcp_variables_for": "Configure Variables for {{0}}",
|
||||
"com_ui_mcp_dialog_desc": "Please enter the necessary information below.",
|
||||
"com_ui_mcp_enter_var": "Enter value for {{0}}",
|
||||
"com_ui_saving": "Saving...",
|
||||
"com_sources_more_sources": "+{{count}} sources",
|
||||
"com_sources_tab_all": "All",
|
||||
"com_sources_tab_images": "Images",
|
||||
|
|
@ -570,6 +580,7 @@
|
|||
"com_ui_authentication_type": "Authentication Type",
|
||||
"com_ui_avatar": "Avatar",
|
||||
"com_ui_azure": "Azure",
|
||||
"com_ui_back": "Back",
|
||||
"com_ui_back_to_chat": "Back to Chat",
|
||||
"com_ui_back_to_prompts": "Back to Prompts",
|
||||
"com_ui_backup_codes": "Backup Codes",
|
||||
|
|
@ -795,6 +806,7 @@
|
|||
"com_ui_manage": "Manage",
|
||||
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
|
||||
"com_ui_mcp_servers": "MCP Servers",
|
||||
"com_ui_mcp_server_not_found": "Server not found.",
|
||||
"com_ui_memories": "Memories",
|
||||
"com_ui_memories_allow_create": "Allow creating Memories",
|
||||
"com_ui_memories_allow_opt_out": "Allow users to opt out of Memories",
|
||||
|
|
@ -1020,6 +1032,7 @@
|
|||
"com_user_message": "You",
|
||||
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.",
|
||||
"com_ui_add_mcp": "Add MCP",
|
||||
"com_ui_add_mcp": "Add MCP",
|
||||
"com_ui_add_mcp_server": "Add MCP Server",
|
||||
"com_ui_edit_mcp_server": "Edit MCP Server",
|
||||
"com_agents_mcps_disabled": "You need to create an agent before adding MCPs.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue