🗝️ 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:
Danny Avila 2025-06-19 18:27:55 -04:00 committed by GitHub
parent 8b15bb2ed6
commit 3e4b01de82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1536 additions and 166 deletions

View file

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