feat: Implement MCP Server Management API and UI Components

- Added API endpoints for managing MCP servers: create, read, update, and delete functionalities.
- Introduced new UI components for MCP server configuration, including MCPFormPanel and MCPConfig.
- Updated existing types and data provider to support MCP operations.
- Enhanced the side panel to include MCP server management options.
- Refactored related components and hooks for better integration with the new MCP features.
- Added tests for the new MCP server API functionalities.
This commit is contained in:
Dustin Healy 2025-06-29 17:55:09 -07:00
parent 20100e120b
commit 351f30254c
26 changed files with 1189 additions and 290 deletions

View file

@ -5,6 +5,13 @@ const { CacheKeys } = require('librechat-data-provider');
const { requireJwtAuth } = require('~/server/middleware');
const { getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
const {
getMCPServers,
getMCPServer,
createMCPServer,
updateMCPServer,
deleteMCPServer,
} = require('@librechat/api');
const router = Router();
@ -202,4 +209,44 @@ router.get('/oauth/status/:flowId', async (req, res) => {
}
});
/**
* Get all MCP servers for the authenticated user
* @route GET /api/mcp
* @returns {Array} Array of MCP servers
*/
router.get('/', requireJwtAuth, getMCPServers);
/**
* Get a single MCP server by ID
* @route GET /api/mcp/:mcp_id
* @param {string} mcp_id - The ID of the MCP server to fetch
* @returns {object} MCP server data
*/
router.get('/:mcp_id', requireJwtAuth, getMCPServer);
/**
* Create a new MCP server
* @route POST /api/mcp/add
* @param {object} req.body - MCP server data
* @returns {object} Created MCP server with populated tools
*/
router.post('/add', requireJwtAuth, createMCPServer);
/**
* Update an existing MCP server
* @route PUT /api/mcp/:mcp_id
* @param {string} mcp_id - The ID of the MCP server to update
* @param {object} req.body - Updated MCP server data
* @returns {object} Updated MCP server with populated tools
*/
router.put('/:mcp_id', requireJwtAuth, updateMCPServer);
/**
* Delete an MCP server
* @route DELETE /api/mcp/:mcp_id
* @param {string} mcp_id - The ID of the MCP server to delete
* @returns {object} Deletion confirmation
*/
router.delete('/:mcp_id', requireJwtAuth, deleteMCPServer);
module.exports = router;

View file

@ -1,26 +1,13 @@
import {
AuthorizationTypeEnum,
AuthTypeEnum,
TokenExchangeMethodEnum,
} from 'librechat-data-provider';
import { MCPForm } from '~/common/types';
export const defaultMCPFormValues: MCPForm = {
type: AuthTypeEnum.None,
saved_auth_fields: false,
api_key: '',
authorization_type: AuthorizationTypeEnum.Basic,
custom_auth_header: '',
oauth_client_id: '',
oauth_client_secret: '',
authorization_url: '',
client_url: '',
scope: '',
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
name: '',
description: '',
url: '',
tools: [],
icon: '',
trust: false,
customHeaders: [],
requestTimeout: undefined,
connectionTimeout: undefined,
};

View file

