mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
✨ 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:
parent
20100e120b
commit
351f30254c
26 changed files with 1189 additions and 290 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'> & {
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
222
client/src/components/SidePanel/MCP/MCPConfig.tsx
Normal file
222
client/src/components/SidePanel/MCP/MCPConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
>
|
||||
{(() => {
|
||||
|
|
@ -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 (
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
1
client/src/data-provider/MCPs/index.ts
Normal file
1
client/src/data-provider/MCPs/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './mutations';
|
||||
73
client/src/data-provider/MCPs/mutations.ts
Normal file
73
client/src/data-provider/MCPs/mutations.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
212
packages/api/src/mcp/servers.spec.ts
Normal file
212
packages/api/src/mcp/servers.spec.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
270
packages/api/src/mcp/servers.ts
Normal file
270
packages/api/src/mcp/servers.ts
Normal 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' });
|
||||
}
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
17
packages/api/src/types/mcp.ts
Normal file
17
packages/api/src/types/mcp.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export enum QueryKeys {
|
|||
banner = 'banner',
|
||||
/* Memories */
|
||||
memories = 'memories',
|
||||
mcpServers = 'mcpServers',
|
||||
}
|
||||
|
||||
export enum MutationKeys {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue