From c95c7faa5fa10a8e2c7fdeba270869e56068f84d Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Wed, 5 Mar 2025 17:59:08 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20Model=20Context=20Pro?= =?UTF-8?q?tocol=20(MCP)=20support=20(#2809)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: add Model Context Protocol (MCP) server configuration (main) - Added `@modelcontextprotocol/sdk` dependency for MCP integration. - Introduced MCP server configuration UI in settings with add, edit, delete, and activation functionalities. - Created `useMCPServers` hook to manage MCP server state and actions. - Added i18n support for MCP settings with translation keys. - Integrated MCP settings into the application's settings navigation and routing. - Implemented Redux state management for MCP servers. - Updated `yarn.lock` with new dependencies and their resolutions. * 🌟 feat: implement mcp service and integrate with ipc handlers - Added `MCPService` class to manage Model Context Protocol servers. - Implemented various handlers in `ipc.ts` for managing MCP servers including listing, adding, updating, deleting, and activating/deactivating servers. - Integrated MCP related types into existing type declarations for consistency across the application. - Updated `preload` to expose new MCP related APIs to the renderer process. - Enhanced `MCPSettings` component to interact directly with the new MCP service for adding, updating, deleting servers and setting their active states. - Introduced selectors in the MCP Redux slice for fetching active and all servers from the store. - Moved MCP types to a centralized location in `@renderer/types` for reuse across different parts of the application. * feat: enhance MCPService initialization to prevent recursive calls and improve error handling * feat: enhance MCP integration by adding MCPTool type and updating related methods * feat: implement streaming support for tool calls in OpenAIProvider and enhance message processing --- package.json | 1 + src/main/ipc.ts | 47 +- src/main/services/mcp.ts | 337 +++++++++++ src/preload/index.d.ts | 17 +- src/preload/index.ts | 13 +- src/renderer/src/hooks/useKnowledge.ts | 3 +- src/renderer/src/hooks/useMCPServers.ts | 42 ++ src/renderer/src/i18n/locales/en-us.json | 26 + .../src/pages/settings/MCPSettings.tsx | 299 ++++++++++ .../src/pages/settings/SettingsPage.tsx | 9 + src/renderer/src/providers/AiProvider.ts | 10 +- src/renderer/src/providers/OpenAIProvider.ts | 239 ++++++-- src/renderer/src/providers/index.d.ts | 1 + src/renderer/src/services/ApiService.ts | 7 +- src/renderer/src/store/index.ts | 4 +- src/renderer/src/store/mcp.ts | 51 ++ src/renderer/src/types/index.ts | 39 ++ yarn.lock | 561 +++++++++++++++++- 18 files changed, 1609 insertions(+), 97 deletions(-) create mode 100644 src/main/services/mcp.ts create mode 100644 src/renderer/src/hooks/useMCPServers.ts create mode 100644 src/renderer/src/pages/settings/MCPSettings.tsx create mode 100644 src/renderer/src/store/mcp.ts diff --git a/package.json b/package.json index 590189d9..3623a8cb 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@llm-tools/embedjs-loader-web": "^0.1.28", "@llm-tools/embedjs-loader-xml": "^0.1.28", "@llm-tools/embedjs-openai": "^0.1.28", + "@modelcontextprotocol/sdk": "^1.6.1", "@notionhq/client": "^2.2.15", "@types/react-infinite-scroll-component": "^5.0.0", "adm-zip": "^0.5.16", diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 918f2380..8369879c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,7 +1,7 @@ import fs from 'node:fs' import path from 'node:path' -import { Shortcut, ThemeMode } from '@types' +import { MCPServer, Shortcut, ThemeMode } from '@types' import { BrowserWindow, ipcMain, ProxyConfig, session, shell } from 'electron' import log from 'electron-log' @@ -14,17 +14,18 @@ import FileService from './services/FileService' import FileStorage from './services/FileStorage' import { GeminiService } from './services/GeminiService' import KnowledgeService from './services/KnowledgeService' +import MCPService from './services/mcp' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' import { getResourcePath } from './utils' -import { decrypt } from './utils/aes' -import { encrypt } from './utils/aes' +import { decrypt, encrypt } from './utils/aes' import { compress, decompress } from './utils/zip' const fileManager = new FileStorage() const backupManager = new BackupManager() const exportService = new ExportService(fileManager) +const mcpService = new MCPService() export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const appUpdater = new AppUpdater(mainWindow) @@ -210,4 +211,44 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle('aes:decrypt', (_, encryptedData: string, iv: string, secretKey: string) => decrypt(encryptedData, iv, secretKey) ) + + // Register MCP handlers + ipcMain.handle('mcp:list-servers', async () => { + return mcpService.listAvailableServices() + }) + + ipcMain.handle('mcp:add-server', async (_, server: MCPServer) => { + return mcpService.addServer(server) + }) + + ipcMain.handle('mcp:update-server', async (_, server: MCPServer) => { + return mcpService.updateServer(server) + }) + + ipcMain.handle('mcp:delete-server', async (_, serverName: string) => { + return mcpService.deleteServer(serverName) + }) + + ipcMain.handle('mcp:set-server-active', async (_, { name, isActive }) => { + return mcpService.setServerActive({ name, isActive }) + }) + + // According to preload, this should take no parameters, but our implementation accepts + // an optional serverName for better flexibility + ipcMain.handle('mcp:list-tools', async (_, serverName?: string) => { + return mcpService.listTools(serverName) + }) + + ipcMain.handle('mcp:call-tool', async (_, params: { client: string; name: string; args: any }) => { + return mcpService.callTool(params) + }) + + ipcMain.handle('mcp:cleanup', async () => { + return mcpService.cleanup() + }) + + // Clean up MCP services when app quits + app.on('before-quit', async () => { + await mcpService.cleanup() + }) } diff --git a/src/main/services/mcp.ts b/src/main/services/mcp.ts new file mode 100644 index 00000000..29fe3462 --- /dev/null +++ b/src/main/services/mcp.ts @@ -0,0 +1,337 @@ +import { MCPServer, MCPTool } from '@types' +import log from 'electron-log' +import Store from 'electron-store' +import { EventEmitter } from 'events' + +const store = new Store() + +export default class MCPService extends EventEmitter { + private activeServers: Map = new Map() + private clients: { [key: string]: any } = {} + private Client: any + private Transport: any + private initialized = false + private initPromise: Promise | null = null + + constructor() { + super() + this.init().catch((err) => { + log.error('[MCP] Failed to initialize MCP service:', err) + }) + } + private getServersFromStore(): MCPServer[] { + return store.get('mcp.servers', []) as MCPServer[] + } + + public async init() { + // If already initialized, return immediately + if (this.initialized) return + + // If initialization is in progress, return that promise + if (this.initPromise) return this.initPromise + + // Create and store the initialization promise + this.initPromise = (async () => { + try { + log.info('[MCP] Starting initialization') + this.Client = await this.importClient() + this.Transport = await this.importTransport() + + // Mark as initialized before loading servers to prevent recursive initialization + this.initialized = true + + await this.load(this.getServersFromStore()) + log.info('[MCP] Initialization completed successfully') + } catch (err) { + this.initialized = false // Reset flag on error + log.error('[MCP] Failed to initialize:', err) + throw err + } finally { + this.initPromise = null + } + })() + + return this.initPromise + } + + private async importClient() { + try { + const { Client } = await import('@modelcontextprotocol/sdk/client/index.js') + return Client + } catch (err) { + log.error('[MCP] Failed to import Client:', err) + throw err + } + } + + private async importTransport() { + try { + const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js') + return StdioClientTransport + } catch (err) { + log.error('[MCP] Failed to import Transport:', err) + throw err + } + } + + public async listAvailableServices(): Promise { + await this.ensureInitialized() + return this.getServersFromStore() + } + + private async ensureInitialized() { + if (!this.initialized) { + log.debug('[MCP] Ensuring initialization') + await this.init() + } + } + + public async addServer(server: MCPServer): Promise { + await this.ensureInitialized() + try { + const servers = this.getServersFromStore() + if (servers.some((s) => s.name === server.name)) { + throw new Error(`Server with name ${server.name} already exists`) + } + + servers.push(server) + store.set('mcp.servers', servers) + + if (server.isActive) { + await this.activate(server) + } + } catch (error) { + log.error('Failed to add MCP server:', error) + throw error + } + } + + public async updateServer(server: MCPServer): Promise { + await this.ensureInitialized() + try { + const servers = this.getServersFromStore() + const index = servers.findIndex((s) => s.name === server.name) + + if (index === -1) { + throw new Error(`Server ${server.name} not found`) + } + + const wasActive = servers[index].isActive + if (wasActive && !server.isActive) { + await this.deactivate(server.name) + } else if (!wasActive && server.isActive) { + await this.activate(server) + } + + servers[index] = server + store.set('mcp.servers', servers) + } catch (error) { + log.error('Failed to update MCP server:', error) + throw error + } + } + + public async deleteServer(serverName: string): Promise { + await this.ensureInitialized() + try { + if (this.clients[serverName]) { + await this.deactivate(serverName) + } + + const servers = this.getServersFromStore() + const filteredServers = servers.filter((s) => s.name !== serverName) + store.set('mcp.servers', filteredServers) + } catch (error) { + log.error('Failed to delete MCP server:', error) + throw error + } + } + + public async setServerActive(params: { name: string; isActive: boolean }): Promise { + await this.ensureInitialized() + try { + const { name, isActive } = params + const servers = this.getServersFromStore() + const server = servers.find((s) => s.name === name) + + if (!server) { + throw new Error(`Server ${name} not found`) + } + + server.isActive = isActive + store.set('mcp.servers', servers) + + if (isActive) { + await this.activate(server) + } else { + await this.deactivate(name) + } + } catch (error) { + log.error('Failed to set MCP server active status:', error) + throw error + } + } + + public async activate(server: MCPServer): Promise { + await this.ensureInitialized() + try { + const { name, command, args, env } = server + + if (this.clients[name]) { + log.info(`[MCP] Server ${name} is already running`) + return + } + + let cmd: string = command + if (command === 'npx') { + cmd = process.platform === 'win32' ? `${command}.cmd` : command + } + + const mergedEnv = { + ...env, + PATH: process.env.PATH + } + + const client = new this.Client( + { + name: name, + version: '1.0.0' + }, + { + capabilities: {} + } + ) + + const transport = new this.Transport({ + command: cmd, + args, + stderr: process.platform === 'win32' ? 'pipe' : 'inherit', + env: mergedEnv + }) + + await client.connect(transport) + this.clients[name] = client + this.activeServers.set(name, { client, server }) + + log.info(`[MCP] Server ${name} started successfully`) + this.emit('server-started', { name }) + } catch (error) { + log.error('[MCP] Error activating server:', error) + throw error + } + } + + public async deactivate(name: string): Promise { + await this.ensureInitialized() + try { + if (this.clients[name]) { + log.info(`[MCP] Stopping server: ${name}`) + await this.clients[name].close() + delete this.clients[name] + this.activeServers.delete(name) + this.emit('server-stopped', { name }) + } else { + log.warn(`[MCP] Server ${name} is not running`) + } + } catch (error) { + log.error('[MCP] Error deactivating server:', error) + throw error + } + } + + public async listTools(serverName?: string): Promise { + await this.ensureInitialized() + try { + if (serverName) { + if (!this.clients[serverName]) { + throw new Error(`MCP Client ${serverName} not found`) + } + const { tools } = await this.clients[serverName].listTools() + return tools.map((tool: any) => { + return tool + }) + } else { + let allTools: MCPTool[] = [] + for (const clientName in this.clients) { + try { + const { tools } = await this.clients[clientName].listTools() + log.info(`[MCP] Tools for ${clientName}:`, tools) + allTools = allTools.concat( + tools.map((tool: MCPTool) => { + tool.serverName = clientName + return tool + }) + ) + } catch (error) { + log.error(`[MCP] Error listing tools for ${clientName}:`, error) + } + } + log.info(`[MCP] Total tools listed: ${allTools.length}`) + return allTools + } + } catch (error) { + log.error('[MCP] Error listing tools:', error) + return [] + } + } + + public async callTool(params: { client: string; name: string; args: any }): Promise { + await this.ensureInitialized() + try { + const { client, name, args } = params + if (!this.clients[client]) { + throw new Error(`MCP Client ${client} not found`) + } + + log.info('[MCP] Calling:', client, name, args) + const result = await this.clients[client].callTool({ + name, + arguments: args + }) + return result + } catch (error) { + log.error(`[MCP] Error calling tool ${params.name} on ${params.client}:`, error) + throw error + } + } + + public async cleanup(): Promise { + try { + for (const name in this.clients) { + await this.deactivate(name).catch((err) => { + log.error(`[MCP] Error during cleanup of ${name}:`, err) + }) + } + this.clients = {} + this.activeServers.clear() + log.info('[MCP] All servers cleaned up') + } catch (error) { + log.error('[MCP] Failed to clean up servers:', error) + throw error + } + } + + public async load(servers: MCPServer[]): Promise { + log.info(`[MCP] Loading ${servers.length} servers`) + + const activeServers = servers.filter((server) => server.isActive) + + if (activeServers.length === 0) { + log.info('[MCP] No active servers to load') + return + } + + for (const server of activeServers) { + log.info(`[MCP] Activating server: ${server.name}`) + try { + await this.activate(server) + log.info(`[MCP] Successfully activated server: ${server.name}`) + } catch (error) { + log.error(`[MCP] Failed to activate server ${server.name}:`, error) + this.emit('server-error', { name: server.name, error }) + } + } + + log.info(`[MCP] Loaded and activated ${Object.keys(this.clients).length} servers`) + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index bb0624bf..2ae5d251 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,9 +1,7 @@ import { ElectronAPI } from '@electron-toolkit/preload' import type { FileMetadataResponse, ListFilesResponse, UploadFileResponse } from '@google/generative-ai/server' import { ExtractChunkData } from '@llm-tools/embedjs-interfaces' -import { FileType } from '@renderer/types' -import { WebDavConfig } from '@renderer/types' -import { AppInfo, KnowledgeBaseParams, KnowledgeItem, LanguageVarious } from '@renderer/types' +import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, WebDavConfig } from '@renderer/types' import type { LoaderReturn } from '@shared/config/types' import type { OpenDialogOptions } from 'electron' import type { UpdateInfo } from 'electron-updater' @@ -123,6 +121,19 @@ declare global { shell: { openExternal: (url: string, options?: OpenExternalOptions) => Promise } + mcp: { + // servers + listServers: () => Promise + addServer: (server: MCPServer) => Promise + updateServer: (server: MCPServer) => Promise + deleteServer: (serverName: string) => Promise + setServerActive: (name: string, isActive: boolean) => Promise + // tools + listTools: () => Promise + callTool: ({ client, name, args }: { client: string; name: string; args: any }) => Promise + // status + cleanup: () => Promise + } } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index ca93f359..a050cc36 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ import { electronAPI } from '@electron-toolkit/preload' -import { FileType, KnowledgeBaseParams, KnowledgeItem, Shortcut, WebDavConfig } from '@types' +import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron' // Custom APIs for renderer @@ -106,6 +106,17 @@ const api = { decrypt: (encryptedData: string, iv: string, secretKey: string) => ipcRenderer.invoke('aes:decrypt', encryptedData, iv, secretKey) }, + mcp: { + listServers: () => ipcRenderer.invoke('mcp:list-servers'), + addServer: (server: MCPServer) => ipcRenderer.invoke('mcp:add-server', server), + updateServer: (server: MCPServer) => ipcRenderer.invoke('mcp:update-server', server), + deleteServer: (serverName: string) => ipcRenderer.invoke('mcp:delete-server', serverName), + setServerActive: (name: string, isActive: boolean) => + ipcRenderer.invoke('mcp:set-server-active', { name, isActive }), + listTools: (serverName?: string) => ipcRenderer.invoke('mcp:list-tools', serverName), + callTool: (params: { client: string; name: string; args: any }) => ipcRenderer.invoke('mcp:call-tool', params), + cleanup: () => ipcRenderer.invoke('mcp:cleanup') + }, shell: { openExternal: shell.openExternal } diff --git a/src/renderer/src/hooks/useKnowledge.ts b/src/renderer/src/hooks/useKnowledge.ts index 347f15a3..628a51ec 100644 --- a/src/renderer/src/hooks/useKnowledge.ts +++ b/src/renderer/src/hooks/useKnowledge.ts @@ -19,8 +19,7 @@ import { updateItemProcessingStatus, updateNotes } from '@renderer/store/knowledge' -import { FileType, KnowledgeBase, ProcessingStatus } from '@renderer/types' -import { KnowledgeItem } from '@renderer/types' +import { FileType, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types' import { runAsyncFunction } from '@renderer/utils' import { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' diff --git a/src/renderer/src/hooks/useMCPServers.ts b/src/renderer/src/hooks/useMCPServers.ts new file mode 100644 index 00000000..21b90c1b --- /dev/null +++ b/src/renderer/src/hooks/useMCPServers.ts @@ -0,0 +1,42 @@ +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { + addMCPServer as _addMCPServer, + deleteMCPServer as _deleteMCPServer, + setMCPServerActive as _setMCPServerActive, + updateMCPServer as _updateMCPServer +} from '@renderer/store/mcp' +import { MCPServer } from '@renderer/types' + +export const useMCPServers = () => { + const mcpServers = useAppSelector((state) => state.mcp.servers) + const dispatch = useAppDispatch() + + const addMCPServer = (server: MCPServer) => { + dispatch(_addMCPServer(server)) + } + + const updateMCPServer = (server: MCPServer) => { + dispatch(_updateMCPServer(server)) + } + + const deleteMCPServer = (name: string) => { + dispatch(_deleteMCPServer(name)) + } + + const setMCPServerActive = (name: string, isActive: boolean) => { + dispatch(_setMCPServerActive({ name, isActive })) + } + + const getActiveMCPServers = () => { + return mcpServers.filter((server) => server.isActive) + } + + return { + mcpServers, + addMCPServer, + updateMCPServer, + deleteMCPServer, + setMCPServerActive, + getActiveMCPServers + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index f82423f3..ca825ab3 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -859,6 +859,32 @@ "blacklist_tooltip": "Please use the following format (separated by line breaks)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com", "search_max_result": "Number of search results", "search_result_default": "Default" + }, + "mcp": { + "title": "MCP Servers", + "config_description": "Configure Model Context Protocol servers", + "description": "Description", + "addServer": "Add Server", + "editServer": "Edit Server", + "name": "Name", + "command": "Command", + "args": "Arguments", + "argsTooltip": "Each argument on a new line", + "env": "Environment Variables", + "envTooltip": "Format: KEY=value, one per line", + "active": "Active", + "actions": "Actions", + "noServers": "No servers configured", + "nameRequired": "Please enter a server name", + "commandRequired": "Please enter a command", + "confirmDelete": "Delete Server", + "confirmDeleteMessage": "Are you sure you want to delete the server?", + "addSuccess": "Server added successfully", + "updateSuccess": "Server updated successfully", + "deleteSuccess": "Server deleted successfully", + "duplicateName": "A server with this name already exists", + "serverSingular": "server", + "serverPlural": "servers" } }, "translate": { diff --git a/src/renderer/src/pages/settings/MCPSettings.tsx b/src/renderer/src/pages/settings/MCPSettings.tsx new file mode 100644 index 00000000..cdc1dc98 --- /dev/null +++ b/src/renderer/src/pages/settings/MCPSettings.tsx @@ -0,0 +1,299 @@ +import { DeleteOutlined, EditOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons' +import { useTheme } from '@renderer/context/ThemeProvider' +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { addMCPServer, deleteMCPServer, setMCPServerActive, updateMCPServer } from '@renderer/store/mcp' +import { MCPServer } from '@renderer/types' +import { Button, Card, Form, Input, message, Modal, Space, Switch, Table, Tooltip, Typography } from 'antd' +import TextArea from 'antd/es/input/TextArea' +import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.' + +interface MCPFormValues { + name: string + command: string + description?: string + args: string + env?: string + isActive: boolean +} + +const MCPSettings: FC = () => { + const { t } = useTranslation() + const { theme } = useTheme() + const { Paragraph, Text } = Typography + const dispatch = useAppDispatch() + const mcpServers = useAppSelector((state) => state.mcp.servers) + + const [isModalVisible, setIsModalVisible] = useState(false) + const [editingServer, setEditingServer] = useState(null) + const [loading, setLoading] = useState(false) + const [form] = Form.useForm() + + const showAddModal = () => { + form.resetFields() + setEditingServer(null) + setIsModalVisible(true) + } + + const showEditModal = (server: MCPServer) => { + setEditingServer(server) + form.setFieldsValue({ + name: server.name, + command: server.command, + description: server.description, + args: server.args.join('\n'), + env: server.env + ? Object.entries(server.env) + .map(([key, value]) => `${key}=${value}`) + .join('\n') + : '', + isActive: server.isActive + }) + setIsModalVisible(true) + } + + const handleCancel = () => { + setIsModalVisible(false) + form.resetFields() + } + + const handleSubmit = () => { + setLoading(true) + form + .validateFields() + .then((values) => { + const args = values.args ? values.args.split('\n').filter((arg) => arg.trim() !== '') : [] + + const env: Record = {} + if (values.env) { + values.env.split('\n').forEach((line) => { + if (line.trim()) { + const [key, value] = line.split('=') + if (key && value) { + env[key.trim()] = value.trim() + } + } + }) + } + + const mcpServer: MCPServer = { + name: values.name, + command: values.command, + description: values.description, + args, + env: Object.keys(env).length > 0 ? env : undefined, + isActive: values.isActive + } + + if (editingServer) { + window.api.mcp + .updateServer(mcpServer) + .then(() => { + message.success(t('settings.mcp.updateSuccess')) + setLoading(false) + setIsModalVisible(false) + form.resetFields() + }) + .catch((error) => { + message.error(`${t('settings.mcp.updateError')}: ${error.message}`) + setLoading(false) + }) + dispatch(updateMCPServer(mcpServer)) + } else { + // Check for duplicate name + if (mcpServers.some((server: MCPServer) => server.name === mcpServer.name)) { + message.error(t('settings.mcp.duplicateName')) + setLoading(false) + return + } + + window.api.mcp + .addServer(mcpServer) + .then(() => { + message.success(t('settings.mcp.addSuccess')) + setLoading(false) + setIsModalVisible(false) + form.resetFields() + }) + .catch((error) => { + message.error(`${t('settings.mcp.addError')}: ${error.message}`) + setLoading(false) + }) + dispatch(addMCPServer(mcpServer)) + } + }) + .catch(() => { + setLoading(false) + }) + } + + const handleDelete = (serverName: string) => { + Modal.confirm({ + title: t('settings.mcp.confirmDelete'), + content: t('settings.mcp.confirmDeleteMessage'), + okText: t('common.delete'), + okButtonProps: { danger: true }, + cancelText: t('common.cancel'), + onOk: () => { + window.api.mcp + .deleteServer(serverName) + .then(() => { + message.success(t('settings.mcp.deleteSuccess')) + }) + .catch((error) => { + message.error(`${t('settings.mcp.deleteError')}: ${error.message}`) + }) + dispatch(deleteMCPServer(serverName)) + } + }) + } + + const handleToggleActive = (name: string, isActive: boolean) => { + window.api.mcp + .setServerActive(name, isActive) + .then(() => { + // Optional: Show success message or update UI + }) + .catch((error) => { + message.error(`${t('settings.mcp.toggleError')}: ${error.message}`) + }) + dispatch(setMCPServerActive({ name, isActive })) + } + + const columns = [ + { + title: t('settings.mcp.name'), + dataIndex: 'name', + key: 'name', + width: '20%', + render: (text: string, record: MCPServer) => {text} + }, + { + title: t('settings.mcp.description'), + dataIndex: 'description', + key: 'description', + width: '40%', + render: (text: string) => + text || ( + + {t('common.description')} + + ) + }, + { + title: t('settings.mcp.active'), + dataIndex: 'isActive', + key: 'isActive', + width: '15%', + render: (isActive: boolean, record: MCPServer) => ( + handleToggleActive(record.name, checked)} /> + ) + }, + { + title: t('settings.mcp.actions'), + key: 'actions', + width: '25%', + render: (_: any, record: MCPServer) => ( + + + + + {mcpServers.length}{' '} + {mcpServers.length === 1 ? t('settings.mcp.serverSingular') : t('settings.mcp.serverPlural')} + + + + + (!record.isActive ? 'inactive-row' : '')} + onRow={(record) => ({ + style: !record.isActive ? inactiveRowStyle : {} + })} + /> + + + +
+ + + + + +