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:
@@ -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",
|
||||
|
||||
@@ -1012,7 +1012,12 @@
|
||||
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください"
|
||||
},
|
||||
"editMcpJson": "MCP 設定を編集",
|
||||
"installHelp": "インストールヘルプを取得"
|
||||
"installHelp": "インストールヘルプを取得",
|
||||
"tools": {
|
||||
"inputSchema": "入力スキーマ",
|
||||
"availableTools": "利用可能なツール",
|
||||
"noToolsAvailable": "利用可能なツールはありません"
|
||||
}
|
||||
},
|
||||
"messages.divider": "メッセージ間に区切り線を表示",
|
||||
"messages.grid_columns": "メッセージグリッドの表示列数",
|
||||
|
||||
@@ -1011,8 +1011,13 @@
|
||||
"updateError": "Ошибка обновления сервера",
|
||||
"updateSuccess": "Сервер успешно обновлен",
|
||||
"url": "URL",
|
||||
"editMcpJson": "Редактировать MCP 配置",
|
||||
"installHelp": "Получить помощь по установке"
|
||||
"editMcpJson": "Редактировать MCP",
|
||||
"installHelp": "Получить помощь по установке",
|
||||
"tools": {
|
||||
"inputSchema": "входные параметры",
|
||||
"availableTools": "доступные инструменты",
|
||||
"noToolsAvailable": "нет доступных инструментов"
|
||||
}
|
||||
},
|
||||
"messages.divider": "Показывать разделитель между сообщениями",
|
||||
"messages.grid_columns": "Количество столбцов сетки сообщений",
|
||||
|
||||
@@ -1013,7 +1013,12 @@
|
||||
"updateSuccess": "服务器更新成功",
|
||||
"url": "URL",
|
||||
"editMcpJson": "编辑 MCP 配置",
|
||||
"installHelp": "获取安装帮助"
|
||||
"installHelp": "获取安装帮助",
|
||||
"tools": {
|
||||
"inputSchema": "输入参数",
|
||||
"availableTools": "可用工具",
|
||||
"noToolsAvailable": "没有可用工具"
|
||||
}
|
||||
},
|
||||
"messages.divider": "消息分割线",
|
||||
"messages.grid_columns": "消息网格展示列数",
|
||||
|
||||
@@ -1012,7 +1012,12 @@
|
||||
"updateSuccess": "伺服器更新成功",
|
||||
"url": "URL",
|
||||
"editMcpJson": "編輯 MCP 配置",
|
||||
"installHelp": "獲取安裝幫助"
|
||||
"installHelp": "獲取安裝幫助",
|
||||
"tools": {
|
||||
"inputSchema": "輸入參數",
|
||||
"availableTools": "可用工具",
|
||||
"noToolsAvailable": "沒有可用工具"
|
||||
}
|
||||
},
|
||||
"messages.divider": "訊息間顯示分隔線",
|
||||
"messages.grid_columns": "訊息網格展示列數",
|
||||
|
||||
@@ -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,27 +61,25 @@ const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton
|
||||
</div>
|
||||
</DropdownHeader>
|
||||
<DropdownBody>
|
||||
{mcpServers.length > 0 ? (
|
||||
mcpServers
|
||||
.filter((s) => s.isActive)
|
||||
.map((server) => (
|
||||
<McpServerItems key={server.id} className="ant-dropdown-menu-item">
|
||||
<div className="server-info">
|
||||
<div className="server-name">{server.name}</div>
|
||||
{server.description && (
|
||||
<Tooltip title={server.description} placement="bottom">
|
||||
<div className="server-description">{truncateText(server.description)}</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{server.baseUrl && <div className="server-url">{server.baseUrl}</div>}
|
||||
</div>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={enabledMCPs.some((s) => s.id === server.id)}
|
||||
onChange={() => toggelEnableMCP(server)}
|
||||
/>
|
||||
</McpServerItems>
|
||||
))
|
||||
{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>
|
||||
{server.description && (
|
||||
<Tooltip title={server.description} placement="bottom">
|
||||
<div className="server-description">{truncateText(server.description)}</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{server.baseUrl && <div className="server-url">{server.baseUrl}</div>}
|
||||
</div>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={enabledMCPs.some((s) => s.id === server.id)}
|
||||
onChange={() => toggelEnableMCP(server)}
|
||||
/>
|
||||
</McpServerItems>
|
||||
))
|
||||
) : (
|
||||
<div className="ant-dropdown-menu-item-group">
|
||||
<div className="ant-dropdown-menu-item no-results">{t('settings.mcp.noServers')}</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user