From f30f06f40f8d1ddbad0b81fbd3cf50b91b73a0b1 Mon Sep 17 00:00:00 2001 From: xu-ya Date: Tue, 18 Nov 2025 14:30:44 +0800 Subject: [PATCH 1/5] feat: add mcp Execute Tool --- src/renderer/src/i18n/locales/en-us.json | 31 +++++++- src/renderer/src/i18n/locales/zh-cn.json | 31 +++++++- .../pages/settings/MCPSettings/McpTool.tsx | 79 +++++++++++++++---- 3 files changed, 123 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index c1c0dc2620..b3d04b7abf 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3985,7 +3985,36 @@ }, "loadError": "Get tools Error", "noToolsAvailable": "No tools available", - "run": "Run" + "run": "Run", + "execute": { + "label": "Execute", + "button": "Execute", + "tooltip": "Execute tool with custom parameters", + "title": "Execute Tool: {{name}}", + "params": "Parameters (JSON)", + "paramsPlaceholder": "Enter JSON parameters...", + "execute": "Execute", + "copied": "Copied to clipboard", + "copyFailed": "Failed to copy", + "copy": "Copy", + "result": { + "success": "Result", + "error": "Error Result", + "text": "Text" + }, + "view": { + "json": "JSON", + "formatted": "Formatted" + }, + "table": { + "name": "Name", + "value": "Value" + }, + "error": { + "noToolOrServer": "Tool or server not found", + "invalidJson": "Invalid JSON format: {{error}}" + } + } }, "type": "Type", "types": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 2a1ee09688..57873468a3 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3985,7 +3985,36 @@ }, "loadError": "获取工具失败", "noToolsAvailable": "无可用工具", - "run": "运行" + "run": "运行", + "execute": { + "label": "执行", + "button": "执行", + "tooltip": "使用自定义参数执行工具", + "title": "执行工具: {{name}}", + "params": "参数 (JSON)", + "paramsPlaceholder": "输入 JSON 参数...", + "execute": "执行", + "copied": "已复制到剪贴板", + "copyFailed": "复制失败", + "copy": "复制", + "result": { + "success": "结果", + "error": "错误结果", + "text": "文本" + }, + "view": { + "json": "JSON", + "formatted": "美化" + }, + "table": { + "name": "名称", + "value": "值" + }, + "error": { + "noToolOrServer": "未找到工具或服务器", + "invalidJson": "无效的 JSON 格式: {{error}}" + } + } }, "type": "类型", "types": { diff --git a/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx b/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx index bfd9992dc4..aeb00e8bc9 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx @@ -1,9 +1,11 @@ import type { MCPServer, MCPTool } from '@renderer/types' import { isToolAutoApproved } from '@renderer/utils/mcp-tools' -import { Badge, Descriptions, Empty, Flex, Switch, Table, Tag, Tooltip, Typography } from 'antd' +import { Badge, Button, Descriptions, Empty, Flex, Switch, Table, Tag, Tooltip, Typography } from 'antd' import type { ColumnsType } from 'antd/es/table' -import { Hammer, Info, Zap } from 'lucide-react' +import { Hammer, Info, Play, Zap } from 'lucide-react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' +import ExecuteToolModal from './ExecuteToolModal' interface MCPToolsSectionProps { tools: MCPTool[] @@ -14,6 +16,8 @@ interface MCPToolsSectionProps { const MCPToolsSection = ({ tools, server, onToggleTool, onToggleAutoApprove }: MCPToolsSectionProps) => { const { t } = useTranslation() + const [executeModalOpen, setExecuteModalOpen] = useState(false) + const [selectedTool, setSelectedTool] = useState(null) // Check if a tool is enabled (not in the disabledTools array) const isToolEnabled = (tool: MCPTool) => { @@ -30,6 +34,12 @@ const MCPToolsSection = ({ tools, server, onToggleTool, onToggleAutoApprove }: M onToggleAutoApprove(tool, checked) } + // Handle execute tool + const handleExecuteTool = (tool: MCPTool) => { + setSelectedTool(tool) + setExecuteModalOpen(true) + } + // Render tool properties from the input schema const renderToolProperties = (tool: MCPTool) => { if (!tool.inputSchema?.properties) return null @@ -102,6 +112,7 @@ const MCPToolsSection = ({ tools, server, onToggleTool, onToggleAutoApprove }: M } const columns: ColumnsType = [ + // 工具列表列定义:可用工具、启用工具、自动批准、执行工具 { title: {t('settings.mcp.tools.availableTools')}, dataIndex: 'name', @@ -141,7 +152,7 @@ const MCPToolsSection = ({ tools, server, onToggleTool, onToggleAutoApprove }: M ), key: 'enable', - width: 150, // Fixed width might be good for alignment + width: 130, // Fixed width might be good for alignment align: 'center', render: (_, tool) => ( handleToggle(tool, checked)} size="small" /> @@ -155,7 +166,7 @@ const MCPToolsSection = ({ tools, server, onToggleTool, onToggleAutoApprove }: M ), key: 'autoApprove', - width: 150, // Fixed width + width: 130, // Fixed width align: 'center', render: (_, tool) => ( ) + }, + { + title: ( + + + {t('settings.mcp.tools.execute.label', 'Execute')} + + ), + key: 'execute', + width: 130, + align: 'center', + render: (_, tool) => ( + + + + ) } ] - return tools.length > 0 ? ( - renderToolProperties(tool) - }} - /> - ) : ( - + return ( + <> + {tools.length > 0 ? ( +
renderToolProperties(tool) + }} + /> + ) : ( + + )} + { + setExecuteModalOpen(false) + setSelectedTool(null) + }} + /> + ) } From 8d227a5164944b474990153ee538e3de0bec9865 Mon Sep 17 00:00:00 2001 From: xu-ya Date: Tue, 18 Nov 2025 14:45:10 +0800 Subject: [PATCH 2/5] feat: add mcp Execute Tool --- .../settings/MCPSettings/ExecuteToolModal.tsx | 438 ++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 src/renderer/src/pages/settings/MCPSettings/ExecuteToolModal.tsx diff --git a/src/renderer/src/pages/settings/MCPSettings/ExecuteToolModal.tsx b/src/renderer/src/pages/settings/MCPSettings/ExecuteToolModal.tsx new file mode 100644 index 0000000000..e9dc4995de --- /dev/null +++ b/src/renderer/src/pages/settings/MCPSettings/ExecuteToolModal.tsx @@ -0,0 +1,438 @@ +import 'katex/dist/katex.min.css' + +import type { MCPServer, MCPTool } from '@renderer/types' +import { loggerService } from '@logger' +import { Button, Flex, Input, Modal, Space, Table, Typography, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { Copy, Play, Sparkles, Code as CodeIcon } from 'lucide-react' +import { useState, useMemo, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' +import rehypeKatex from 'rehype-katex' +import rehypeRaw from 'rehype-raw' +import remarkCjkFriendly from 'remark-cjk-friendly' + +const logger = loggerService.withContext('ExecuteToolModal') + +interface ExecuteToolModalProps { + open: boolean + tool: MCPTool | null + server: MCPServer | null + onClose: () => void +} + +interface TableData { + key: string + name: string + value: any +} + +const ExecuteToolModal: React.FC = ({ open, tool, server, onClose }) => { + const { t } = useTranslation() + const [paramsJson, setParamsJson] = useState('{}') + const [loading, setLoading] = useState(false) + const [result, setResult] = useState<{ content: any[]; isError?: boolean } | null>(null) + const [viewMode, setViewMode] = useState<'json' | 'formatted'>('json') + + // 初始化参数 JSON(基于工具的 inputSchema) + const initialParams = useMemo(() => { + if (!tool?.inputSchema?.properties) { + return '{}' + } + + const params: Record = {} + const properties = tool.inputSchema.properties + + // 为每个属性生成默认值或示例 + Object.keys(properties).forEach((key) => { + const prop = properties[key] + if (prop.type === 'string') { + params[key] = prop.default || '' + } else if (prop.type === 'number') { + params[key] = prop.default || 0 + } else if (prop.type === 'boolean') { + params[key] = prop.default || false + } else if (prop.type === 'array') { + params[key] = prop.default || [] + } else if (prop.type === 'object') { + params[key] = prop.default || {} + } + }) + + return JSON.stringify(params, null, 2) + }, [tool]) + + // 当工具改变时,重置参数 + useEffect(() => { + if (open && tool) { + setParamsJson(initialParams) + setResult(null) + setViewMode('json') + } + }, [open, tool, initialParams]) + + // 检测文本类型 + const detectContentType = (text: string): 'json' | 'markdown' | 'html' | 'text' => { + if (!text) return 'text' + + // 检测 HTML 特征(优先检测,因为 HTML 可能包含其他格式) + // 检查是否包含完整的 HTML 文档结构或大量 HTML 标签 + const htmlDocumentPatterns = [ + /]/i, + /]/i, + /]/i + ] + + const hasHtmlDocument = htmlDocumentPatterns.some((pattern) => pattern.test(text)) + + // 检查 HTML 标签数量 + const htmlTagPattern = /<[a-z][a-z0-9]*[\s>]/gi + const htmlTags = text.match(htmlTagPattern) + const htmlTagCount = htmlTags ? htmlTags.length : 0 + + // 如果包含 HTML 文档结构,或者有多个 HTML 标签,认为是 HTML + if (hasHtmlDocument || htmlTagCount >= 3) { + return 'html' + } + + // 尝试解析为 JSON + try { + const parsed = JSON.parse(text) + if (typeof parsed === 'object' && parsed !== null) { + return 'json' + } + } catch { + // 不是 JSON + } + + // 检测 Markdown 特征 + const markdownPatterns = [ + /^#{1,6}\s+.+$/m, // 标题 + /^\s*[-*+]\s+.+$/m, // 列表 + /^\s*\d+\.\s+.+$/m, // 有序列表 + /\[.+\]\(.+\)/, // 链接 + /!\[.+\]\(.+\)/, // 图片 + /```[\s\S]*?```/, // 代码块 + /`[^`]+`/, // 行内代码 + /\*\*.*?\*\*/, // 粗体 + /\*.*?\*/, // 斜体 + /^>\s+.+$/m // 引用 + ] + + const hasMarkdown = markdownPatterns.some((pattern) => pattern.test(text)) + if (hasMarkdown) { + return 'markdown' + } + + return 'text' + } + + // 获取主要文本内容 + const mainTextContent = useMemo(() => { + if (!result || !result.content) return '' + + // 查找第一个 text 类型的 content + const textContent = result.content.find((item) => item.type === 'text') + return textContent?.text || '' + }, [result]) + + // 获取格式化的内容类型 + const formattedContentType = useMemo(() => { + return detectContentType(mainTextContent) + }, [mainTextContent]) + + // 验证 JSON 格式 + const validateJson = (jsonStr: string): { valid: boolean; data?: any; error?: string } => { + try { + const data = JSON.parse(jsonStr) + return { valid: true, data } + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : 'Invalid JSON' + } + } + } + + // 执行工具 + const handleExecute = async () => { + if (!tool || !server) { + message.error(t('settings.mcp.tools.execute.error.noToolOrServer', 'Tool or server not found')) + return + } + + // 验证 JSON + const validation = validateJson(paramsJson) + if (!validation.valid) { + message.error( + t('settings.mcp.tools.execute.error.invalidJson', 'Invalid JSON format: {{error}}', { + error: validation.error + }) + ) + return + } + + setLoading(true) + setResult(null) + + try { + logger.info(`Executing tool: ${tool.name}`, { params: validation.data }) + + const resp = await window.api.mcp.callTool({ + server, + name: tool.name, + args: validation.data, + callId: `manual-${Date.now()}` + }) + + logger.info(`Tool executed successfully: ${tool.name}`, resp) + setResult(resp) + } catch (error) { + logger.error(`Error executing tool: ${tool.name}`, error as Error) + const errorMessage = + error instanceof Error ? error.message : typeof error === 'string' ? error : JSON.stringify(error) + + setResult({ + content: [ + { + type: 'text', + text: errorMessage + } + ], + isError: true + }) + } finally { + setLoading(false) + } + } + + // 复制结果 + const handleCopy = () => { + if (!result) return + + const resultText = JSON.stringify(result, null, 2) + navigator.clipboard.writeText(resultText).then( + () => { + message.success(t('settings.mcp.tools.execute.copied', 'Copied to clipboard')) + }, + () => { + message.error(t('settings.mcp.tools.execute.copyFailed', 'Failed to copy')) + } + ) + } + + // 将结果转换为表格数据(仅当内容是 JSON 时) + const tableData: TableData[] = useMemo(() => { + if (!result || !result.content || formattedContentType !== 'json') return [] + + const data: TableData[] = [] + + try { + const parsed = JSON.parse(mainTextContent) + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + // 如果是对象,展开为多行 + Object.entries(parsed).forEach(([key, value]) => { + data.push({ + key: key, + name: key, + value: typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value) + }) + }) + } else if (Array.isArray(parsed)) { + // 如果是数组,显示索引和值 + parsed.forEach((item, idx) => { + data.push({ + key: `item-${idx}`, + name: String(idx), + value: typeof item === 'object' ? JSON.stringify(item, null, 2) : String(item) + }) + }) + } + } catch { + // 解析失败,返回空数组 + } + + return data + }, [result, formattedContentType, mainTextContent]) + + const tableColumns: ColumnsType = [ + { + title: t('settings.mcp.tools.execute.table.name', 'Name'), + dataIndex: 'name', + key: 'name', + width: 200 + }, + { + title: t('settings.mcp.tools.execute.table.value', 'Value'), + dataIndex: 'value', + key: 'value', + render: (value: string) => ( + + {value} + + ) + } + ] + + if (!tool || !server) { + return null + } + + return ( + + + + {t('settings.mcp.tools.execute.title', 'Execute Tool: {{name}}', { name: tool.name })} + + + } + open={open} + onCancel={onClose} + width={900} + footer={[ + , + + ]}> + + {/* 参数输入 */} +
+ + {t('settings.mcp.tools.execute.params', 'Parameters (JSON)')} + + setParamsJson(e.target.value)} + rows={8} + placeholder={t('settings.mcp.tools.execute.paramsPlaceholder', 'Enter JSON parameters...')} + style={{ fontFamily: 'monospace', fontSize: '12px' }} + /> +
+ + {/* 结果显示 */} + {result && ( +
+ + + {result.isError + ? t('settings.mcp.tools.execute.result.error', 'Error Result') + : t('settings.mcp.tools.execute.result.success', 'Result')} + + + + + + + + + + + {viewMode === 'json' ? ( + + ) : ( +
+ {formattedContentType === 'html' ? ( +