feat: mcp tools (#4069)

* feat(McpSettings): add MCP tools section and fetch tools on server activation

* refactor(McpService): improve client management and connection handling

* feat(McpService): add server management functions for restart and stop

* feat(McpTool): add tools section with input schema and availability messages

* feat(McpService): add unique IDs to tools and update function name mapping

* feat(McpService): implement caching for tool listings and enhance tool structure

* feat(McpToolsButton): streamline active server handling and update dropdown rendering

* fix(mcp-tools): update tool lookup to use unique IDs and add warning for missing tools
This commit is contained in:
LiuVaayne 2025-03-29 07:16:59 +08:00 committed by GitHub
parent d3584d2d39
commit 3f40cc28ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 328 additions and 76 deletions

View File

@ -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)

View File

@ -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<string, Client> = 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<Client> {
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
// 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
}
// If there's an existing client for a different server, close it
if (this.client) {
await this.closeClient()
}
// 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 client.close()
Logger.info(`[MCP] Closed server: ${serverKey}`)
this.clients.delete(serverKey)
} else {
Logger.warn(`[MCP] No client found for server: ${serverKey}`)
}
}
await this.client.close()
this.client = null
}
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) => ({
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<MCPTool[]>(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<any> {
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)

View File

@ -147,6 +147,8 @@ declare global {
}
mcp: {
removeServer: (server: MCPServer) => Promise<void>
restartServer: (server: MCPServer) => Promise<void>
stopServer: (server: MCPServer) => Promise<void>
listTools: (server: MCPServer) => Promise<MCPTool[]>
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any>
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>

View File

@ -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 }),

View File

@ -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",

View File

@ -1012,7 +1012,12 @@
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください"
},
"editMcpJson": "MCP 設定を編集",
"installHelp": "インストールヘルプを取得"
"installHelp": "インストールヘルプを取得",
"tools": {
"inputSchema": "入力スキーマ",
"availableTools": "利用可能なツール",
"noToolsAvailable": "利用可能なツールはありません"
}
},
"messages.divider": "メッセージ間に区切り線を表示",
"messages.grid_columns": "メッセージグリッドの表示列数",

View File

@ -1011,8 +1011,13 @@
"updateError": "Ошибка обновления сервера",
"updateSuccess": "Сервер успешно обновлен",
"url": "URL",
"editMcpJson": "Редактировать MCP 配置",
"installHelp": "Получить помощь по установке"
"editMcpJson": "Редактировать MCP",
"installHelp": "Получить помощь по установке",
"tools": {
"inputSchema": "входные параметры",
"availableTools": "доступные инструменты",
"noToolsAvailable": "нет доступных инструментов"
}
},
"messages.divider": "Показывать разделитель между сообщениями",
"messages.grid_columns": "Количество столбцов сетки сообщений",

View File

@ -1013,7 +1013,12 @@
"updateSuccess": "服务器更新成功",
"url": "URL",
"editMcpJson": "编辑 MCP 配置",
"installHelp": "获取安装帮助"
"installHelp": "获取安装帮助",
"tools": {
"inputSchema": "输入参数",
"availableTools": "可用工具",
"noToolsAvailable": "没有可用工具"
}
},
"messages.divider": "消息分割线",
"messages.grid_columns": "消息网格展示列数",

View File

@ -1012,7 +1012,12 @@
"updateSuccess": "伺服器更新成功",
"url": "URL",
"editMcpJson": "編輯 MCP 配置",
"installHelp": "獲取安裝幫助"
"installHelp": "獲取安裝幫助",
"tools": {
"inputSchema": "輸入參數",
"availableTools": "可用工具",
"noToolsAvailable": "沒有可用工具"
}
},
"messages.divider": "訊息間顯示分隔線",
"messages.grid_columns": "訊息網格展示列數",

View File

