diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index 3dfed4d240..7d33bcbd1e 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -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; diff --git a/client/src/common/mcp.ts b/client/src/common/mcp.ts index b4f44a1f94..d6739e168c 100644 --- a/client/src/common/mcp.ts +++ b/client/src/common/mcp.ts @@ -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, }; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index c7f2d6788a..2f49151a81 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -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 & { diff --git a/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx b/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx index 495b047b51..b88b16b762 100644 --- a/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx @@ -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 ; } - if (activePanel === Panel.mcp) { - return ; - } return ; } diff --git a/client/src/components/SidePanel/Agents/MCPSection.tsx b/client/src/components/SidePanel/Agents/MCPSection.tsx index 4575eb8f25..575fc1ec2e 100644 --- a/client/src/components/SidePanel/Agents/MCPSection.tsx +++ b/client/src/components/SidePanel/Agents/MCPSection.tsx @@ -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) => ( - { diff --git a/client/src/components/SidePanel/Builder/MCPAuth.tsx b/client/src/components/SidePanel/Builder/MCPAuth.tsx deleted file mode 100644 index 4ea3faae61..0000000000 --- a/client/src/components/SidePanel/Builder/MCPAuth.tsx +++ /dev/null @@ -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 ( - - - - ); -} diff --git a/client/src/components/SidePanel/MCP/MCPConfig.tsx b/client/src/components/SidePanel/MCP/MCPConfig.tsx new file mode 100644 index 0000000000..2d131f4b97 --- /dev/null +++ b/client/src/components/SidePanel/MCP/MCPConfig.tsx @@ -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 ( +
+ {/* Authentication Accordion */} + + + + {localize('com_ui_authentication')} + + +
+ {/* Custom Headers Section - Individual Inputs Version */} +
+
+ + 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" + > + + {localize('com_ui_mcp_headers')} + + } + /> +
+ +
+ {customHeaders.length === 0 ? ( +
+

+ {localize('com_ui_mcp_no_custom_headers')} +

+ +
+ ) : ( + <> + {customHeaders.map((header: any) => ( +
+ 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" + /> + 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" + /> + +
+ ))} + {/* Add New Header Button */} +
+ +
+ + )} +
+
+
+
+
+
+ + {/* Configuration Accordion */} + + + + {localize('com_ui_mcp_configuration')} + + +
+ {/* Request Timeout */} +
+ + +

+ {localize('com_ui_mcp_request_timeout_description')} +

+
+ + {/* Connection Timeout */} +
+ + +

+ {localize('com_ui_mcp_connection_timeout_description')} +

+
+
+
+
+
+
+ ); +} diff --git a/client/src/components/SidePanel/Agents/MCPPanel.tsx b/client/src/components/SidePanel/MCP/MCPFormPanel.tsx similarity index 50% rename from client/src/components/SidePanel/Agents/MCPPanel.tsx rename to client/src/components/SidePanel/MCP/MCPFormPanel.tsx index 016e445e4f..99fc5c95ba 100644 --- a/client/src/components/SidePanel/Agents/MCPPanel.tsx +++ b/client/src/components/SidePanel/MCP/MCPFormPanel.tsx @@ -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; } -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({ - 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 (
-
- {!!mcp && ( + {!!mcp && showDeleteButton && (
- +
diff --git a/client/src/components/SidePanel/Agents/MCPInput.tsx b/client/src/components/SidePanel/MCP/MCPInput.tsx similarity index 71% rename from client/src/components/SidePanel/Agents/MCPInput.tsx rename to client/src/components/SidePanel/MCP/MCPInput.tsx index 6c2f2ce51e..b76834d586 100644 --- a/client/src/components/SidePanel/Agents/MCPInput.tsx +++ b/client/src/components/SidePanel/MCP/MCPInput.tsx @@ -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>; + 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(); - const [isLoading, setIsLoading] = useState(false); const [showTools, setShowTools] = useState(false); const [selectedTools, setSelectedTools] = useState([]); @@ -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) { )}
- -
+ +
( - + )} /> - + +
+
+ + {localize('com_agents_mcp_trust_subtext')} +
{errors.trust && ( - {localize('com_ui_field_required')} +
+ {localize('com_ui_field_required')} +
)}
@@ -231,7 +198,7 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) { + ); } @@ -144,15 +179,28 @@ export default function MCPPanel() {
{mcpServerDefinitions.map((server) => ( - +
+ {server.serverName} +
+ ))} +
); @@ -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 ))}
- {Object.keys(server.config.customUserVars).length > 0 && ( + {Object.keys(server.config.customUserVars || {}).length > 0 && (