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:
parent
d3584d2d39
commit
3f40cc28ac
@ -264,6 +264,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
|
|
||||||
// Register MCP handlers
|
// Register MCP handlers
|
||||||
ipcMain.handle('mcp:remove-server', mcpService.removeServer)
|
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:list-tools', mcpService.listTools)
|
||||||
ipcMain.handle('mcp:call-tool', mcpService.callTool)
|
ipcMain.handle('mcp:call-tool', mcpService.callTool)
|
||||||
ipcMain.handle('mcp:get-install-info', mcpService.getInstallInfo)
|
ipcMain.handle('mcp:get-install-info', mcpService.getInstallInfo)
|
||||||
|
|||||||
@ -5,12 +5,14 @@ import { getBinaryName, getBinaryPath } from '@main/utils/process'
|
|||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.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 { app } from 'electron'
|
||||||
import Logger from 'electron-log'
|
import Logger from 'electron-log'
|
||||||
|
|
||||||
|
import { CacheService } from './CacheService'
|
||||||
|
|
||||||
class McpService {
|
class McpService {
|
||||||
private client: Client | null = null
|
|
||||||
private clients: Map<string, Client> = new Map()
|
private clients: Map<string, Client> = new Map()
|
||||||
|
|
||||||
private getServerKey(server: MCPServer): string {
|
private getServerKey(server: MCPServer): string {
|
||||||
@ -29,25 +31,30 @@ class McpService {
|
|||||||
this.callTool = this.callTool.bind(this)
|
this.callTool = this.callTool.bind(this)
|
||||||
this.closeClient = this.closeClient.bind(this)
|
this.closeClient = this.closeClient.bind(this)
|
||||||
this.removeServer = this.removeServer.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)
|
const serverKey = this.getServerKey(server)
|
||||||
|
|
||||||
// Check if we already have a client for this server configuration
|
// Check if we already have a client for this server configuration
|
||||||
const existingClient = this.clients.get(serverKey)
|
const existingClient = this.clients.get(serverKey)
|
||||||
if (existingClient) {
|
if (existingClient) {
|
||||||
this.client = existingClient
|
// Check if the existing client is still connected
|
||||||
return
|
const pingResult = await existingClient.ping()
|
||||||
}
|
Logger.info(`[MCP] Ping result for ${server.name}:`, pingResult)
|
||||||
|
// If the ping fails, remove the client from the cache
|
||||||
// If there's an existing client for a different server, close it
|
// and create a new one
|
||||||
if (this.client) {
|
if (!pingResult) {
|
||||||
await this.closeClient()
|
this.clients.delete(serverKey)
|
||||||
|
} else {
|
||||||
|
return existingClient
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new client instance for each connection
|
// 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 || [])]
|
const args = [...(server.args || [])]
|
||||||
|
|
||||||
@ -95,46 +102,76 @@ class McpService {
|
|||||||
throw new Error('Either baseUrl or command must be provided')
|
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
|
// 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}`)
|
Logger.info(`[MCP] Activated server: ${server.name}`)
|
||||||
|
return client
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
|
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeClient() {
|
async closeClient(serverKey: string) {
|
||||||
if (this.client) {
|
const client = this.clients.get(serverKey)
|
||||||
|
if (client) {
|
||||||
// Remove the client from the cache
|
// Remove the client from the cache
|
||||||
for (const [key, client] of this.clients.entries()) {
|
await client.close()
|
||||||
if (client === this.client) {
|
Logger.info(`[MCP] Closed server: ${serverKey}`)
|
||||||
this.clients.delete(key)
|
this.clients.delete(serverKey)
|
||||||
break
|
} 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) {
|
async removeServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||||
await this.closeClient()
|
const serverKey = this.getServerKey(server)
|
||||||
this.clients.delete(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) {
|
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||||
await this.initClient(server)
|
const client = await this.initClient(server)
|
||||||
const { tools } = await this.client!.listTools()
|
const cacheKey = `mcp:list_tool:${server.id}`
|
||||||
return tools.map((tool) => ({
|
if (CacheService.has(cacheKey)) {
|
||||||
...tool,
|
Logger.info(`[MCP] Tools from ${server.name} loaded from cache`)
|
||||||
serverId: server.id,
|
const cachedTools = CacheService.get<MCPTool[]>(cacheKey)
|
||||||
serverName: server.name
|
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,
|
_: Electron.IpcMainInvokeEvent,
|
||||||
{ server, name, args }: { server: MCPServer; name: string; args: any }
|
{ server, name, args }: { server: MCPServer; name: string; args: any }
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
await this.initClient(server)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Logger.info('[MCP] Calling:', server.name, name, args)
|
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
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error)
|
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error)
|
||||||
|
|||||||
2
src/preload/index.d.ts
vendored
2
src/preload/index.d.ts
vendored
@ -147,6 +147,8 @@ declare global {
|
|||||||
}
|
}
|
||||||
mcp: {
|
mcp: {
|
||||||
removeServer: (server: MCPServer) => Promise<void>
|
removeServer: (server: MCPServer) => Promise<void>
|
||||||
|
restartServer: (server: MCPServer) => Promise<void>
|
||||||
|
stopServer: (server: MCPServer) => Promise<void>
|
||||||
listTools: (server: MCPServer) => Promise<MCPTool[]>
|
listTools: (server: MCPServer) => Promise<MCPTool[]>
|
||||||
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any>
|
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any>
|
||||||
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
|
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
|
||||||
|
|||||||
@ -121,6 +121,8 @@ const api = {
|
|||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
removeServer: (server: MCPServer) => ipcRenderer.invoke('mcp:remove-server', server),
|
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),
|
listTools: (server: MCPServer) => ipcRenderer.invoke('mcp:list-tools', server),
|
||||||
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
|
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
|
||||||
ipcRenderer.invoke('mcp:call-tool', { server, name, args }),
|
ipcRenderer.invoke('mcp:call-tool', { server, name, args }),
|
||||||
|
|||||||
@ -1013,7 +1013,12 @@
|
|||||||
"updateSuccess": "Server updated successfully",
|
"updateSuccess": "Server updated successfully",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"editMcpJson": "Edit MCP Configuration",
|
"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.divider": "Show divider between messages",
|
||||||
"messages.grid_columns": "Message grid display columns",
|
"messages.grid_columns": "Message grid display columns",
|
||||||
|
|||||||
@ -1012,7 +1012,12 @@
|
|||||||
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください"
|
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください"
|
||||||
},
|
},
|
||||||
"editMcpJson": "MCP 設定を編集",
|
"editMcpJson": "MCP 設定を編集",
|
||||||
"installHelp": "インストールヘルプを取得"
|
"installHelp": "インストールヘルプを取得",
|
||||||
|
"tools": {
|
||||||
|
"inputSchema": "入力スキーマ",
|
||||||
|
"availableTools": "利用可能なツール",
|
||||||
|
"noToolsAvailable": "利用可能なツールはありません"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"messages.divider": "メッセージ間に区切り線を表示",
|
"messages.divider": "メッセージ間に区切り線を表示",
|
||||||
"messages.grid_columns": "メッセージグリッドの表示列数",
|
"messages.grid_columns": "メッセージグリッドの表示列数",
|
||||||
|
|||||||
@ -1011,8 +1011,13 @@
|
|||||||
"updateError": "Ошибка обновления сервера",
|
"updateError": "Ошибка обновления сервера",
|
||||||
"updateSuccess": "Сервер успешно обновлен",
|
"updateSuccess": "Сервер успешно обновлен",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"editMcpJson": "Редактировать MCP 配置",
|
"editMcpJson": "Редактировать MCP",
|
||||||
"installHelp": "Получить помощь по установке"
|
"installHelp": "Получить помощь по установке",
|
||||||
|
"tools": {
|
||||||
|
"inputSchema": "входные параметры",
|
||||||
|
"availableTools": "доступные инструменты",
|
||||||
|
"noToolsAvailable": "нет доступных инструментов"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"messages.divider": "Показывать разделитель между сообщениями",
|
"messages.divider": "Показывать разделитель между сообщениями",
|
||||||
"messages.grid_columns": "Количество столбцов сетки сообщений",
|
"messages.grid_columns": "Количество столбцов сетки сообщений",
|
||||||
|
|||||||
@ -1013,7 +1013,12 @@
|
|||||||
"updateSuccess": "服务器更新成功",
|
"updateSuccess": "服务器更新成功",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"editMcpJson": "编辑 MCP 配置",
|
"editMcpJson": "编辑 MCP 配置",
|
||||||
"installHelp": "获取安装帮助"
|
"installHelp": "获取安装帮助",
|
||||||
|
"tools": {
|
||||||
|
"inputSchema": "输入参数",
|
||||||
|
"availableTools": "可用工具",
|
||||||
|
"noToolsAvailable": "没有可用工具"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"messages.divider": "消息分割线",
|
"messages.divider": "消息分割线",
|
||||||
"messages.grid_columns": "消息网格展示列数",
|
"messages.grid_columns": "消息网格展示列数",
|
||||||
|
|||||||
@ -1012,7 +1012,12 @@
|
|||||||
"updateSuccess": "伺服器更新成功",
|
"updateSuccess": "伺服器更新成功",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"editMcpJson": "編輯 MCP 配置",
|
"editMcpJson": "編輯 MCP 配置",
|
||||||
"installHelp": "獲取安裝幫助"
|
"installHelp": "獲取安裝幫助",
|
||||||
|
"tools": {
|
||||||
|
"inputSchema": "輸入參數",
|
||||||
|
"availableTools": "可用工具",
|
||||||
|
"noToolsAvailable": "沒有可用工具"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"messages.divider": "訊息間顯示分隔線",
|
"messages.divider": "訊息間顯示分隔線",
|
||||||
"messages.grid_columns": "訊息網格展示列數",
|
"messages.grid_columns": "訊息網格展示列數",
|
||||||
|
|||||||
@ -13,7 +13,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton }) => {
|
const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton }) => {
|
||||||
const { mcpServers, activedMcpServers } = useMCPServers()
|
const { activedMcpServers } = useMCPServers()
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const dropdownRef = useRef<any>(null)
|
const dropdownRef = useRef<any>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
@ -25,14 +25,15 @@ const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if all active servers are enabled
|
// 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 = () =>
|
const disableAll = () =>
|
||||||
mcpServers.forEach((s) => {
|
activedMcpServers.forEach((s) => {
|
||||||
enabledMCPs.forEach((enabledServer) => {
|
enabledMCPs.forEach((enabledServer) => {
|
||||||
if (enabledServer.id === s.id) {
|
if (enabledServer.id === s.id) {
|
||||||
toggelEnableMCP(s)
|
toggelEnableMCP(s)
|
||||||
@ -60,27 +61,25 @@ const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton
|
|||||||
</div>
|
</div>
|
||||||
</DropdownHeader>
|
</DropdownHeader>
|
||||||
<DropdownBody>
|
<DropdownBody>
|
||||||
{mcpServers.length > 0 ? (
|
{activedMcpServers.length > 0 ? (
|
||||||
mcpServers
|
activedMcpServers.map((server) => (
|
||||||
.filter((s) => s.isActive)
|
<McpServerItems key={server.id} className="ant-dropdown-menu-item">
|
||||||
.map((server) => (
|
<div className="server-info">
|
||||||
<McpServerItems key={server.id} className="ant-dropdown-menu-item">
|
<div className="server-name">{server.name}</div>
|
||||||
<div className="server-info">
|
{server.description && (
|
||||||
<div className="server-name">{server.name}</div>
|
<Tooltip title={server.description} placement="bottom">
|
||||||
{server.description && (
|
<div className="server-description">{truncateText(server.description)}</div>
|
||||||
<Tooltip title={server.description} placement="bottom">
|
</Tooltip>
|
||||||
<div className="server-description">{truncateText(server.description)}</div>
|
)}
|
||||||
</Tooltip>
|
{server.baseUrl && <div className="server-url">{server.baseUrl}</div>}
|
||||||
)}
|
</div>
|
||||||
{server.baseUrl && <div className="server-url">{server.baseUrl}</div>}
|
<Switch
|
||||||
</div>
|
size="small"
|
||||||
<Switch
|
checked={enabledMCPs.some((s) => s.id === server.id)}
|
||||||
size="small"
|
onChange={() => toggelEnableMCP(server)}
|
||||||
checked={enabledMCPs.some((s) => s.id === server.id)}
|
/>
|
||||||
onChange={() => toggelEnableMCP(server)}
|
</McpServerItems>
|
||||||
/>
|
))
|
||||||
</McpServerItems>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<div className="ant-dropdown-menu-item-group">
|
<div className="ant-dropdown-menu-item-group">
|
||||||
<div className="ant-dropdown-menu-item no-results">{t('settings.mcp.noServers')}</div>
|
<div className="ant-dropdown-menu-item no-results">{t('settings.mcp.noServers')}</div>
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
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 { Button, Flex, Form, Input, Radio, Switch } from 'antd'
|
||||||
import TextArea from 'antd/es/input/TextArea'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..'
|
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..'
|
||||||
|
import MCPToolsSection from './McpTool'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
server: MCPServer
|
server: MCPServer
|
||||||
@ -25,12 +26,14 @@ interface MCPFormValues {
|
|||||||
|
|
||||||
const McpSettings: React.FC<Props> = ({ server }) => {
|
const McpSettings: React.FC<Props> = ({ server }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { deleteMCPServer } = useMCPServers()
|
||||||
const [serverType, setServerType] = useState<'sse' | 'stdio'>('stdio')
|
const [serverType, setServerType] = useState<'sse' | 'stdio'>('stdio')
|
||||||
const [form] = Form.useForm<MCPFormValues>()
|
const [form] = Form.useForm<MCPFormValues>()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [isFormChanged, setIsFormChanged] = useState(false)
|
const [isFormChanged, setIsFormChanged] = useState(false)
|
||||||
const [loadingServer, setLoadingServer] = useState<string | null>(null)
|
const [loadingServer, setLoadingServer] = useState<string | null>(null)
|
||||||
const { updateMCPServer } = useMCPServers()
|
const { updateMCPServer } = useMCPServers()
|
||||||
|
const [tools, setTools] = useState<MCPTool[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (server) {
|
if (server) {
|
||||||
@ -76,6 +79,30 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
type && setServerType(type)
|
type && setServerType(type)
|
||||||
}, [form])
|
}, [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 () => {
|
const onSave = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@ -110,7 +137,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.api.mcp.listTools(mcpServer)
|
await window.api.mcp.restartServer(mcpServer)
|
||||||
updateMCPServer({ ...mcpServer, isActive: true })
|
updateMCPServer({ ...mcpServer, isActive: true })
|
||||||
window.message.success({ content: t('settings.mcp.updateSuccess'), key: 'mcp-update-success' })
|
window.message.success({ content: t('settings.mcp.updateSuccess'), key: 'mcp-update-success' })
|
||||||
setLoading(false)
|
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 = () => {
|
const onFormValuesChange = () => {
|
||||||
setIsFormChanged(true)
|
setIsFormChanged(true)
|
||||||
}
|
}
|
||||||
@ -144,10 +188,14 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
const onToggleActive = async (active: boolean) => {
|
const onToggleActive = async (active: boolean) => {
|
||||||
await form.validateFields()
|
await form.validateFields()
|
||||||
setLoadingServer(server.id)
|
setLoadingServer(server.id)
|
||||||
|
const oldActiveState = server.isActive
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (active) {
|
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 })
|
updateMCPServer({ ...server, isActive: active })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -156,7 +204,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
content: formatError(error),
|
content: formatError(error),
|
||||||
centered: true
|
centered: true
|
||||||
})
|
})
|
||||||
console.error('[MCP] Error toggling server active', error)
|
updateMCPServer({ ...server, isActive: oldActiveState })
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingServer(null)
|
setLoadingServer(null)
|
||||||
}
|
}
|
||||||
@ -177,6 +225,9 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
<Button type="primary" size="small" onClick={onSave} loading={loading} disabled={!isFormChanged}>
|
<Button type="primary" size="small" onClick={onSave} loading={loading} disabled={!isFormChanged}>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button danger type="primary" size="small" onClick={() => onDeleteMcpServer(server)} loading={loading}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</SettingTitle>
|
</SettingTitle>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
@ -185,7 +236,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
onValuesChange={onFormValuesChange}
|
onValuesChange={onFormValuesChange}
|
||||||
style={{
|
style={{
|
||||||
height: 'calc(100vh - var(--navbar-height) - 115px)',
|
// height: 'calc(100vh - var(--navbar-height) - 315px)',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
width: 'calc(100% + 10px)',
|
width: 'calc(100% + 10px)',
|
||||||
paddingRight: '10px'
|
paddingRight: '10px'
|
||||||
@ -237,6 +288,8 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
{server.isActive && <MCPToolsSection tools={tools} />}
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
)
|
)
|
||||||
|
|||||||
132
src/renderer/src/pages/settings/MCPSettings/McpTool.tsx
Normal file
132
src/renderer/src/pages/settings/MCPSettings/McpTool.tsx
Normal 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
|
||||||
@ -61,7 +61,7 @@ export function mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array<ChatCompletion
|
|||||||
type: 'function',
|
type: 'function',
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
function: {
|
function: {
|
||||||
name: tool.serverId,
|
name: tool.id,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@ -79,9 +79,10 @@ export function openAIToolsToMcpTool(
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const tool = mcpTools.find((mcptool) => mcptool.serverId === llmTool.function.name)
|
const tool = mcpTools.find((mcptool) => mcptool.id === llmTool.function.name)
|
||||||
|
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
|
console.warn('No MCP Tool found for tool call:', llmTool)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user