@ -13,7 +13,7 @@ interface Props {
}
const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton }) => {
const { mcpServers, activedMcpServers } = useMCPServers()
const { activedMcpServers } = useMCPServers()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<any>(null)
const menuRef = useRef<HTMLDivElement>(null)
@ -25,14 +25,15 @@ const MCPToolsButton: FC<Props> = ({ 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,10 +61,8 @@ const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton
</div>
</DropdownHeader>
<DropdownBody>
{mcpServers.length > 0 ? (
mcpServers
.filter((s) => s.isActive)
.map((server) => (
{activedMcpServers.length > 0 ? (
activedMcpServers.map((server) => (
<McpServerItems key={server.id} className="ant-dropdown-menu-item">
<div className="server-info">
<div className="server-name">{server.name}</div>

View File

@ -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<Props> = ({ server }) => {
const { t } = useTranslation()
const { deleteMCPServer } = useMCPServers()
const [serverType, setServerType] = useState<'sse' | 'stdio'>('stdio')
const [form] = Form.useForm<MCPFormValues>()
const [loading, setLoading] = useState(false)
const [isFormChanged, setIsFormChanged] = useState(false)
const [loadingServer, setLoadingServer] = useState<string | null>(null)
const { updateMCPServer } = useMCPServers()
const [tools, setTools] = useState<MCPTool[]>([])
useEffect(() => {
if (server) {
@ -76,6 +79,30 @@ const McpSettings: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ server }) => {
<Button type="primary" size="small" onClick={onSave} loading={loading} disabled={!isFormChanged}>
{t('common.save')}
</Button>
<Button danger type="primary" size="small" onClick={() => onDeleteMcpServer(server)} loading={loading}>
{t('common.delete')}
</Button>
</Flex>
</SettingTitle>
<SettingDivider />
@ -185,7 +236,7 @@ const McpSettings: React.FC<Props> = ({ 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<Props> = ({ server }) => {
</>
)}
</Form>
{server.isActive && <MCPToolsSection tools={tools} />}
</SettingGroup>
</SettingContainer>
)

View File

@ -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 (
<div style={{ marginTop: 12 }}>
<Typography.Title level={5}>{t('settings.mcp.tools.inputSchema')}:</Typography.Title>
<Descriptions bordered size="small" column={1} style={{ marginTop: 8 }}>
{Object.entries(tool.inputSchema.properties).map(([key, prop]: [string, any]) => (
<Descriptions.Item
key={key}
label={
<Flex align="center" gap={8}>
<Typography.Text strong>{key}</Typography.Text>
{tool.inputSchema.required?.includes(key) && (
<Tooltip title="Required field">
<Tag color="red">Required</Tag>
</Tooltip>
)}
</Flex>
}>
<Flex vertical gap={4}>
<Flex align="center" gap={8}>
{prop.type && (
// <Typography.Text type="secondary">{prop.type} </Typography.Text>
<Badge
color={getTypeColor(prop.type)}
text={<Typography.Text type="secondary">{prop.type}</Typography.Text>}
/>
)}
</Flex>
{prop.description && (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
{prop.description}
</Typography.Paragraph>
)}
{prop.enum && (
<div style={{ marginTop: 4 }}>
<Typography.Text type="secondary">Allowed values: </Typography.Text>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
{prop.enum.map((value: string, idx: number) => (
<Tag key={idx}>{value}</Tag>
))}
</div>
</div>
)}
</Flex>
</Descriptions.Item>
))}
</Descriptions>
</div>
)
}
return (
<Section>
<SectionTitle>{t('settings.mcp.tools.availableTools')}</SectionTitle>
{tools.length > 0 ? (
<Collapse bordered={false} ghost>
{tools.map((tool) => (
<Collapse.Panel
key={tool.id}
header={
<Flex vertical align="flex-start" style={{ width: '100%' }}>
<Flex align="center" style={{ width: '100%' }}>
<Typography.Text strong>{tool.name}</Typography.Text>
<Typography.Text type="secondary" style={{ marginLeft: 8, fontSize: '12px' }}>
{tool.id}
</Typography.Text>
</Flex>
{tool.description && (
<Typography.Text type="secondary" style={{ fontSize: '13px', marginTop: 4 }}>
{tool.description}
</Typography.Text>
)}
</Flex>
}>
<SelectableContent>{renderToolProperties(tool)}</SelectableContent>
</Collapse.Panel>
))}
</Collapse>
) : (
<Empty description={t('settings.mcp.tools.noToolsAvailable')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Section>
)
}
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

View File

@ -61,7 +61,7 @@ export function mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array<ChatCompletion
type: 'function',
name: tool.name,
function: {
name: tool.serverId,
name: tool.id,
description: tool.description,
parameters: {
type: 'object',
@ -79,9 +79,10 @@ export function openAIToolsToMcpTool(
return undefined
}
const tool = mcpTools.find((mcptool) => 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
}