mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 21:42:27 +08:00
✨ 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:
parent
a7b78c547a
commit
8f86c53941
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user