@ -1,5 +1,5 @@
import { RefObject } from 'react';
import { FileSources, EModelEndpoint } from 'librechat-data-provider';
import { FileSources, EModelEndpoint, TPlugin } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type * as InputNumberPrimitive from 'rc-input-number';
import type { SetterOrUpdater, RecoilState } from 'recoil';
@ -167,13 +167,27 @@ export type ActionAuthForm = {
token_exchange_method: t.TokenExchangeMethodEnum;
};
export type MCPForm = ActionAuthForm & {
name?: string;
export type MCPForm = MCPMetadata;
export type MCP = {
mcp_id: string;
metadata: MCPMetadata;
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string });
export type MCPMetadata = {
name: string;
description?: string;
url?: string;
tools?: string[];
url: string;
tools?: TPlugin[];
icon?: string;
trust?: boolean;
customHeaders?: Array<{
id: string;
name: string;
value: string;
}>;
requestTimeout?: number;
connectionTimeout?: number;
};
export type ActionWithNullableMetadata = Omit<t.Action, 'metadata'> & {

View file

@ -7,7 +7,6 @@ import VersionPanel from './Version/VersionPanel';
import { useChatContext } from '~/Providers';
import ActionsPanel from './ActionsPanel';
import AgentPanel from './AgentPanel';
import MCPPanel from './MCPPanel';
import { Panel } from '~/common';
export default function AgentPanelSwitch() {
@ -54,8 +53,5 @@ function AgentPanelSwitchWithContext() {
if (activePanel === Panel.version) {
return <VersionPanel />;
}
if (activePanel === Panel.mcp) {
return <MCPPanel />;
}
return <AgentPanel agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />;
}

View file

@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { useLocalize } from '~/hooks';
import { useToastContext } from '~/Providers';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import MCP from '~/components/SidePanel/Builder/MCP';
import { MCPItem } from '~/components/SidePanel/MCP/MCPItem';
import { Panel } from '~/common';
export default function MCPSection() {
@ -30,7 +30,7 @@ export default function MCPSection() {
{mcps
.filter((mcp) => mcp.agent_id === agent_id)
.map((mcp, i) => (
<MCP
<MCPItem
key={i}
mcp={mcp}
onClick={() => {

View file

@ -1,55 +0,0 @@
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth';
import {
AuthorizationTypeEnum,
TokenExchangeMethodEnum,
AuthTypeEnum,
} from 'librechat-data-provider';
export default function MCPAuth() {
// Create a separate form for auth
const authMethods = useForm({
defaultValues: {
/* General */
type: AuthTypeEnum.None,
saved_auth_fields: false,
/* API key */
api_key: '',
authorization_type: AuthorizationTypeEnum.Basic,
custom_auth_header: '',
/* OAuth */
oauth_client_id: '',
oauth_client_secret: '',
authorization_url: '',
client_url: '',
scope: '',
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
},
});
const { watch, setValue } = authMethods;
const type = watch('type');
// Sync form state when auth type changes
useEffect(() => {
if (type === 'none') {
// Reset auth fields when type is none
setValue('api_key', '');
setValue('authorization_type', AuthorizationTypeEnum.Basic);
setValue('custom_auth_header', '');
setValue('oauth_client_id', '');
setValue('oauth_client_secret', '');
setValue('authorization_url', '');
setValue('client_url', '');
setValue('scope', '');
setValue('token_exchange_method', TokenExchangeMethodEnum.DefaultPost);
}
}, [type, setValue]);
return (
<FormProvider {...authMethods}>
<ActionsAuth />
</FormProvider>
);
}

View file

@ -0,0 +1,222 @@
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { Plus, Trash2, CirclePlus } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '~/components/ui/Accordion';
import { DropdownPopup } from '~/components/ui';
import { useLocalize } from '~/hooks';
interface UserInfoPlaceholder {
label: string;
value: string;
description: string;
}
const userInfoPlaceholders: UserInfoPlaceholder[] = [
{ label: 'user-id', value: '{{LIBRECHAT_USER_ID}}', description: 'Current user ID' },
{ label: 'username', value: '{{LIBRECHAT_USER_USERNAME}}', description: 'Current username' },
{ label: 'email', value: '{{LIBRECHAT_USER_EMAIL}}', description: 'Current user email' },
{ label: 'name', value: '{{LIBRECHAT_USER_NAME}}', description: 'Current user name' },
{
label: 'provider',
value: '{{LIBRECHAT_USER_PROVIDER}}',
description: 'Authentication provider',
},
{ label: 'role', value: '{{LIBRECHAT_USER_ROLE}}', description: 'User role' },
];
export function MCPConfig() {
const localize = useLocalize();
const { register, watch, setValue } = useFormContext();
const [isHeadersMenuOpen, setIsHeadersMenuOpen] = useState(false);
const customHeaders = watch('customHeaders') || [];
const addCustomHeader = () => {
const newHeader = {
id: Date.now().toString(),
name: '',
value: '',
};
setValue('customHeaders', [...customHeaders, newHeader]);
};
const removeCustomHeader = (id: string) => {
setValue(
'customHeaders',
customHeaders.filter((header: any) => header.id !== id),
);
};
const updateCustomHeader = (id: string, field: 'name' | 'value', value: string) => {
setValue(
'customHeaders',
customHeaders.map((header: any) =>
header.id === id ? { ...header, [field]: value } : header,
),
);
};
const handleAddPlaceholder = (placeholder: UserInfoPlaceholder) => {
const newHeader = {
id: Date.now().toString(),
name: placeholder.label,
value: placeholder.value,
};
setValue('customHeaders', [...customHeaders, newHeader]);
setIsHeadersMenuOpen(false);
};
const headerMenuItems = [
...userInfoPlaceholders.map((placeholder) => ({
label: `${placeholder.label} - ${placeholder.description}`,
onClick: () => handleAddPlaceholder(placeholder),
})),
];
return (
<div className="space-y-4">
{/* Authentication Accordion */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="authentication" className="rounded-lg border border-border-medium">
<AccordionTrigger className="rounded px-4 py-3 text-sm font-medium hover:no-underline focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
{localize('com_ui_authentication')}
</AccordionTrigger>
<AccordionContent className="px-4 pb-4 pt-2">
<div className="space-y-4">
{/* Custom Headers Section - Individual Inputs Version */}
<div>
<div className="mb-3 flex items-center justify-between">
<label className="text-sm font-medium text-text-primary">
{localize('com_ui_mcp_custom_headers')}
</label>
<DropdownPopup
menuId="headers-menu"
items={headerMenuItems}
isOpen={isHeadersMenuOpen}
setIsOpen={setIsHeadersMenuOpen}
trigger={
<Menu.MenuButton
onClick={() => setIsHeadersMenuOpen(!isHeadersMenuOpen)}
className="flex h-7 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-xs text-text-primary transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<CirclePlus className="mr-1 h-3 w-3 text-text-secondary" />
{localize('com_ui_mcp_headers')}
</Menu.MenuButton>
}
/>
</div>
<div className="space-y-2">
{customHeaders.length === 0 ? (
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 flex-1 text-sm text-text-secondary">
{localize('com_ui_mcp_no_custom_headers')}
</p>
<button
type="button"
onClick={addCustomHeader}
className="flex h-7 shrink-0 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-xs text-text-primary transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<Plus className="h-3 w-3" />
{localize('com_ui_mcp_add_header')}
</button>
</div>
) : (
<>
{customHeaders.map((header: any) => (
<div key={header.id} className="flex min-w-0 gap-2">
<input
type="text"
placeholder={localize('com_ui_mcp_header_name')}
value={header.name}
onChange={(e) => updateCustomHeader(header.id, 'name', e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
<input
type="text"
placeholder={localize('com_ui_mcp_header_value')}
value={header.value}
onChange={(e) => updateCustomHeader(header.id, 'value', e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
<button
type="button"
onClick={() => removeCustomHeader(header.id)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border-medium bg-surface-primary text-text-secondary hover:bg-surface-secondary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
{/* Add New Header Button */}
<div className="flex justify-end">
<button
type="button"
onClick={addCustomHeader}
className="flex h-7 shrink-0 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-xs text-text-primary transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<Plus className="h-3 w-3" />
{localize('com_ui_mcp_add_header')}
</button>
</div>
</>
)}
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* Configuration Accordion */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="configuration" className="rounded-lg border border-border-medium">
<AccordionTrigger className="rounded px-4 py-3 text-sm font-medium hover:no-underline focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
{localize('com_ui_mcp_configuration')}
</AccordionTrigger>
<AccordionContent className="px-4 pb-4 pt-2">
<div className="space-y-4">
{/* Request Timeout */}
<div>
<label className="mb-2 block text-sm font-medium text-text-primary">
{localize('com_ui_mcp_request_timeout')}
</label>
<input
type="number"
placeholder="10000"
{...register('requestTimeout')}
className="h-9 w-full rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
<p className="mt-1 text-xs text-text-secondary">
{localize('com_ui_mcp_request_timeout_description')}
</p>
</div>
{/* Connection Timeout */}
<div>
<label className="mb-2 block text-sm font-medium text-text-primary">
{localize('com_ui_mcp_connection_timeout')}
</label>
<input
type="number"
placeholder="10000"
{...register('connectionTimeout')}
className="h-9 w-full rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
<p className="mt-1 text-xs text-text-secondary">
{localize('com_ui_mcp_connection_timeout_description')}
</p>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View file

@ -1,66 +1,103 @@
import { useEffect } from 'react';
import { ChevronLeft } from 'lucide-react';
import { useForm, FormProvider } from 'react-hook-form';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import type { MCPForm } from '~/common';
import {
useCreateMCPMutation,
useUpdateMCPMutation,
useDeleteMCPMutation,
} from '~/data-provider/MCPs/mutations';
import type { MCP } from 'librechat-data-provider';
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { defaultMCPFormValues } from '~/common/mcp';
import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '~/Providers';
import useLocalize from '~/hooks/useLocalize';
import { TrashIcon } from '~/components/svg';
import type { MCPForm } from '~/common';
import MCPInput from './MCPInput';
import { Panel } from '~/common';
import {
AuthTypeEnum,
AuthorizationTypeEnum,
TokenExchangeMethodEnum,
} from 'librechat-data-provider';
// TODO: Add MCP delete (for now mocked for ui)
// import { useDeleteAgentMCP } from '~/data-provider';
function useDeleteAgentMCP({
onSuccess,
onError,
}: {
onSuccess: () => void;
onError: (error: Error) => void;
}) {
return {
mutate: async ({ mcp_id, agent_id }: { mcp_id: string; agent_id: string }) => {
try {
console.log('Mock delete MCP:', { mcp_id, agent_id });
onSuccess();
} catch (error) {
onError(error as Error);
}
},
};
interface MCPFormPanelProps {
// Data
mcp?: MCP;
// Actions
onBack: () => void;
// UI customization
title?: string;
subtitle?: string;
showDeleteButton?: boolean;
deleteConfirmMessage?: string;
// Form customization
defaultValues?: Partial<MCPForm>;
}
export default function MCPPanel() {
export default function MCPFormPanel({
mcp,
onBack,
title,
subtitle,
showDeleteButton = true,
deleteConfirmMessage,
defaultValues = defaultMCPFormValues,
}: MCPFormPanelProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { mcp, setMcp, agent_id, setActivePanel } = useAgentPanelContext();
const deleteAgentMCP = useDeleteAgentMCP({
const create = useCreateMCPMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
onBack();
},
onError: (error) => {
console.error('Error creating MCP:', error);
showToast({
message: localize('com_ui_update_mcp_error'),
status: 'error',
});
},
});
const update = useUpdateMCPMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
onBack();
},
onError: (error) => {
console.error('Error updating MCP:', error);
showToast({
message: localize('com_ui_update_mcp_error'),
status: 'error',
});
},
});
const deleteMCP = useDeleteMCPMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_delete_mcp_success'),
status: 'success',
});
setActivePanel(Panel.builder);
setMcp(undefined);
onBack();
},
onError(error) {
onError: (error) => {
console.error('Error deleting MCP:', error);
showToast({
message: (error as Error).message ?? localize('com_ui_delete_mcp_error'),
message: localize('com_ui_delete_mcp_error'),
status: 'error',
});
},
});
const methods = useForm<MCPForm>({
defaultValues: defaultMCPFormValues,
defaultValues: defaultValues,
});
const { reset } = methods;
@ -74,55 +111,51 @@ export default function MCPPanel() {
url: mcp.metadata.url ?? '',
tools: mcp.metadata.tools ?? [],
trust: mcp.metadata.trust ?? false,
customHeaders: mcp.metadata.customHeaders ?? [],
requestTimeout: mcp.metadata.requestTimeout,
connectionTimeout: mcp.metadata.connectionTimeout,
};
if (mcp.metadata.auth) {
Object.assign(formData, {
type: mcp.metadata.auth.type || AuthTypeEnum.None,
saved_auth_fields: false,
api_key: mcp.metadata.api_key ?? '',
authorization_type: mcp.metadata.auth.authorization_type || AuthorizationTypeEnum.Basic,
oauth_client_id: mcp.metadata.oauth_client_id ?? '',
oauth_client_secret: mcp.metadata.oauth_client_secret ?? '',
authorization_url: mcp.metadata.auth.authorization_url ?? '',
client_url: mcp.metadata.auth.client_url ?? '',
scope: mcp.metadata.auth.scope ?? '',
token_exchange_method:
mcp.metadata.auth.token_exchange_method ?? TokenExchangeMethodEnum.DefaultPost,
});
}
reset(formData);
}
}, [mcp, reset]);
const handleSave = (mcpData: MCP) => {
if (mcp) {
// Update existing MCP
update.mutate({ mcp_id: mcp.mcp_id, data: mcpData });
} else {
// Create new MCP
create.mutate(mcpData);
}
};
const handleDelete = () => {
if (mcp?.mcp_id) {
deleteMCP.mutate({ mcp_id: mcp.mcp_id });
}
};
return (
<FormProvider {...methods}>
<form className="h-full grow overflow-hidden">
<div className="h-full overflow-auto px-2 pb-12 text-sm">
<div className="relative flex flex-col items-center px-16 py-6 text-center">
<div className="absolute left-0 top-6">
<button
type="button"
className="btn btn-neutral relative"
onClick={() => {
setActivePanel(Panel.builder);
setMcp(undefined);
}}
>
<button type="button" className="btn btn-neutral relative" onClick={onBack}>
<div className="flex w-full items-center justify-center gap-2">
<ChevronLeft />
</div>
</button>
</div>
{!!mcp && (
{!!mcp && showDeleteButton && (
<OGDialog>
<OGDialogTrigger asChild>
<div className="absolute right-0 top-6">
<button
type="button"
disabled={!agent_id || !mcp.mcp_id}
disabled={!mcp.mcp_id}
className="btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium"
>
<TrashIcon className="text-red-500" />
@ -135,22 +168,11 @@ export default function MCPPanel() {
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_mcp_confirm')}
{deleteConfirmMessage || localize('com_ui_delete_mcp_confirm')}
</Label>
}
selection={{
selectHandler: () => {
if (!agent_id) {
return showToast({
message: localize('com_agents_no_agent_id_error'),
status: 'error',
});
}
deleteAgentMCP.mutate({
mcp_id: mcp.mcp_id,
agent_id,
});
},
selectHandler: handleDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),
@ -160,11 +182,17 @@ export default function MCPPanel() {
)}
<div className="text-xl font-medium">
{mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server')}
{title ||
(mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server'))}
</div>
<div className="text-xs text-text-secondary">{localize('com_agents_mcp_info')}</div>
<div className="text-xs text-text-secondary">{subtitle || ''}</div>
</div>
<MCPInput mcp={mcp} agent_id={agent_id} setMCP={setMcp} />
<MCPInput
mcp={mcp}
agent_id=""
onSave={handleSave}
isLoading={create.isLoading || update.isLoading}
/>
</div>
</form>
</FormProvider>

View file

@ -1,58 +1,31 @@
import { useState, useEffect } from 'react';
import { Constants } from 'librechat-data-provider';
import { useFormContext, Controller } from 'react-hook-form';
import type { MCP } from 'librechat-data-provider';
import MCPAuth from '~/components/SidePanel/Builder/MCPAuth';
import type { MCP, MCPMetadata } from 'librechat-data-provider';
import { MCPConfig } from '~/components/SidePanel/MCP/MCPConfig';
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
import { Label, Checkbox } from '~/components/ui';
import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { MCPForm } from '~/common/types';
function useUpdateAgentMCP({
onSuccess,
onError,
}: {
onSuccess: (data: [string, MCP]) => void;
onError: (error: Error) => void;
}) {
return {
mutate: async ({
mcp_id,
metadata,
agent_id,
}: {
mcp_id?: string;
metadata: MCP['metadata'];
agent_id: string;
}) => {
try {
// TODO: Implement MCP endpoint
onSuccess(['success', { mcp_id, metadata, agent_id } as MCP]);
} catch (error) {
onError(error as Error);
}
},
isLoading: false,
};
}
interface MCPInputProps {
mcp?: MCP;
agent_id?: string;
setMCP: React.Dispatch<React.SetStateAction<MCP | undefined>>;
onSave: (mcp: MCP) => void;
isLoading?: boolean;
}
export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
export default function MCPInput({ mcp, agent_id = '', onSave, isLoading = false }: MCPInputProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const {
handleSubmit,
register,
formState: { errors },
control,
setValue,
getValues,
} = useFormContext<MCPForm>();
const [isLoading, setIsLoading] = useState(false);
const [showTools, setShowTools] = useState(false);
const [selectedTools, setSelectedTools] = useState<string[]>([]);
@ -64,50 +37,20 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
}
}, [mcp]);
const updateAgentMCP = useUpdateAgentMCP({
onSuccess(data) {
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
setMCP(data[1]);
setShowTools(true);
setSelectedTools(data[1].metadata.tools ?? []);
setIsLoading(false);
},
onError(error) {
showToast({
message: (error as Error).message || localize('com_ui_update_mcp_error'),
status: 'error',
});
setIsLoading(false);
},
});
const saveMCP = handleSubmit(async (data: MCPForm) => {
setIsLoading(true);
try {
const response = await updateAgentMCP.mutate({
agent_id: agent_id ?? '',
mcp_id: mcp?.mcp_id,
metadata: {
...data,
tools: selectedTools,
},
});
setMCP(response[1]);
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
} catch {
showToast({
message: localize('com_ui_update_mcp_error'),
status: 'error',
});
} finally {
setIsLoading(false);
}
// Generate MCP ID using server name and delimiter for new MCPs
const mcpId =
mcp?.mcp_id || `${data.name.replace(/\s+/g, '_').toLowerCase()}${Constants.mcp_delimiter}`;
const updatedMCP: MCP = {
mcp_id: mcpId,
agent_id: agent_id ?? '',
metadata: {
...data,
tools: selectedTools,
} as MCPMetadata, // Type assertion since form validation ensures required fields
};
onSave(updatedMCP);
});
const handleSelectAll = () => {
@ -140,14 +83,15 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
setMCP({
const updatedMCP: MCP = {
mcp_id: mcp?.mcp_id ?? '',
agent_id: agent_id ?? '',
metadata: {
...mcp?.metadata,
icon: base64String,
},
});
};
onSave(updatedMCP);
};
reader.readAsDataURL(file);
}
@ -205,25 +149,48 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
</span>
)}
</div>
<MCPAuth />
<div className="my-2 flex items-center gap-2">
<MCPConfig />
<div className="my-2 flex items-center">
<Controller
name="trust"
control={control}
rules={{ required: true }}
render={({ field }) => (
<Checkbox id="trust" checked={field.value} onCheckedChange={field.onChange} />
<Checkbox
{...field}
checked={field.value ?? false}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={(field.value ?? false).toString()}
/>
)}
/>
<Label htmlFor="trust" className="flex flex-col">
{localize('com_ui_trust_app')}
<span className="text-xs text-text-secondary">
{localize('com_agents_mcp_trust_subtext')}
</span>
</Label>
<button
type="button"
className="flex items-center space-x-2"
onClick={() =>
setValue('trust', !getValues('trust'), {
shouldDirty: true,
})
}
>
<label
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor="trust"
>
{localize('com_ui_trust_app')}
</label>
</button>
</div>
<div className="-mt-5 ml-6">
<span className="text-xs text-text-secondary">
{localize('com_agents_mcp_trust_subtext')}
</span>
</div>
{errors.trust && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
<div className="ml-6">
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
</div>
)}
</div>
@ -231,7 +198,7 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
<button
onClick={saveMCP}
disabled={isLoading}
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white transition-colors duration-200 hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:bg-green-400"
type="button"
>
{(() => {

View file

@ -9,7 +9,7 @@ type MCPProps = {
onClick: () => void;
};
export default function MCP({ mcp, onClick }: MCPProps) {
export function MCPItem({ mcp, onClick }: MCPProps) {
const [isHovering, setIsHovering] = useState(false);
return (

View file

@ -1,7 +1,7 @@
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 { Constants } from 'librechat-data-provider';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins } from 'librechat-data-provider';
import { Button, Input, Label } from '~/components/ui';
@ -9,6 +9,7 @@ import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import MCPFormPanel from './MCPFormPanel';
interface ServerConfigWithVars {
serverName: string;
@ -24,6 +25,7 @@ export default function MCPPanel() {
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
null,
);
const [showMCPForm, setShowMCPForm] = useState(false);
const mcpServerDefinitions = useMemo(() => {
if (!startupConfig?.mcpServers) {
@ -89,14 +91,47 @@ export default function MCPPanel() {
setSelectedServerNameForEditing(null);
};
const handleAddMCP = () => {
setShowMCPForm(true);
};
const handleBackFromForm = () => {
setShowMCPForm(false);
};
if (showMCPForm) {
return (
<MCPFormPanel
onBack={handleBackFromForm}
title={localize('com_ui_add_mcp_server')}
subtitle={localize('com_agents_mcp_info_chat')}
/>
);
}
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 className="h-auto max-w-full overflow-x-hidden p-3">
<div className="p-4 text-center text-sm text-gray-500">
{localize('com_sidepanel_mcp_no_servers_with_vars')}
</div>
<div className="mt-4">
<button
type="button"
onClick={handleAddMCP}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-haspopup="dialog"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_ui_add_mcp')}
</div>
</button>
</div>
</div>
);
}
@ -144,15 +179,28 @@ export default function MCPPanel() {
<div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="space-y-2">
{mcpServerDefinitions.map((server) => (
<Button
<button
key={server.serverName}
variant="outline"
className="w-full justify-start dark:hover:bg-gray-700"
type="button"
onClick={() => handleServerClickToEdit(server.serverName)}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-label={`Configure MCP server ${server.serverName}`}
>
{server.serverName}
</Button>
<div className="flex w-full items-center justify-start gap-2">
{server.serverName}
</div>
</button>
))}
<button
type="button"
onClick={handleAddMCP}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-haspopup="dialog"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_ui_add_mcp')}
</div>
</button>
</div>
</div>
);
@ -181,7 +229,7 @@ function MCPVariableEditor({ server, onSave, onRevoke, isSubmitting }: MCPVariab
useEffect(() => {
// Always initialize with empty strings based on the schema
const initialFormValues = Object.keys(server.config.customUserVars).reduce(
const initialFormValues = Object.keys(server.config.customUserVars || {}).reduce(
(acc, key) => {
acc[key] = '';
return acc;
@ -230,7 +278,7 @@ function MCPVariableEditor({ server, onSave, onRevoke, isSubmitting }: MCPVariab
</div>
))}
<div className="flex justify-end gap-2 pt-2">
{Object.keys(server.config.customUserVars).length > 0 && (
{Object.keys(server.config.customUserVars || {}).length > 0 && (
<Button
type="button"
onClick={handleRevokeClick}

View file

@ -0,0 +1 @@
export * from './mutations';

View file

@ -0,0 +1,73 @@
import { dataService, QueryKeys } from 'librechat-data-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { UseMutationResult } from '@tanstack/react-query';
import type * as t from 'librechat-data-provider';
export const useCreateMCPMutation = (
options?: t.CreateMCPMutationOptions,
): UseMutationResult<t.MCP, Error, t.MCP> => {
const queryClient = useQueryClient();
return useMutation(
(mcp: t.MCP) => {
return dataService.createMCP(mcp);
},
{
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (data, variables, context) => {
queryClient.setQueryData<t.MCP[]>([QueryKeys.mcpServers], (prev) => {
return prev ? [...prev, data] : [data];
});
return options?.onSuccess?.(data, variables, context);
},
},
);
};
export const useUpdateMCPMutation = (
options?: t.UpdateMCPMutationOptions,
): UseMutationResult<t.MCP, Error, { mcp_id: string; data: t.MCP }> => {
const queryClient = useQueryClient();
return useMutation(
({ mcp_id, data }: { mcp_id: string; data: t.MCP }) => {
return dataService.updateMCP({ mcp_id, data });
},
{
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (data, variables, context) => {
queryClient.setQueryData<t.MCP[]>([QueryKeys.mcpServers], (prev) => {
if (!prev) return prev;
return prev.map((mcp) => (mcp.mcp_id === variables.mcp_id ? data : mcp));
});
return options?.onSuccess?.(data, variables, context);
},
},
);
};
export const useDeleteMCPMutation = (
options?: t.DeleteMCPMutationOptions,
): UseMutationResult<Record<string, unknown>, Error, { mcp_id: string }> => {
const queryClient = useQueryClient();
return useMutation(
({ mcp_id }: { mcp_id: string }) => {
return dataService.deleteMCP({ mcp_id });
},
{
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (data, variables, context) => {
queryClient.setQueryData<t.MCP[]>([QueryKeys.mcpServers], (prev) => {
if (!prev) return prev;
return prev.filter((mcp) => mcp.mcp_id !== variables.mcp_id);
});
return options?.onSuccess?.(data, variables, context);
},
},
);
};

View file

@ -7,6 +7,7 @@ export * from './Memories';
export * from './Messages';
export * from './Misc';
export * from './Tools';
export * from './MCPs';
export * from './connection';
export * from './mutations';
export * from './prompts';

View file

@ -19,7 +19,6 @@ import { Blocks, MCPIcon, AttachmentIcon } from '~/components/svg';
import Parameters from '~/components/SidePanel/Parameters/Panel';
import FilesPanel from '~/components/SidePanel/Files/Panel';
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
import { useGetStartupConfig } from '~/data-provider';
import { useHasAccess } from '~/hooks';
export default function useSideNavLinks({
@ -61,7 +60,6 @@ export default function useSideNavLinks({
permissionType: PermissionTypes.AGENTS,
permission: Permissions.CREATE,
});
const { data: startupConfig } = useGetStartupConfig();
const Links = useMemo(() => {
const links: NavLink[] = [];
@ -152,20 +150,13 @@ 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_nav_mcp_panel',
label: '',
icon: MCPIcon,
id: 'mcp-settings',
Component: MCPPanel,
});
links.push({
title: 'com_sidepanel_hide_panel',
@ -189,7 +180,6 @@ export default function useSideNavLinks({
hasAccessToBookmarks,
hasAccessToCreateAgents,
hidePanel,
startupConfig,
]);
return Links;

View file

@ -20,6 +20,7 @@
"com_agents_mcp_description_placeholder": "Explain what it does in a few words",
"com_agents_mcp_icon_size": "Minimum size 128 x 128 px",
"com_agents_mcp_info": "Add MCP servers to your agent to enable it to perform tasks and interact with external services",
"com_agents_mcp_info_chat": "Add MCP servers to enable chat to perform tasks and interact with external services",
"com_agents_mcp_name_placeholder": "Custom Tool",
"com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat",
"com_agents_mcps_disabled": "You need to create an agent before adding MCPs.",
@ -431,6 +432,7 @@
"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_panel": "MCP Servers",
"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",
@ -457,7 +459,6 @@
"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",
@ -827,6 +828,17 @@
"com_ui_mcp_server_not_found": "Server not found.",
"com_ui_mcp_servers": "MCP Servers",
"com_ui_mcp_url": "MCP Server URL",
"com_ui_mcp_custom_headers": "Custom Headers",
"com_ui_mcp_headers": "Headers",
"com_ui_mcp_no_custom_headers": "No custom headers configured",
"com_ui_mcp_add_header": "Add Header",
"com_ui_mcp_header_name": "Header Name",
"com_ui_mcp_header_value": "Header Value",
"com_ui_mcp_configuration": "Configuration",
"com_ui_mcp_request_timeout": "Request Timeout (ms)",
"com_ui_mcp_request_timeout_description": "Maximum time in milliseconds to wait for a response from the MCP server",
"com_ui_mcp_connection_timeout": "Connection Timeout (ms)",
"com_ui_mcp_connection_timeout_description": "Maximum time in milliseconds to establish connection to the MCP server",
"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",
@ -1060,4 +1072,4 @@
"com_ui_zoom": "Zoom",
"com_user_message": "You",
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint."
}
}

View file

@ -2,6 +2,7 @@
export * from './mcp/manager';
export * from './mcp/oauth';
export * from './mcp/auth';
export * from './mcp/servers';
/* Utilities */
export * from './mcp/utils';
export * from './utils';

View file

@ -0,0 +1,212 @@
import { Response } from 'express';
import type { TUser } from 'librechat-data-provider';
import { getMCPServers, createMCPServer, updateMCPServer, deleteMCPServer } from './servers';
import type { AuthenticatedRequest, MCPRequest, MCPParamsRequest } from '../types';
describe('MCP Server Functions', () => {
let mockReq: Partial<AuthenticatedRequest>;
let mockRes: Partial<Response>;
let mockUser: TUser;
beforeEach(() => {
mockUser = { id: 'user123' } as TUser;
mockReq = { user: mockUser };
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getMCPServers', () => {
it('should return mock MCP servers', async () => {
await getMCPServers(mockReq as AuthenticatedRequest, mockRes as Response);
expect(mockRes.json).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
mcp_id: 'mcp_weather_001',
metadata: expect.objectContaining({
name: 'Weather Service',
}),
}),
expect.objectContaining({
mcp_id: 'mcp_calendar_002',
metadata: expect.objectContaining({
name: 'Calendar Manager',
}),
}),
]),
);
});
it('should reject unauthenticated requests', async () => {
mockReq.user = undefined;
await getMCPServers(mockReq as AuthenticatedRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'User not authenticated' });
});
});
describe('createMCPServer', () => {
beforeEach(() => {
mockReq.body = {
mcp_id: 'mcp_test_123',
metadata: {
name: 'Test MCP Server',
description: 'A test MCP server',
url: 'http://localhost:3000',
tools: ['test_tool'],
icon: '🔧',
trust: false,
customHeaders: [],
requestTimeout: 30000,
connectionTimeout: 10000,
},
agent_id: '',
};
});
it('should create new MCP server from form data', async () => {
await createMCPServer(mockReq as MCPRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
mcp_id: 'mcp_test_123',
metadata: expect.objectContaining({
name: 'Test MCP Server',
description: 'A test MCP server',
url: 'http://localhost:3000',
tools: ['test_tool'],
icon: '🔧',
trust: false,
}),
}),
);
});
it('should prevent duplicate server names', async () => {
mockReq.body = {
metadata: {
name: 'Weather Service', // This name already exists in mock data
url: 'http://localhost:3000',
},
agent_id: '',
};
await createMCPServer(mockReq as MCPRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(409);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'MCP server already exists' });
});
it('should validate required fields', async () => {
mockReq.body = {
metadata: {
description: 'A test MCP server',
// Missing name and url
},
agent_id: '',
};
await createMCPServer(mockReq as MCPRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
message: 'Missing required fields: name and url are required',
});
});
});
describe('updateMCPServer', () => {
beforeEach(() => {
mockReq.body = {
metadata: {
name: 'Updated MCP Server',
description: 'An updated MCP server',
url: 'http://localhost:3001',
tools: ['updated_tool'],
icon: '⚙️',
trust: true,
customHeaders: [],
requestTimeout: 45000,
connectionTimeout: 15000,
},
agent_id: '',
};
mockReq.params = { mcp_id: 'mcp_weather_001' }; // Use existing mock server ID
});
it('should update existing MCP server', async () => {
await updateMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
mcp_id: 'mcp_weather_001',
metadata: expect.objectContaining({
name: 'Updated MCP Server',
description: 'An updated MCP server',
url: 'http://localhost:3001',
tools: ['updated_tool'],
icon: '⚙️',
trust: true,
}),
}),
);
});
it('should reject updates to non-existent servers', async () => {
mockReq.params = { mcp_id: 'non_existent_id' };
await updateMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'MCP server not found' });
});
it('should validate required fields', async () => {
mockReq.body = {
metadata: {
description: 'An updated MCP server',
// Missing name and url
},
agent_id: '',
};
await updateMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
message: 'Missing required fields: name and url are required',
});
});
});
describe('deleteMCPServer', () => {
beforeEach(() => {
mockReq.params = { mcp_id: 'mcp_weather_001' }; // Use existing mock server ID
});
it('should delete existing MCP server', async () => {
await deleteMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'MCP server deleted successfully' });
});
it('should reject deletion of non-existent servers', async () => {
mockReq.params = { mcp_id: 'non_existent_id' };
await deleteMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'MCP server not found' });
});
});
});

View file

@ -0,0 +1,270 @@
import { logger } from '@librechat/data-schemas';
import type { MCP } from 'librechat-data-provider';
import type { Response } from 'express';
import type { AuthenticatedRequest, MCPRequest, MCPParamsRequest } from '../types';
// Mock data for demonstration
const mockMCPServers: MCP[] = [
{
mcp_id: 'mcp_weather_001',
metadata: {
name: 'Weather Service',
description: 'Provides weather information and forecasts',
url: 'https://weather-mcp.example.com',
tools: ['get_current_weather', 'get_forecast', 'get_weather_alerts'],
icon: '',
trust: true,
customHeaders: [],
requestTimeout: 30000,
connectionTimeout: 10000,
},
agent_id: '',
},
{
mcp_id: 'mcp_calendar_002',
metadata: {
name: 'Calendar Manager',
description: 'Manages calendar events and scheduling',
url: 'https://calendar-mcp.example.com',
tools: ['create_event', 'list_events', 'update_event', 'delete_event'],
icon: '',
trust: false,
customHeaders: [{ id: '1', name: 'Authorization', value: 'Bearer {{api_key}}' }],
requestTimeout: 45000,
connectionTimeout: 15000,
},
agent_id: '',
},
];
/**
* Get all MCP servers for the authenticated user
*/
export const getMCPServers = async (req: AuthenticatedRequest, res: Response): Promise<void> => {
try {
const userId = req.user?.id;
if (!userId) {
logger.warn('MCP servers fetch without user ID');
res.status(401).json({ message: 'User not authenticated' });
return;
}
// Return mock MCP servers
res.json(mockMCPServers);
} catch (error) {
logger.error('Error fetching MCP servers:', error);
res.status(500).json({ message: 'Failed to fetch MCP servers' });
}
};
/**
* Get a single MCP server by ID
*/
export const getMCPServer = async (req: AuthenticatedRequest, res: Response): Promise<void> => {
try {
const { mcp_id } = req.params;
const userId = req.user?.id;
if (!userId) {
logger.warn('MCP server fetch without user ID');
res.status(401).json({ message: 'User not authenticated' });
return;
}
if (!mcp_id) {
logger.warn('MCP server fetch with missing mcp_id');
res.status(400).json({ message: 'Missing required parameter: mcp_id' });
return;
}
// Find the MCP server
const server = mockMCPServers.find((s) => s.mcp_id === mcp_id);
if (!server) {
logger.warn(`MCP server ${mcp_id} not found for user ${userId}`);
res.status(404).json({ message: 'MCP server not found' });
return;
}
res.json(server);
} catch (error) {
logger.error('Error fetching MCP server:', error);
res.status(500).json({ message: 'Failed to fetch MCP server' });
}
};
/**
* Create a new MCP server
*/
export const createMCPServer = async (req: MCPRequest, res: Response): Promise<void> => {
try {
const { body: formData } = req;
const userId = req.user?.id;
if (!userId) {
logger.warn('MCP server creation without user ID');
res.status(401).json({ message: 'User not authenticated' });
return;
}
// Validate required fields
if (!formData?.metadata?.name || !formData?.metadata?.url) {
logger.warn('MCP server creation with missing required fields');
res.status(400).json({
message: 'Missing required fields: name and url are required',
});
return;
}
// Check if server already exists
const serverExists = mockMCPServers.some(
(server) => server.metadata.name === formData.metadata.name,
);
if (serverExists) {
logger.warn(`MCP server ${formData.metadata.name} already exists for user ${userId}`);
res.status(409).json({ message: 'MCP server already exists' });
return;
}
// Create new MCP server from form data
const newMCPServer: MCP = {
mcp_id: formData.mcp_id || `mcp_${Date.now()}`,
metadata: {
name: formData.metadata.name,
description: formData.metadata.description || '',
url: formData.metadata.url,
tools: formData.metadata.tools || [],
icon: formData.metadata.icon || '🔧',
trust: formData.metadata.trust || false,
customHeaders: formData.metadata.customHeaders || [],
requestTimeout: formData.metadata.requestTimeout || 30000,
connectionTimeout: formData.metadata.connectionTimeout || 10000,
},
agent_id: formData.agent_id || '',
};
logger.info(`Created MCP server: ${newMCPServer.mcp_id} for user ${userId}`);
res.status(201).json(newMCPServer);
} catch (error) {
logger.error('Error creating MCP server:', error);
res.status(500).json({ message: 'Failed to create MCP server' });
}
};
/**
* Update an existing MCP server
*/
export const updateMCPServer = async (req: MCPParamsRequest, res: Response): Promise<void> => {
try {
const {
body: formData,
params: { mcp_id },
} = req;
const userId = req.user?.id;
if (!userId) {
logger.warn('MCP server update without user ID');
res.status(401).json({ message: 'User not authenticated' });
return;
}
// Validate required fields
if (!formData?.metadata?.name || !formData?.metadata?.url) {
logger.warn('MCP server update with missing required fields');
res.status(400).json({
message: 'Missing required fields: name and url are required',
});
return;
}
if (!mcp_id) {
logger.warn('MCP server update with missing mcp_id');
res.status(400).json({ message: 'Missing required parameter: mcp_id' });
return;
}
// Check if server exists
const existingServer = mockMCPServers.find((server) => server.mcp_id === mcp_id);
if (!existingServer) {
logger.warn(`MCP server ${mcp_id} not found for update for user ${userId}`);
res.status(404).json({ message: 'MCP server not found' });
return;
}
// Create updated MCP server from form data
const updatedMCP: MCP = {
mcp_id,
metadata: {
name: formData.metadata.name,
description: formData.metadata.description || existingServer.metadata.description || '',
url: formData.metadata.url,
tools: formData.metadata.tools || existingServer.metadata.tools || [],
icon: formData.metadata.icon || existingServer.metadata.icon || '🔧',
trust:
formData.metadata.trust !== undefined
? formData.metadata.trust
: existingServer.metadata.trust || false,
customHeaders:
formData.metadata.customHeaders || existingServer.metadata.customHeaders || [],
requestTimeout:
formData.metadata.requestTimeout || existingServer.metadata.requestTimeout || 30000,
connectionTimeout:
formData.metadata.connectionTimeout || existingServer.metadata.connectionTimeout || 10000,
},
agent_id: formData.agent_id || existingServer.agent_id || '',
};
// In a real implementation, you would update this in a database
logger.info(`Updated MCP server: ${mcp_id} for user ${userId}`);
res.json(updatedMCP);
} catch (error) {
logger.error('Error updating MCP server:', error);
res.status(500).json({ message: 'Failed to update MCP server' });
}
};
/**
* Delete an MCP server
*/
export const deleteMCPServer = async (req: MCPParamsRequest, res: Response): Promise<void> => {
try {
const {
params: { mcp_id },
} = req;
const userId = req.user?.id;
if (!userId) {
logger.warn('MCP server deletion without user ID');
res.status(401).json({ message: 'User not authenticated' });
return;
}
if (!mcp_id) {
logger.warn('MCP server deletion with missing mcp_id');
res.status(400).json({ message: 'Missing required parameter: mcp_id' });
return;
}
// Check if server exists
const serverExists = mockMCPServers.some((server) => server.mcp_id === mcp_id);
if (!serverExists) {
logger.warn(`MCP server ${mcp_id} not found for deletion for user ${userId}`);
res.status(404).json({ message: 'MCP server not found' });
return;
}
// In a real implementation, you would delete this from a database
logger.info(`Deleted MCP server: ${mcp_id} for user ${userId}`);
res.json({ message: 'MCP server deleted successfully' });
} catch (error) {
logger.error('Error deleting MCP server:', error);
res.status(500).json({ message: 'Failed to delete MCP server' });
}
};

View file

@ -2,5 +2,6 @@ export * from './azure';
export * from './events';
export * from './google';
export * from './mistral';
export * from './mcp';
export * from './openai';
export * from './run';

View file

@ -0,0 +1,17 @@
import type { TUser, MCP } from 'librechat-data-provider';
import type { Request } from 'express';
export interface AuthenticatedRequest extends Request {
user?: TUser;
}
export interface MCPRequest extends AuthenticatedRequest {
body: MCP;
}
export interface MCPParamsRequest extends AuthenticatedRequest {
params: {
mcp_id: string;
};
body: MCP;
}

View file

@ -185,6 +185,21 @@ export const agents = ({ path = '', options }: { path?: string; options?: object
return url;
};
export const mcps = ({ path = '', options }: { path?: string; options?: object }) => {
let url = '/api/mcp';
if (path && path !== '') {
url += `/${path}`;
}
if (options && Object.keys(options).length > 0) {
const queryParams = new URLSearchParams(options as Record<string, string>).toString();
url += `?${queryParams}`;
}
return url;
};
export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`;
export const files = () => '/api/files';

View file

@ -832,3 +832,41 @@ export const createMemory = (data: {
}): Promise<{ created: boolean; memory: q.TUserMemory }> => {
return request.post(endpoints.memories(), data);
};
export const createMCP = (mcp: ag.MCP): Promise<ag.MCP> => {
return request.post(
endpoints.mcps({
path: 'add',
}),
mcp,
);
};
export const getMCPServers = (): Promise<ag.MCP[]> => {
return request.get(endpoints.mcps({}));
};
export const getMCP = (mcp_id: string): Promise<ag.MCP> => {
return request.get(
endpoints.mcps({
path: mcp_id,
}),
);
};
export const updateMCP = ({ mcp_id, data }: { mcp_id: string; data: ag.MCP }): Promise<ag.MCP> => {
return request.put(
endpoints.mcps({
path: mcp_id,
}),
data,
);
};
export const deleteMCP = ({ mcp_id }: { mcp_id: string }): Promise<Record<string, unknown>> => {
return request.delete(
endpoints.mcps({
path: mcp_id,
}),
);
};

View file

@ -48,6 +48,7 @@ export enum QueryKeys {
banner = 'banner',
/* Memories */
memories = 'memories',
mcpServers = 'mcpServers',
}
export enum MutationKeys {

View file

@ -337,18 +337,22 @@ export type MCP = {
metadata: MCPMetadata;
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string });
export type MCPMetadata = Omit<ActionMetadata, 'auth'> & {
name?: string;
export type MCPMetadata = {
name: string;
description?: string;
url?: string;
tools?: string[];
auth?: MCPAuth;
url: string;
tools?: TPlugin[];
icon?: string;
trust?: boolean;
customHeaders?: Array<{
id: string;
name: string;
value: string;
}>;
requestTimeout?: number;
connectionTimeout?: number;
};
export type MCPAuth = ActionAuth;
export type AgentToolType = {
tool_id: string;
metadata: ToolMetadata;

View file

@ -12,7 +12,7 @@ import {
AgentCreateParams,
AgentUpdateParams,
} from './assistants';
import { Action, ActionMetadata } from './agents';
import { Action, ActionMetadata, MCP } from './agents';
export type MutationOptions<
Response,
@ -319,6 +319,15 @@ export type AcceptTermsMutationOptions = MutationOptions<
/* Tools */
export type UpdatePluginAuthOptions = MutationOptions<types.TUser, types.TUpdateUserPlugins>;
export type CreateMCPMutationOptions = MutationOptions<Record<string, unknown>, MCP>;
export type UpdateMCPMutationOptions = MutationOptions<
Record<string, unknown>,
{ mcp_id: string; data: MCP }
>;
export type DeleteMCPMutationOptions = MutationOptions<Record<string, unknown>, { mcp_id: string }>;
export type ToolParamsMap = {
[Tools.execute_code]: {
lang: string;