diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 85817efc..fa53f082 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -264,6 +264,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // Register MCP handlers ipcMain.handle('mcp:remove-server', mcpService.removeServer) + ipcMain.handle('mcp:restart-server', mcpService.restartServer) + ipcMain.handle('mcp:stop-server', mcpService.stopServer) ipcMain.handle('mcp:list-tools', mcpService.listTools) ipcMain.handle('mcp:call-tool', mcpService.callTool) ipcMain.handle('mcp:get-install-info', mcpService.getInstallInfo) diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index e37a9b4f..c9c4185b 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -5,12 +5,14 @@ import { getBinaryName, getBinaryPath } from '@main/utils/process' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' -import { MCPServer } from '@types' +import { nanoid } from '@reduxjs/toolkit' +import { MCPServer, MCPTool } from '@types' import { app } from 'electron' import Logger from 'electron-log' +import { CacheService } from './CacheService' + class McpService { - private client: Client | null = null private clients: Map = new Map() private getServerKey(server: MCPServer): string { @@ -29,25 +31,30 @@ class McpService { this.callTool = this.callTool.bind(this) this.closeClient = this.closeClient.bind(this) this.removeServer = this.removeServer.bind(this) + this.restartServer = this.restartServer.bind(this) + this.stopServer = this.stopServer.bind(this) } - async initClient(server: MCPServer) { + async initClient(server: MCPServer): Promise { const serverKey = this.getServerKey(server) // Check if we already have a client for this server configuration const existingClient = this.clients.get(serverKey) if (existingClient) { - this.client = existingClient - return - } - - // If there's an existing client for a different server, close it - if (this.client) { - await this.closeClient() + // Check if the existing client is still connected + const pingResult = await existingClient.ping() + Logger.info(`[MCP] Ping result for ${server.name}:`, pingResult) + // If the ping fails, remove the client from the cache + // and create a new one + if (!pingResult) { + this.clients.delete(serverKey) + } else { + return existingClient + } } // Create new client instance for each connection - this.client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} }) + const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} }) const args = [...(server.args || [])] @@ -95,46 +102,76 @@ class McpService { throw new Error('Either baseUrl or command must be provided') } - await this.client.connect(transport) + await client.connect(transport) // Store the new client in the cache - this.clients.set(serverKey, this.client) + this.clients.set(serverKey, client) Logger.info(`[MCP] Activated server: ${server.name}`) + return client } catch (error: any) { Logger.error(`[MCP] Error activating server ${server.name}:`, error) throw error } } - async closeClient() { - if (this.client) { + async closeClient(serverKey: string) { + const client = this.clients.get(serverKey) + if (client) { // Remove the client from the cache - for (const [key, client] of this.clients.entries()) { - if (client === this.client) { - this.clients.delete(key) - break - } - } - - await this.client.close() - this.client = null + await client.close() + Logger.info(`[MCP] Closed server: ${serverKey}`) + this.clients.delete(serverKey) + } else { + Logger.warn(`[MCP] No client found for server: ${serverKey}`) } } + async stopServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) { + const serverKey = this.getServerKey(server) + Logger.info(`[MCP] Stopping server: ${server.name}`) + await this.closeClient(serverKey) + } + async removeServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) { - await this.closeClient() - this.clients.delete(this.getServerKey(server)) + const serverKey = this.getServerKey(server) + const existingClient = this.clients.get(serverKey) + if (existingClient) { + await this.closeClient(serverKey) + } + } + + async restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) { + Logger.info(`[MCP] Restarting server: ${server.name}`) + const serverKey = this.getServerKey(server) + await this.closeClient(serverKey) + await this.initClient(server) } async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) { - await this.initClient(server) - const { tools } = await this.client!.listTools() - return tools.map((tool) => ({ - ...tool, - serverId: server.id, - serverName: server.name - })) + const client = await this.initClient(server) + const cacheKey = `mcp:list_tool:${server.id}` + if (CacheService.has(cacheKey)) { + Logger.info(`[MCP] Tools from ${server.name} loaded from cache`) + const cachedTools = CacheService.get(cacheKey) + if (cachedTools && cachedTools.length > 0) { + return cachedTools + } + } + Logger.info(`[MCP] Listing tools for server: ${server.name}`) + const { tools } = await client.listTools() + const serverTools: MCPTool[] = [] + tools.map((tool: any) => { + const serverTool: MCPTool = { + ...tool, + id: nanoid(), + serverId: server.id, + serverName: server.name + } + serverTools.push(serverTool) + }) + CacheService.set(cacheKey, serverTools, 5 * 60 * 1000) + return serverTools } /** @@ -144,11 +181,10 @@ class McpService { _: Electron.IpcMainInvokeEvent, { server, name, args }: { server: MCPServer; name: string; args: any } ): Promise { - await this.initClient(server) - try { Logger.info('[MCP] Calling:', server.name, name, args) - const result = await this.client!.callTool({ name, arguments: args }) + const client = await this.initClient(server) + const result = await client.callTool({ name, arguments: args }) return result } catch (error) { Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index bb2b3efa..1e2fb51a 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -147,6 +147,8 @@ declare global { } mcp: { removeServer: (server: MCPServer) => Promise + restartServer: (server: MCPServer) => Promise + stopServer: (server: MCPServer) => Promise listTools: (server: MCPServer) => Promise callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }> diff --git a/src/preload/index.ts b/src/preload/index.ts index 53a6dd7a..50aa5a2d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -121,6 +121,8 @@ const api = { }, mcp: { removeServer: (server: MCPServer) => ipcRenderer.invoke('mcp:remove-server', server), + restartServer: (server: MCPServer) => ipcRenderer.invoke('mcp:restart-server', server), + stopServer: (server: MCPServer) => ipcRenderer.invoke('mcp:stop-server', server), listTools: (server: MCPServer) => ipcRenderer.invoke('mcp:list-tools', server), callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => ipcRenderer.invoke('mcp:call-tool', { server, name, args }), diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index adb4ad4f..b643f11a 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1013,7 +1013,12 @@ "updateSuccess": "Server updated successfully", "url": "URL", "editMcpJson": "Edit MCP Configuration", - "installHelp": "Get Installation Help" + "installHelp": "Get Installation Help", + "tools": { + "inputSchema": "Input Schema", + "availableTools": "Available Tools", + "noToolsAvailable": "No tools available" + } }, "messages.divider": "Show divider between messages", "messages.grid_columns": "Message grid display columns", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 973ec7e0..c7ecf2dd 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1012,7 +1012,12 @@ "32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください" }, "editMcpJson": "MCP 設定を編集", - "installHelp": "インストールヘルプを取得" + "installHelp": "インストールヘルプを取得", + "tools": { + "inputSchema": "入力スキーマ", + "availableTools": "利用可能なツール", + "noToolsAvailable": "利用可能なツールはありません" + } }, "messages.divider": "メッセージ間に区切り線を表示", "messages.grid_columns": "メッセージグリッドの表示列数", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 468c1a3e..5834edef 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1011,8 +1011,13 @@ "updateError": "Ошибка обновления сервера", "updateSuccess": "Сервер успешно обновлен", "url": "URL", - "editMcpJson": "Редактировать MCP 配置", - "installHelp": "Получить помощь по установке" + "editMcpJson": "Редактировать MCP", + "installHelp": "Получить помощь по установке", + "tools": { + "inputSchema": "входные параметры", + "availableTools": "доступные инструменты", + "noToolsAvailable": "нет доступных инструментов" + } }, "messages.divider": "Показывать разделитель между сообщениями", "messages.grid_columns": "Количество столбцов сетки сообщений", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 34c9daa9..51a43ebe 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1013,7 +1013,12 @@ "updateSuccess": "服务器更新成功", "url": "URL", "editMcpJson": "编辑 MCP 配置", - "installHelp": "获取安装帮助" + "installHelp": "获取安装帮助", + "tools": { + "inputSchema": "输入参数", + "availableTools": "可用工具", + "noToolsAvailable": "没有可用工具" + } }, "messages.divider": "消息分割线", "messages.grid_columns": "消息网格展示列数", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 5235a5c7..21cec760 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1012,7 +1012,12 @@ "updateSuccess": "伺服器更新成功", "url": "URL", "editMcpJson": "編輯 MCP 配置", - "installHelp": "獲取安裝幫助" + "installHelp": "獲取安裝幫助", + "tools": { + "inputSchema": "輸入參數", + "availableTools": "可用工具", + "noToolsAvailable": "沒有可用工具" + } }, "messages.divider": "訊息間顯示分隔線", "messages.grid_columns": "訊息網格展示列數", diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index 2d5e41f7..8930d0c6 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -13,7 +13,7 @@ interface Props { } const MCPToolsButton: FC = ({ enabledMCPs, toggelEnableMCP, ToolbarButton }) => { - const { mcpServers, activedMcpServers } = useMCPServers() + const { activedMcpServers } = useMCPServers() const [isOpen, setIsOpen] = useState(false) const dropdownRef = useRef(null) const menuRef = useRef(null) @@ -25,14 +25,15 @@ const MCPToolsButton: FC = ({ enabledMCPs, toggelEnableMCP, ToolbarButton } // Check if all active servers are enabled - const activeServers = mcpServers.filter((s) => s.isActive) - const anyEnable = activeServers.some((server) => enabledMCPs.some((enabledServer) => enabledServer.id === server.id)) + const anyEnable = activedMcpServers.some((server) => + enabledMCPs.some((enabledServer) => enabledServer.id === server.id) + ) - const enableAll = () => mcpServers.forEach(toggelEnableMCP) + const enableAll = () => activedMcpServers.forEach(toggelEnableMCP) const disableAll = () => - mcpServers.forEach((s) => { + activedMcpServers.forEach((s) => { enabledMCPs.forEach((enabledServer) => { if (enabledServer.id === s.id) { toggelEnableMCP(s) @@ -60,27 +61,25 @@ const MCPToolsButton: FC = ({ enabledMCPs, toggelEnableMCP, ToolbarButton - {mcpServers.length > 0 ? ( - mcpServers - .filter((s) => s.isActive) - .map((server) => ( - -
-
{server.name}
- {server.description && ( - -
{truncateText(server.description)}
-
- )} - {server.baseUrl &&
{server.baseUrl}
} -
- s.id === server.id)} - onChange={() => toggelEnableMCP(server)} - /> -
- )) + {activedMcpServers.length > 0 ? ( + activedMcpServers.map((server) => ( + +
+
{server.name}
+ {server.description && ( + +
{truncateText(server.description)}
+
+ )} + {server.baseUrl &&
{server.baseUrl}
} +
+ s.id === server.id)} + onChange={() => toggelEnableMCP(server)} + /> +
+ )) ) : (
{t('settings.mcp.noServers')}
diff --git a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx index bac98f98..741fe220 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx @@ -1,12 +1,13 @@ import { useMCPServers } from '@renderer/hooks/useMCPServers' -import { MCPServer } from '@renderer/types' +import { MCPServer, MCPTool } from '@renderer/types' import { Button, Flex, Form, Input, Radio, Switch } from 'antd' import TextArea from 'antd/es/input/TextArea' -import React, { useEffect, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..' +import MCPToolsSection from './McpTool' interface Props { server: MCPServer @@ -25,12 +26,14 @@ interface MCPFormValues { const McpSettings: React.FC = ({ server }) => { const { t } = useTranslation() + const { deleteMCPServer } = useMCPServers() const [serverType, setServerType] = useState<'sse' | 'stdio'>('stdio') const [form] = Form.useForm() const [loading, setLoading] = useState(false) const [isFormChanged, setIsFormChanged] = useState(false) const [loadingServer, setLoadingServer] = useState(null) const { updateMCPServer } = useMCPServers() + const [tools, setTools] = useState([]) useEffect(() => { if (server) { @@ -76,6 +79,30 @@ const McpSettings: React.FC = ({ server }) => { type && setServerType(type) }, [form]) + // Load tools on initial mount if server is active + useEffect(() => { + const fetchTools = async () => { + if (server.isActive) { + try { + setLoadingServer(server.id) + const localTools = await window.api.mcp.listTools(server) + setTools(localTools) + // window.message.success(t('settings.mcp.toolsLoaded')) + } catch (error) { + window.message.error({ + content: t('settings.mcp.toolsLoadError') + formatError(error), + key: 'mcp-tools-error' + }) + } finally { + setLoadingServer(null) + } + } + } + + fetchTools() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + // Save the form data const onSave = async () => { setLoading(true) try { @@ -110,7 +137,7 @@ const McpSettings: React.FC = ({ server }) => { } try { - await window.api.mcp.listTools(mcpServer) + await window.api.mcp.restartServer(mcpServer) updateMCPServer({ ...mcpServer, isActive: true }) window.message.success({ content: t('settings.mcp.updateSuccess'), key: 'mcp-update-success' }) setLoading(false) @@ -129,6 +156,23 @@ const McpSettings: React.FC = ({ server }) => { } } + const onDeleteMcpServer = useCallback( + async (server: MCPServer) => { + try { + await window.api.mcp.removeServer(server) + deleteMCPServer(server.id) + window.message.success({ content: t('settings.mcp.deleteSuccess'), key: 'mcp-list' }) + } catch (error: any) { + window.message.error({ + content: `${t('settings.mcp.deleteError')}: ${error.message}`, + key: 'mcp-list' + }) + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [server, t] + ) + const onFormValuesChange = () => { setIsFormChanged(true) } @@ -144,10 +188,14 @@ const McpSettings: React.FC = ({ server }) => { const onToggleActive = async (active: boolean) => { await form.validateFields() setLoadingServer(server.id) + const oldActiveState = server.isActive try { if (active) { - await window.api.mcp.listTools(server) + const localTools = await window.api.mcp.listTools(server) + setTools(localTools) + } else { + await window.api.mcp.stopServer(server) } updateMCPServer({ ...server, isActive: active }) } catch (error: any) { @@ -156,7 +204,7 @@ const McpSettings: React.FC = ({ server }) => { content: formatError(error), centered: true }) - console.error('[MCP] Error toggling server active', error) + updateMCPServer({ ...server, isActive: oldActiveState }) } finally { setLoadingServer(null) } @@ -177,6 +225,9 @@ const McpSettings: React.FC = ({ server }) => { + @@ -185,7 +236,7 @@ const McpSettings: React.FC = ({ server }) => { layout="vertical" onValuesChange={onFormValuesChange} style={{ - height: 'calc(100vh - var(--navbar-height) - 115px)', + // height: 'calc(100vh - var(--navbar-height) - 315px)', overflowY: 'auto', width: 'calc(100% + 10px)', paddingRight: '10px' @@ -237,6 +288,8 @@ const McpSettings: React.FC = ({ server }) => { )} + + {server.isActive && } ) diff --git a/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx b/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx new file mode 100644 index 00000000..b5c80e89 --- /dev/null +++ b/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx @@ -0,0 +1,132 @@ +import { MCPTool } from '@renderer/types' +import { Badge, Collapse, Descriptions, Empty, Flex, Tag, Tooltip, Typography } from 'antd' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const MCPToolsSection = ({ tools }: { tools: MCPTool[] }) => { + const { t } = useTranslation() + + // Render tool properties from the input schema + const renderToolProperties = (tool: MCPTool) => { + if (!tool.inputSchema?.properties) return null + + const getTypeColor = (type: string) => { + switch (type) { + case 'string': + return 'blue' + case 'number': + return 'green' + case 'boolean': + return 'purple' + case 'object': + return 'orange' + case 'array': + return 'cyan' + default: + return 'default' + } + } + + return ( +
+ {t('settings.mcp.tools.inputSchema')}: + + {Object.entries(tool.inputSchema.properties).map(([key, prop]: [string, any]) => ( + + {key} + {tool.inputSchema.required?.includes(key) && ( + + Required + + )} + + }> + + + {prop.type && ( + // {prop.type} + {prop.type}} + /> + )} + + {prop.description && ( + + {prop.description} + + )} + {prop.enum && ( +
+ Allowed values: +
+ {prop.enum.map((value: string, idx: number) => ( + {value} + ))} +
+
+ )} +
+
+ ))} +
+
+ ) + } + + return ( +
+ {t('settings.mcp.tools.availableTools')} + {tools.length > 0 ? ( + + {tools.map((tool) => ( + + + {tool.name} + + {tool.id} + + + {tool.description && ( + + {tool.description} + + )} + + }> + {renderToolProperties(tool)} + + ))} + + ) : ( + + )} +
+ ) +} + +const Section = styled.div` + margin-top: 8px; + border-top: 1px solid var(--color-border); + padding-top: 8px; +` + +const SectionTitle = styled.h3` + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + color: var(--color-text-secondary); +` + +const SelectableContent = styled.div` + user-select: text; + padding: 0 12px; +` + +export default MCPToolsSection diff --git a/src/renderer/src/utils/mcp-tools.ts b/src/renderer/src/utils/mcp-tools.ts index c54de4a6..6d099a35 100644 --- a/src/renderer/src/utils/mcp-tools.ts +++ b/src/renderer/src/utils/mcp-tools.ts @@ -61,7 +61,7 @@ export function mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array mcptool.serverId === llmTool.function.name) + const tool = mcpTools.find((mcptool) => mcptool.id === llmTool.function.name) if (!tool) { + console.warn('No MCP Tool found for tool call:', llmTool) return undefined }