🗝️ 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

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

View file

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