feat: implement MCP tool auto-approve functionality (#8007)

*  feat: implement MCP tool auto-approve functionality

- Add auto-approve toggle for MCP tools in settings
- Add improved UI for tool approval with Run/Cancel/Auto-approve buttons
- Add internationalization support for tool approval interface
- Update tool confirmation logic to support auto-approved tools
- Enhance tool status indicators and button styling
- Add disabledAutoApproveTools configuration for MCP servers

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: use table for mcp tools setting

* refactor: improve styles, add missing i18n

* refactor: extract renderStatusIndicator, reuse colors

* refactor: simplify the table

* feat: auto approve same tool in a turn

* feat(i18n): add confirmation tooltip for auto-approve tool in multiple languages

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: one <wangan.cs@gmail.com>
Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: Teo <cheesen.xu@gmail.com>
This commit is contained in:
LiuVaayne 2025-07-10 22:14:52 +08:00 committed by GitHub
parent a7b78c547a
commit 8f86c53941
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 534 additions and 257 deletions

View File

@ -764,7 +764,7 @@
"invoking": "Invoking",
"pending": "Pending",
"preview": "Preview",
"raw": "Raw"
"autoApproveEnabled": "Auto-approve enabled for this tool"
},
"topic.added": "New topic added",
"upgrade.success.button": "Restart",
@ -1888,7 +1888,14 @@
"availableTools": "Available Tools",
"inputSchema": "Input Schema",
"loadError": "Get tools Error",
"noToolsAvailable": "No tools available"
"noToolsAvailable": "No tools available",
"enable": "Enable Tool",
"autoApprove": "Auto Approve",
"autoApprove.tooltip.howToEnable": "Enable the tool first to use auto-approve",
"autoApprove.tooltip.enabled": "Tool will run automatically without confirmation",
"autoApprove.tooltip.disabled": "Tool will require manual approval before running",
"autoApprove.tooltip.confirm": "Are you sure you want to run this MCP tool?",
"run": "Run"
},
"type": "Type",
"types": {

View File

@ -764,7 +764,7 @@
"invoking": "呼び出し中",
"pending": "保留中",
"preview": "プレビュー",
"raw": "生データ"
"autoApproveEnabled": "このツールは自動承認が有効になっています"
},
"topic.added": "新しいトピックが追加されました",
"upgrade.success.button": "再起動",
@ -1888,7 +1888,14 @@
"availableTools": "利用可能なツール",
"inputSchema": "入力スキーマ",
"loadError": "ツール取得エラー",
"noToolsAvailable": "利用可能なツールなし"
"noToolsAvailable": "利用可能なツールなし",
"enable": "ツールを有効にする",
"autoApprove": "自動承認",
"autoApprove.tooltip.howToEnable": "ツールを有効にしてから自動承認を使用できます",
"autoApprove.tooltip.enabled": "ツールは承認なしで自動実行されます",
"autoApprove.tooltip.disabled": "ツールは実行前に手動承認が必要です",
"autoApprove.tooltip.confirm": "このMCPツールを実行してもよろしいですか",
"run": "実行"
},
"type": "タイプ",
"types": {

View File

@ -764,7 +764,7 @@
"invoking": "Вызов",
"pending": "Ожидание",
"preview": "Предпросмотр",
"raw": "Исходный"
"autoApproveEnabled": "Для этого инструмента включен автоматический одобрен"
},
"topic.added": "Новый топик добавлен",
"upgrade.success.button": "Перезапустить",
@ -1888,7 +1888,14 @@
"availableTools": "Доступные инструменты",
"inputSchema": "Схема ввода",
"loadError": "Ошибка получения инструментов",
"noToolsAvailable": "Нет доступных инструментов"
"noToolsAvailable": "Нет доступных инструментов",
"enable": "Включить инструмент",
"autoApprove": "Автоматическое одобрение",
"autoApprove.tooltip.howToEnable": "Включите инструмент, чтобы использовать автоматическое одобрение",
"autoApprove.tooltip.enabled": "Инструмент будет автоматически выполняться без подтверждения",
"autoApprove.tooltip.disabled": "Инструмент будет требовать ручное одобрение перед выполнением",
"autoApprove.tooltip.confirm": "Вы уверены, что хотите выполнить этот инструмент MCP?",
"run": "Выполнить"
},
"type": "Тип",
"types": {

View File

@ -764,7 +764,7 @@
"invoking": "调用中",
"pending": "等待中",
"preview": "预览",
"raw": "原始"
"autoApproveEnabled": "此工具已启用自动批准"
},
"topic.added": "话题添加成功",
"upgrade.success.button": "重启",
@ -1888,7 +1888,14 @@
"availableTools": "可用工具",
"inputSchema": "输入模式",
"loadError": "获取工具失败",
"noToolsAvailable": "无可用工具"
"noToolsAvailable": "无可用工具",
"enable": "启用工具",
"autoApprove": "自动批准",
"autoApprove.tooltip.howToEnable": "启用工具后才能使用自动批准",
"autoApprove.tooltip.enabled": "工具将自动运行而无需批准",
"autoApprove.tooltip.disabled": "工具运行前需要手动批准",
"autoApprove.tooltip.confirm": "是否运行该MCP工具",
"run": "运行"
},
"type": "类型",
"types": {

View File

@ -764,7 +764,7 @@
"invoking": "調用中",
"pending": "等待中",
"preview": "預覽",
"raw": "原始碼"
"autoApproveEnabled": "此工具已啟用自動批准"
},
"topic.added": "新話題已新增",
"upgrade.success.button": "重新啟動",
@ -1888,7 +1888,14 @@
"availableTools": "可用工具",
"inputSchema": "輸入模式",
"loadError": "獲取工具失敗",
"noToolsAvailable": "無可用工具"
"noToolsAvailable": "無可用工具",
"enable": "啟用工具",
"autoApprove": "自動批准",
"autoApprove.tooltip.howToEnable": "啟用工具後才能使用自動批准",
"autoApprove.tooltip.enabled": "工具將自動運行而無需批准",
"autoApprove.tooltip.disabled": "工具運行前需要手動批准",
"autoApprove.tooltip.confirm": "是否運行該MCP工具",
"run": "運行"
},
"type": "類型",
"types": {

View File

@ -1,13 +1,15 @@
import { CheckOutlined, CloseOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { useSettings } from '@renderer/hooks/useSettings'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { isToolAutoApproved } from '@renderer/utils/mcp-tools'
import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation'
import { Collapse, message as antdMessage, Tooltip } from 'antd'
import { Button, Collapse, ConfigProvider, Dropdown, Flex, message as antdMessage, Tooltip } from 'antd'
import { message } from 'antd'
import Logger from 'electron-log/renderer'
import { PauseCircle } from 'lucide-react'
import { FC, memo, useEffect, useMemo, useState } from 'react'
import { ChevronDown, ChevronRight, CirclePlay, CircleX, PauseCircle, ShieldCheck } from 'lucide-react'
import { FC, memo, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -15,11 +17,15 @@ interface Props {
block: ToolMessageBlock
}
const COUNTDOWN_TIME = 30
const MessageTools: FC<Props> = ({ block }) => {
const [activeKeys, setActiveKeys] = useState<string[]>([])
const [copiedMap, setCopiedMap] = useState<Record<string, boolean>>({})
const [countdown, setCountdown] = useState<number>(COUNTDOWN_TIME)
const { t } = useTranslation()
const { messageFont, fontSize } = useSettings()
const { mcpServers, updateMCPServer } = useMCPServers()
const toolResponse = block.metadata?.rawMcpToolResponse
@ -29,6 +35,32 @@ const MessageTools: FC<Props> = ({ block }) => {
const isInvoking = status === 'invoking'
const isDone = status === 'done'
const timer = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (!isPending) return
if (countdown > 0) {
timer.current = setTimeout(() => {
console.log('countdown', countdown)
setCountdown((prev) => prev - 1)
}, 1000)
} else if (countdown === 0) {
confirmToolAction(id)
}
return () => {
if (timer.current) {
clearTimeout(timer.current)
}
}
}, [countdown, id, isPending])
const cancelCountdown = () => {
if (timer.current) {
clearTimeout(timer.current)
}
}
const argsString = useMemo(() => {
if (toolResponse?.arguments) {
return JSON.stringify(toolResponse.arguments, null, 2)
@ -67,10 +99,12 @@ const MessageTools: FC<Props> = ({ block }) => {
}
const handleConfirmTool = () => {
cancelCountdown()
confirmToolAction(id)
}
const handleCancelTool = () => {
cancelCountdown()
cancelToolAction(id)
}
@ -90,6 +124,76 @@ const MessageTools: FC<Props> = ({ block }) => {
}
}
const handleAutoApprove = async () => {
cancelCountdown()
if (!tool || !tool.name) {
return
}
const server = mcpServers.find((s) => s.id === tool.serverId)
if (!server) {
return
}
let disabledAutoApproveTools = [...(server.disabledAutoApproveTools || [])]
// Remove tool from disabledAutoApproveTools to enable auto-approve
disabledAutoApproveTools = disabledAutoApproveTools.filter((name) => name !== tool.name)
const updatedServer = {
...server,
disabledAutoApproveTools
}
updateMCPServer(updatedServer)
// Also confirm the current tool
confirmToolAction(id)
message.success({
content: t('message.tools.autoApproveEnabled', 'Auto-approve enabled for this tool'),
key: 'auto-approve'
})
}
const renderStatusIndicator = (status: string, hasError: boolean) => {
let label = ''
let icon: React.ReactNode | null = null
switch (status) {
case 'pending':
label = t('message.tools.pending', 'Awaiting Approval')
icon = <LoadingOutlined spin style={{ marginLeft: 6, color: 'var(--status-color-warning)' }} />
break
case 'invoking':
label = t('message.tools.invoking')
icon = <LoadingOutlined spin style={{ marginLeft: 6 }} />
break
case 'cancelled':
label = t('message.tools.cancelled')
icon = <CloseOutlined style={{ marginLeft: 6 }} />
break
case 'done':
if (hasError) {
label = t('message.tools.error')
icon = <WarningOutlined style={{ marginLeft: 6 }} />
} else {
label = t('message.tools.completed')
icon = <CheckOutlined style={{ marginLeft: 6 }} />
}
break
default:
label = ''
icon = null
}
return (
<StatusIndicator status={status} hasError={hasError}>
{label}
{icon}
</StatusIndicator>
)
}
// Format tool responses for collapse items
const getCollapseItems = () => {
const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = []
@ -104,108 +208,33 @@ const MessageTools: FC<Props> = ({ block }) => {
label: (
<MessageTitleLabel>
<TitleContent>
<ToolName>{tool.name}</ToolName>
<StatusIndicator status={status} hasError={hasError}>
{(() => {
switch (status) {
case 'pending':
return (
<>
{t('message.tools.pending')}
<LoadingOutlined spin style={{ marginLeft: 6 }} />
</>
)
case 'invoking':
return (
<>
{t('message.tools.invoking')}
<LoadingOutlined spin style={{ marginLeft: 6 }} />
</>
)
case 'cancelled':
return (
<>
{t('message.tools.cancelled')}
<CloseOutlined style={{ marginLeft: 6 }} />
</>
)
case 'done':
if (hasError) {
return (
<>
{t('message.tools.error')}
<WarningOutlined style={{ marginLeft: 6 }} />
</>
)
} else {
return (
<>
{t('message.tools.completed')}
<CheckOutlined style={{ marginLeft: 6 }} />
</>
)
}
default:
return ''
}
})()}
</StatusIndicator>
<ToolName align="center" gap={4}>
{tool.serverName}: {tool.name}
{isToolAutoApproved(tool) && (
<Tooltip title={t('message.tools.autoApproveEnabled')} mouseLeaveDelay={0}>
<ShieldCheck size={14} color="var(--status-color-success)" />
</Tooltip>
)}
</ToolName>
</TitleContent>
<ActionButtonsContainer>
{isPending && (
<>
<Tooltip title={t('common.cancel')} mouseEnterDelay={0.3}>
<ActionButton
onClick={(e) => {
e.stopPropagation()
handleCancelTool()
}}
aria-label={t('common.cancel')}>
<CloseOutlined style={{ fontSize: '14px' }} />
</ActionButton>
</Tooltip>
<Tooltip title={t('common.confirm')} mouseEnterDelay={0.3}>
<ActionButton
className="confirm-button"
onClick={(e) => {
e.stopPropagation()
handleConfirmTool()
}}
aria-label={t('common.confirm')}>
<CheckOutlined style={{ fontSize: '14px' }} />
</ActionButton>
</Tooltip>
</>
)}
{isInvoking && toolResponse?.id && (
<Tooltip title={t('chat.input.pause')} mouseEnterDelay={0.3}>
<StatusIndicator status={status} hasError={hasError}>
{renderStatusIndicator(status, hasError)}
</StatusIndicator>
{!isPending && !isInvoking && (
<Tooltip title={t('common.copy')} mouseEnterDelay={0.5}>
<ActionButton
className="abort-button"
className="message-action-button"
onClick={(e) => {
e.stopPropagation()
handleAbortTool()
copyContent(JSON.stringify(result, null, 2), id)
}}
aria-label={t('chat.input.pause')}>
<PauseCircle color="var(--color-error)" size={14} />
aria-label={t('common.copy')}>
{!copiedMap[id] && <i className="iconfont icon-copy"></i>}
{copiedMap[id] && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
</ActionButton>
</Tooltip>
)}
{isDone && response && (
<>
<Tooltip title={t('common.copy')} mouseEnterDelay={0.5}>
<ActionButton
className="message-action-button"
onClick={(e) => {
e.stopPropagation()
copyContent(JSON.stringify(result, null, 2), id)
}}
aria-label={t('common.copy')}>
{!copiedMap[id] && <i className="iconfont icon-copy"></i>}
{copiedMap[id] && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
</ActionButton>
</Tooltip>
</>
)}
</ActionButtonsContainer>
</MessageTitleLabel>
),
@ -231,16 +260,91 @@ const MessageTools: FC<Props> = ({ block }) => {
}
return (
<ToolContainer>
<CollapseContainer
activeKey={activeKeys}
size="small"
onChange={handleCollapseChange}
className="message-tools-container"
items={getCollapseItems()}
expandIconPosition="end"
/>
</ToolContainer>
<ConfigProvider
theme={{
components: {
Button: {
borderRadiusSM: 6
}
}
}}>
<ToolContainer>
<ToolContentWrapper>
<CollapseContainer
ghost
activeKey={activeKeys}
size="small"
onChange={handleCollapseChange}
className="message-tools-container"
items={getCollapseItems()}
expandIconPosition="end"
expandIcon={({ isActive }) => (
<ExpandIcon $isActive={isActive} size={18} color="var(--color-text-3)" strokeWidth={1.5} />
)}
/>
{(isPending || isInvoking) && (
<ActionsBar>
<ActionLabel>
{isPending ? t('settings.mcp.tools.autoApprove.tooltip.confirm') : t('message.tools.invoking')}
</ActionLabel>
<ActionButtonsGroup>
{isPending && (
<Button
color="danger"
variant="filled"
size="small"
onClick={() => {
handleCancelTool()
}}>
<CircleX size={15} className="lucide-custom" />
{t('common.cancel')}
</Button>
)}
{isInvoking && toolResponse?.id ? (
<Button
size="small"
color="danger"
variant="solid"
className="abort-button"
onClick={(e) => {
e.stopPropagation()
handleAbortTool()
}}>
<PauseCircle className="lucide-custom" size={14} />
{t('chat.input.pause')}
</Button>
) : (
<StyledDropdownButton
size="small"
type="primary"
icon={<ChevronDown size={14} />}
onClick={() => {
handleConfirmTool()
}}
menu={{
items: [
{
key: 'autoApprove',
label: t('settings.mcp.tools.autoApprove'),
onClick: () => {
handleAutoApprove()
}
}
]
}}>
<CirclePlay size={15} className="lucide-custom" />
<CountdownText>
{t('settings.mcp.tools.run', 'Run')} ({countdown}s)
</CountdownText>
</StyledDropdownButton>
)}
</ActionButtonsGroup>
</ActionsBar>
)}
</ToolContentWrapper>
</ToolContainer>
</ConfigProvider>
)
}
@ -265,29 +369,64 @@ const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ i
return <MarkdownContainer className="markdown" dangerouslySetInnerHTML={{ __html: styledResult }} />
}
const CollapseContainer = styled(Collapse)`
const ToolContentWrapper = styled.div`
padding: 1px;
background-color: var(--color-background-soft);
border-radius: 8px;
overflow: hidden;
`
const ActionsBar = styled.div`
padding: 8px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
`
const ActionLabel = styled.div`
flex: 1;
font-size: 14px;
color: var(--color-text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`
const ActionButtonsGroup = styled.div`
display: flex;
gap: 10px;
`
const CountdownText = styled.span`
width: 65px;
text-align: left;
`
const StyledDropdownButton = styled(Dropdown.Button)`
.ant-btn-group {
border-radius: 6px;
}
`
const ExpandIcon = styled(ChevronRight)<{ $isActive?: boolean }>`
transition: transform 0.2s;
transform: ${({ $isActive }) => ($isActive ? 'rotate(90deg)' : 'rotate(0deg)')};
`
const CollapseContainer = styled(Collapse)`
--status-color-warning: var(--color-warning, #faad14);
--status-color-invoking: var(--color-primary);
--status-color-error: var(--color-error, #ff4d4f);
--status-color-success: var(--color-success, green);
border-radius: 7px;
border: none;
background-color: var(--color-background);
overflow: hidden;
.ant-collapse-header {
background-color: var(--color-bg-2);
transition: background-color 0.2s;
display: flex;
align-items: center;
.ant-collapse-expand-icon {
height: 100% !important;
}
.ant-collapse-arrow {
height: 28px !important;
svg {
width: 14px;
height: 14px;
}
}
&:hover {
background-color: var(--color-bg-3);
}
padding: 8px 10px !important;
align-items: center !important;
}
.ant-collapse-content-box {
@ -297,11 +436,7 @@ const CollapseContainer = styled(Collapse)`
const ToolContainer = styled.div`
margin-top: 10px;
margin-bottom: 12px;
border: 1px solid var(--color-border);
background-color: var(--color-bg-2);
border-radius: 8px;
overflow: hidden;
margin-bottom: 10px;
`
const MarkdownContainer = styled.div`
@ -319,7 +454,6 @@ const MessageTitleLabel = styled.div`
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 26px;
gap: 10px;
padding: 0;
margin-left: 4px;
@ -332,7 +466,7 @@ const TitleContent = styled.div`
gap: 8px;
`
const ToolName = styled.span`
const ToolName = styled(Flex)`
color: var(--color-text);
font-weight: 500;
font-size: 13px;
@ -342,29 +476,30 @@ const StatusIndicator = styled.span<{ status: string; hasError?: boolean }>`
color: ${(props) => {
switch (props.status) {
case 'pending':
return 'var(--color-text-2)'
return 'var(--status-color-warning)'
case 'invoking':
return 'var(--color-primary)'
return 'var(--status-color-invoking)'
case 'cancelled':
return 'var(--color-error, #ff4d4f)' // Assuming cancelled should also be an error color
return 'var(--status-color-error)'
case 'done':
return props.hasError ? 'var(--color-error, #ff4d4f)' : 'var(--color-success, #52c41a)'
return props.hasError ? 'var(--status-color-error)' : 'var(--status-color-success)'
default:
return 'var(--color-text)'
}
}};
font-size: 11px;
font-weight: ${(props) => (props.status === 'pending' ? '600' : '400')};
display: flex;
align-items: center;
opacity: 0.85;
border-left: 1px solid var(--color-border);
opacity: ${(props) => (props.status === 'pending' ? '1' : '0.85')};
padding-left: 12px;
`
const ActionButtonsContainer = styled.div`
display: flex;
gap: 8px;
gap: 6px;
margin-left: auto;
align-items: center;
`
const ActionButton = styled.button`

View File

@ -443,6 +443,33 @@ const McpSettings: React.FC = () => {
[server, updateMCPServer]
)
// Handle toggling auto-approve for a tool
const handleToggleAutoApprove = useCallback(
async (tool: MCPTool, autoApprove: boolean) => {
let disabledAutoApproveTools = [...(server.disabledAutoApproveTools || [])]
if (autoApprove) {
disabledAutoApproveTools = disabledAutoApproveTools.filter((name) => name !== tool.name)
} else {
// Add tool to disabledTools if it's being disabled
if (!disabledAutoApproveTools.includes(tool.name)) {
disabledAutoApproveTools.push(tool.name)
}
}
// Update the server with new disabledTools
const updatedServer = {
...server,
disabledAutoApproveTools
}
// Save the updated server configuration
// await window.api.mcp.updateServer(updatedServer)
updateMCPServer(updatedServer)
},
[server, updateMCPServer]
)
const tabs = [
{
key: 'settings',
@ -649,7 +676,14 @@ const McpSettings: React.FC = () => {
{
key: 'tools',
label: t('settings.mcp.tabs.tools'),
children: <MCPToolsSection tools={tools} server={server} onToggleTool={handleToggleTool} />
children: (
<MCPToolsSection
tools={tools}
server={server}
onToggleTool={handleToggleTool}
onToggleAutoApprove={handleToggleAutoApprove}
/>
)
},
{
key: 'prompts',

View File

@ -1,15 +1,18 @@
import { MCPServer, MCPTool } from '@renderer/types'
import { Badge, Collapse, Descriptions, Empty, Flex, Switch, Tag, Tooltip, Typography } from 'antd'
import { isToolAutoApproved } from '@renderer/utils/mcp-tools'
import { Badge, Descriptions, Empty, Flex, Switch, Table, Tag, Tooltip, Typography } from 'antd'
import { ColumnsType } from 'antd/es/table'
import { Hammer, Info, Zap } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface MCPToolsSectionProps {
tools: MCPTool[]
server: MCPServer
onToggleTool: (tool: MCPTool, enabled: boolean) => void
onToggleAutoApprove: (tool: MCPTool, autoApprove: boolean) => void
}
const MCPToolsSection = ({ tools, server, onToggleTool }: MCPToolsSectionProps) => {
const MCPToolsSection = ({ tools, server, onToggleTool, onToggleAutoApprove }: MCPToolsSectionProps) => {
const { t } = useTranslation()
// Check if a tool is enabled (not in the disabledTools array)
@ -22,6 +25,11 @@ const MCPToolsSection = ({ tools, server, onToggleTool }: MCPToolsSectionProps)
onToggleTool(tool, checked)
}
// Handle auto-approve toggle
const handleAutoApproveToggle = (tool: MCPTool, checked: boolean) => {
onToggleAutoApprove(tool, checked)
}
// Render tool properties from the input schema
const renderToolProperties = (tool: MCPTool) => {
if (!tool.inputSchema?.properties) return null
@ -43,114 +51,144 @@ const MCPToolsSection = ({ tools, server, onToggleTool }: MCPToolsSectionProps)
}
}
// <Typography.Title level={5}>{t('settings.mcp.tools.inputSchema')}:</Typography.Title>
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 gap={4}>
<Typography.Text strong>{key}</Typography.Text>
{tool.inputSchema.required?.includes(key) && (
<Tooltip title="Required field">
<span style={{ color: '#f5222d' }}>*</span>
</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>
<Descriptions bordered size="small" column={1} style={{ userSelect: 'text' }}>
{Object.entries(tool.inputSchema.properties).map(([key, prop]: [string, any]) => (
<Descriptions.Item
key={key}
label={
<Flex gap={4}>
<Typography.Text strong>{key}</Typography.Text>
{tool.inputSchema.required?.includes(key) && (
<Tooltip title="Required field">
<span style={{ color: '#f5222d' }}>*</span>
</Tooltip>
)}
</Flex>
</Descriptions.Item>
))}
</Descriptions>
</div>
}>
<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>
)
}
const columns: ColumnsType<MCPTool> = [
{
title: <Typography.Text strong>{t('settings.mcp.tools.availableTools')}</Typography.Text>,
dataIndex: 'name',
key: 'name',
filters: tools.map((tool) => ({
text: tool.name,
value: tool.name
})),
onFilter: (value, record) => record.name === value,
filterSearch: true,
render: (_, tool) => (
<Flex vertical align="flex-start">
<Flex align="center" gap={4} style={{ width: '100%' }}>
<Typography.Text strong>{tool.name}</Typography.Text>
<Tooltip title={`ID: ${tool.id}`} mouseEnterDelay={0}>
<Info size={14} />
</Tooltip>
</Flex>
{tool.description && (
<Typography.Text type="secondary" style={{ fontSize: '13px', marginTop: 4 }}>
{tool.description.length > 100 ? `${tool.description.substring(0, 100)}...` : tool.description}
</Typography.Text>
)}
</Flex>
)
},
{
title: (
<Flex align="center" justify="center" gap={4}>
<Hammer size={14} color="orange" />
<Typography.Text strong>{t('settings.mcp.tools.enable')}</Typography.Text>
</Flex>
),
key: 'enable',
width: 150, // Fixed width might be good for alignment
align: 'center',
render: (_, tool) => (
<Switch checked={isToolEnabled(tool)} onChange={(checked) => handleToggle(tool, checked)} size="small" />
)
},
{
title: (
<Flex align="center" justify="center" gap={4}>
<Zap size={14} color="red" />
<Typography.Text strong>{t('settings.mcp.tools.autoApprove')}</Typography.Text>
</Flex>
),
key: 'autoApprove',
width: 150, // Fixed width
align: 'center',
render: (_, tool) => (
<Tooltip
title={
!isToolEnabled(tool)
? t('settings.mcp.tools.autoApprove.tooltip.howToEnable')
: isToolAutoApproved(tool, server)
? t('settings.mcp.tools.autoApprove.tooltip.enabled')
: t('settings.mcp.tools.autoApprove.tooltip.disabled')
}
placement="top">
<Switch
checked={isToolAutoApproved(tool, server)}
disabled={!isToolEnabled(tool)}
onChange={(checked) => handleAutoApproveToggle(tool, checked)}
size="small"
/>
</Tooltip>
)
}
]
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 justify="space-between" align="center" style={{ width: '100%' }}>
<Flex vertical align="flex-start">
<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.length > 100 ? `${tool.description.substring(0, 100)}...` : tool.description}
</Typography.Text>
)}
</Flex>
<Switch
checked={isToolEnabled(tool)}
onChange={(checked, event) => {
event?.stopPropagation()
handleToggle(tool, checked)
}}
/>
</Flex>
}>
<SelectableContent>{renderToolProperties(tool)}</SelectableContent>
</Collapse.Panel>
))}
</Collapse>
<Table
rowKey="id"
columns={columns}
dataSource={tools}
pagination={false}
sticky={{ offsetHeader: -55 }}
expandable={{
expandedRowRender: (tool) => renderToolProperties(tool)
}}
/>
) : (
<Empty description={t('settings.mcp.tools.noToolsAvailable')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Section>
</>
)
}
const Section = styled.div`
margin-top: 8px;
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

@ -624,6 +624,7 @@ export interface MCPServer {
env?: Record<string, string>
isActive: boolean
disabledTools?: string[] // List of tool names that are disabled for this server
disabledAutoApproveTools?: string[] // Whether to auto-approve tools for this server
configSample?: MCPConfigSample
headers?: Record<string, string> // Custom headers to be sent with requests to this server
searchKey?: string

View File

@ -27,7 +27,7 @@ import {
} from 'openai/resources'
import { CompletionsParams } from '../aiCore/middleware/schemas'
import { requestToolConfirmation } from './userConfirmation'
import { confirmSameNameTools, requestToolConfirmation, setToolIdToNameMapping } from './userConfirmation'
const MCP_AUTO_INSTALL_SERVER_NAME = '@cherry/mcp-auto-install'
const EXTRA_SCHEMA_KEYS = ['schema', 'headers']
@ -461,6 +461,11 @@ export function getMcpServerByTool(tool: MCPTool) {
return servers.find((s) => s.id === tool.serverId)
}
export function isToolAutoApproved(tool: MCPTool, server?: MCPServer): boolean {
const effectiveServer = server ?? getMcpServerByTool(tool)
return effectiveServer ? !effectiveServer.disabledAutoApproveTools?.includes(tool.name) : false
}
export function parseToolUse(content: string, mcpTools: MCPTool[], startIdx: number = 0): ToolUseResponse[] {
if (!content || !mcpTools || mcpTools.length === 0) {
return []
@ -576,7 +581,22 @@ export async function parseAndCallTools<R>(
const pendingPromises: Promise<void>[] = []
curToolResponses.forEach((toolResponse) => {
const confirmationPromise = requestToolConfirmation(toolResponse.id, abortSignal)
const server = getMcpServerByTool(toolResponse.tool)
const isAutoApproveEnabled = isToolAutoApproved(toolResponse.tool, server)
let confirmationPromise: Promise<boolean>
if (isAutoApproveEnabled) {
confirmationPromise = Promise.resolve(true)
} else {
setToolIdToNameMapping(toolResponse.id, toolResponse.tool.name)
confirmationPromise = requestToolConfirmation(toolResponse.id, abortSignal).then((confirmed) => {
if (confirmed && server) {
// 自动确认其他同名的待确认工具
confirmSameNameTools(toolResponse.tool.name)
}
return confirmed
})
}
const processingPromise = confirmationPromise
.then(async (confirmed) => {
@ -700,16 +720,9 @@ export async function parseAndCallTools<R>(
pendingPromises.push(processingPromise)
})
Logger.info(
`🔧 [MCP] Waiting for tool confirmations:`,
curToolResponses.map((t) => t.id)
)
// 等待所有工具处理完成(但每个工具的状态已经实时更新)
await Promise.all(pendingPromises)
Logger.info(`🔧 [MCP] All tools processed. Confirmed tools: ${confirmedTools.length}`)
return { toolResults, confirmedToolResponses: confirmedTools }
}

View File

@ -84,3 +84,24 @@ export function getPendingToolIds(): string[] {
export function isToolPending(toolId: string): boolean {
return toolConfirmResolvers.has(toolId)
}
const toolIdToNameMap = new Map<string, string>()
export function setToolIdToNameMapping(toolId: string, toolName: string): void {
toolIdToNameMap.set(toolId, toolName)
}
export function confirmSameNameTools(confirmedToolName: string): void {
const toolIdsToConfirm: string[] = []
for (const [toolId, toolName] of toolIdToNameMap.entries()) {
if (toolName === confirmedToolName && toolConfirmResolvers.has(toolId)) {
toolIdsToConfirm.push(toolId)
}
}
toolIdsToConfirm.forEach((toolId) => {
confirmToolAction(toolId)
toolIdToNameMap.delete(toolId)
})
}