feat: add confirmation modal for activating protocol-installed MCP

This commit is contained in:
dev 2025-10-31 10:41:07 +08:00
parent 0767952a6f
commit 3a4dfeb819
15 changed files with 292 additions and 37 deletions

View File

@ -9,13 +9,20 @@ const logger = loggerService.withContext('URLSchema:handleMcpProtocolUrl')
function installMCPServer(server: MCPServer) {
const mainWindow = windowService.getMainWindow()
const now = Date.now()
if (!server.id) {
server.id = nanoid()
const payload: MCPServer = {
...server,
id: server.id ?? nanoid(),
installSource: 'protocol',
isTrusted: false,
isActive: false,
trustedAt: undefined,
installedAt: server.installedAt ?? now
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannel.Mcp_AddServer, server)
mainWindow.webContents.send(IpcChannel.Mcp_AddServer, payload)
}
}

View File

@ -0,0 +1,57 @@
import ProtocolInstallWarningContent from '@renderer/pages/settings/MCPSettings/ProtocolInstallWarning'
import {
ensureServerTrusted as ensureServerTrustedCore,
getCommandPreview
} from '@renderer/pages/settings/MCPSettings/utils'
import { MCPServer } from '@renderer/types'
import { modalConfirm } from '@renderer/utils'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useMCPServers } from './useMCPServers'
/**
* Hook for handling MCP server trust verification
* Binds UI (modal dialog) to the core trust verification logic
*/
export const useMCPServerTrust = () => {
const { updateMCPServer } = useMCPServers()
const { t } = useTranslation()
/**
* Request user confirmation to trust a server
* Shows a warning modal with server command preview
*/
const requestConfirm = useCallback(
async (server: MCPServer): Promise<boolean> => {
const commandPreview = getCommandPreview(server)
return modalConfirm({
title: t('settings.mcp.protocolInstallWarning.title'),
content: (
<ProtocolInstallWarningContent
message={t('settings.mcp.protocolInstallWarning.message')}
commandLabel={t('settings.mcp.protocolInstallWarning.command')}
commandPreview={commandPreview}
/>
),
okText: t('settings.mcp.protocolInstallWarning.run'),
cancelText: t('common.cancel'),
okButtonProps: { danger: true }
})
},
[t]
)
/**
* Ensures a server is trusted before proceeding
* Combines core logic with UI confirmation
*/
const ensureServerTrusted = useCallback(
async (server: MCPServer): Promise<MCPServer | null> => {
return ensureServerTrustedCore(server, requestConfirm, updateMCPServer)
},
[requestConfirm, updateMCPServer]
)
return { ensureServerTrusted }
}

View File

@ -3720,6 +3720,12 @@
"label": "Disable MCP Server"
},
"duplicateName": "A server with this name already exists",
"protocolInstallWarning": {
"title": "Run external MCP?",
"message": "This MCP was installed from an external source via protocol. Running unknown tools may harm your computer.",
"command": "Startup command",
"run": "Run"
},
"editJson": "Edit JSON",
"editMcpJson": "Edit MCP Configuration",
"editServer": "Edit Server",

View File

@ -3720,6 +3720,12 @@
"label": "不使用 MCP 服务器"
},
"duplicateName": "已存在同名服务器",
"protocolInstallWarning": {
"title": "运行外部 MCP",
"message": "该 MCP 是通过协议从外部来源安装的,运行来历不明的工具可能对您的计算机造成危害。",
"command": "启动命令",
"run": "运行"
},
"editJson": "编辑 JSON",
"editMcpJson": "编辑 MCP 配置",
"editServer": "编辑服务器",

View File

@ -3720,6 +3720,12 @@
"label": "不使用 MCP 伺服器"
},
"duplicateName": "已存在相同名稱的伺服器",
"protocolInstallWarning": {
"title": "執行外部 MCP",
"message": "此 MCP 透過協議從外部來源安裝,執行來源不明的工具可能會對您的電腦造成危害。",
"command": "啟動命令",
"run": "執行"
},
"editJson": "編輯 JSON",
"editMcpJson": "編輯 MCP 配置",
"editServer": "編輯伺服器",

View File

@ -138,6 +138,7 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
// Process DXT file
try {
const installTimestamp = Date.now()
const result = await window.api.mcp.uploadDxt(dxtFile)
if (!result.success) {
@ -188,7 +189,11 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
logoUrl: manifest.icon ? `${extractDir}/${manifest.icon}` : undefined,
provider: manifest.author?.name,
providerUrl: manifest.homepage || manifest.repository?.url,
tags: manifest.keywords
tags: manifest.keywords,
installSource: 'manual',
isTrusted: true,
installedAt: installTimestamp,
trustedAt: installTimestamp
}
onSuccess(newServer)
@ -253,12 +258,17 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
}
// 如果成功解析並通過所有檢查,立即加入伺服器(非啟用狀態)並關閉對話框
const installTimestamp = Date.now()
const newServer: MCPServer = {
id: nanoid(),
...serverToAdd,
name: serverToAdd.name || t('settings.mcp.newServer'),
baseUrl: serverToAdd.baseUrl ?? serverToAdd.url ?? '',
isActive: false // 初始狀態為非啟用
isActive: false, // 初始狀態為非啟用
installSource: 'manual',
isTrusted: true,
installedAt: installTimestamp,
trustedAt: installTimestamp
}
onSuccess(newServer)

View File

@ -5,6 +5,7 @@ import { Sortable, useDndReorder } from '@renderer/components/dnd'
import { EditIcon, RefreshIcon } from '@renderer/components/Icons'
import Scrollbar from '@renderer/components/Scrollbar'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { useMCPServerTrust } from '@renderer/hooks/useMCPServerTrust'
import { MCPServer } from '@renderer/types'
import { formatMcpError } from '@renderer/utils/error'
import { matchKeywordsInString } from '@renderer/utils/match'
@ -28,6 +29,7 @@ const logger = loggerService.withContext('McpServersList')
const McpServersList: FC = () => {
const { mcpServers, addMCPServer, deleteMCPServer, updateMcpServers, updateMCPServer } = useMCPServers()
const { ensureServerTrusted } = useMCPServerTrust()
const { t } = useTranslation()
const navigate = useNavigate()
const [isAddModalVisible, setIsAddModalVisible] = useState(false)
@ -156,30 +158,37 @@ const McpServersList: FC = () => {
)
const handleToggleActive = async (server: MCPServer, active: boolean) => {
setLoadingServerIds((prev) => new Set(prev).add(server.id))
const oldActiveState = server.isActive
logger.silly('toggle activate', { serverId: server.id, active })
let serverForUpdate = server
if (active) {
const trustedServer = await ensureServerTrusted(server)
if (!trustedServer) {
return
}
serverForUpdate = trustedServer
}
setLoadingServerIds((prev) => new Set(prev).add(serverForUpdate.id))
const oldActiveState = serverForUpdate.isActive
logger.silly('toggle activate', { serverId: serverForUpdate.id, active })
try {
if (active) {
// Fetch version when server is activated
await fetchServerVersion({ ...server, isActive: active })
await fetchServerVersion({ ...serverForUpdate, isActive: active })
} else {
await window.api.mcp.stopServer(server)
// Clear version when server is deactivated
setServerVersions((prev) => ({ ...prev, [server.id]: null }))
await window.api.mcp.stopServer(serverForUpdate)
setServerVersions((prev) => ({ ...prev, [serverForUpdate.id]: null }))
}
updateMCPServer({ ...server, isActive: active })
updateMCPServer({ ...serverForUpdate, isActive: active })
} catch (error: any) {
window.modal.error({
title: t('settings.mcp.startError'),
content: formatMcpError(error),
centered: true
})
updateMCPServer({ ...server, isActive: oldActiveState })
updateMCPServer({ ...serverForUpdate, isActive: oldActiveState })
} finally {
setLoadingServerIds((prev) => {
const next = new Set(prev)
next.delete(server.id)
next.delete(serverForUpdate.id)
return next
})
}

View File

@ -3,6 +3,7 @@ import type { McpError } from '@modelcontextprotocol/sdk/types.js'
import { DeleteIcon } from '@renderer/components/Icons'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMCPServer, useMCPServers } from '@renderer/hooks/useMCPServers'
import { useMCPServerTrust } from '@renderer/hooks/useMCPServerTrust'
import MCPDescription from '@renderer/pages/settings/MCPSettings/McpDescription'
import { MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types'
import { formatMcpError } from '@renderer/utils/error'
@ -81,6 +82,7 @@ const McpSettings: React.FC = () => {
const decodedServerId = serverId ? decodeURIComponent(serverId) : ''
const server = useMCPServer(decodedServerId).server as MCPServer
const { deleteMCPServer, updateMCPServer } = useMCPServers()
const { ensureServerTrusted } = useMCPServerTrust()
const [serverType, setServerType] = useState<MCPServer['type']>('stdio')
const [form] = Form.useForm<MCPFormValues>()
const [loading, setLoading] = useState(false)
@ -266,6 +268,7 @@ const McpSettings: React.FC = () => {
// set basic fields
const mcpServer: MCPServer = {
...server,
id: server.id,
name: values.name,
type: values.serverType || server.type,
@ -401,34 +404,43 @@ const McpSettings: React.FC = () => {
}
await form.validateFields()
setLoadingServer(server.id)
const oldActiveState = server.isActive
let serverForUpdate = server
if (active) {
const trustedServer = await ensureServerTrusted(server)
if (!trustedServer) {
return
}
serverForUpdate = trustedServer
}
setLoadingServer(serverForUpdate.id)
const oldActiveState = serverForUpdate.isActive
try {
if (active) {
const localTools = await window.api.mcp.listTools(server)
const localTools = await window.api.mcp.listTools(serverForUpdate)
setTools(localTools)
const localPrompts = await window.api.mcp.listPrompts(server)
const localPrompts = await window.api.mcp.listPrompts(serverForUpdate)
setPrompts(localPrompts)
const localResources = await window.api.mcp.listResources(server)
const localResources = await window.api.mcp.listResources(serverForUpdate)
setResources(localResources)
const version = await window.api.mcp.getServerVersion(server)
const version = await window.api.mcp.getServerVersion(serverForUpdate)
setServerVersion(version)
} else {
await window.api.mcp.stopServer(server)
await window.api.mcp.stopServer(serverForUpdate)
setServerVersion(null)
}
updateMCPServer({ ...server, isActive: active })
updateMCPServer({ ...serverForUpdate, isActive: active })
} catch (error: any) {
window.modal.error({
title: t('settings.mcp.startError'),
content: formatMcpError(error as McpError),
centered: true
})
updateMCPServer({ ...server, isActive: oldActiveState })
updateMCPServer({ ...serverForUpdate, isActive: oldActiveState })
} finally {
setLoadingServer(null)
}

View File

@ -0,0 +1,33 @@
import React from 'react'
interface ProtocolInstallWarningContentProps {
message: string
commandLabel: string
commandPreview: string
}
/**
* Warning content component for protocol-installed MCP servers
* Displays a security warning and the command that will be executed
*/
const ProtocolInstallWarningContent: React.FC<ProtocolInstallWarningContentProps> = ({
message,
commandLabel,
commandPreview
}) => {
return (
<div className="space-y-3 text-left">
<p>{message}</p>
{commandPreview && (
<div className="space-y-1">
<div className="font-semibold">{commandLabel}</div>
<pre className="whitespace-pre-wrap break-all rounded-md bg-[var(--color-fill-secondary)] p-2">
{commandPreview}
</pre>
</div>
)}
</div>
)
}
export default ProtocolInstallWarningContent

View File

@ -0,0 +1,58 @@
import { loggerService } from '@logger'
import { MCPServer } from '@renderer/types'
const logger = loggerService.withContext('MCPSettings/utils')
/**
* Get command preview string from MCP server configuration
* @param server - The MCP server to extract command from
* @returns Formatted command string with arguments
*/
export const getCommandPreview = (server: MCPServer): string => {
return [server.command, ...(server.args ?? [])]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join(' ')
}
/**
* Ensures a server is trusted before proceeding (pure logic, no UI)
* @param currentServer - The server to verify trust for
* @param requestConfirm - Callback to request user confirmation
* @param updateServer - Callback to update server state
* @returns The trusted server if confirmed, or null if user declined
*/
export async function ensureServerTrusted(
currentServer: MCPServer,
requestConfirm: (server: MCPServer) => Promise<boolean>,
updateServer: (server: MCPServer) => void
): Promise<MCPServer | null> {
const isProtocolInstall = currentServer.installSource === 'protocol'
logger.silly('ensureServerTrusted', {
serverId: currentServer.id,
installSource: currentServer.installSource,
isTrusted: currentServer.isTrusted
})
// Early return if no trust verification needed
if (!isProtocolInstall || currentServer.isTrusted) {
return currentServer
}
// Request user confirmation via callback
const confirmed = await requestConfirm(currentServer)
if (!confirmed) {
return null
}
// Update server with trust information
const trustedServer = {
...currentServer,
installSource: 'protocol' as const,
isTrusted: true,
trustedAt: Date.now()
}
updateServer(trustedServer)
return trustedServer
}

View File

@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 168,
version: 169,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
migrate
},

View File

@ -79,7 +79,9 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
command: 'npx',
args: ['-y', '@mcpmarket/mcp-auto-install', 'connect', '--json'],
isActive: false,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
@ -91,14 +93,18 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
MEMORY_FILE_PATH: 'YOUR_MEMORY_FILE_PATH'
},
shouldConfig: true,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
name: BuiltinMCPServerNames.sequentialThinking,
type: 'inMemory',
isActive: true,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
@ -109,14 +115,18 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
BRAVE_API_KEY: 'YOUR_API_KEY'
},
shouldConfig: true,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
name: BuiltinMCPServerNames.fetch,
type: 'inMemory',
isActive: true,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
@ -125,7 +135,9 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
args: ['/Users/username/Desktop', '/path/to/other/allowed/dir'],
shouldConfig: true,
isActive: false,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
@ -136,14 +148,18 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
DIFY_KEY: 'YOUR_DIFY_KEY'
},
shouldConfig: true,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
name: BuiltinMCPServerNames.python,
type: 'inMemory',
isActive: false,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
@ -155,7 +171,9 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
DIDI_API_KEY: 'YOUR_DIDI_API_KEY'
},
shouldConfig: true,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
}
] as const

View File

@ -24,6 +24,7 @@ import { defaultPreprocessProviders } from '@renderer/store/preprocess'
import {
Assistant,
BuiltinOcrProvider,
isBuiltinMCPServer,
isSystemProvider,
Model,
Provider,
@ -2767,6 +2768,23 @@ const migrateConfig = {
logger.error('migrate 168 error', error as Error)
return state
}
},
'169': (state: RootState) => {
try {
if (state?.mcp?.servers) {
state.mcp.servers = state.mcp.servers.map((server) => {
const inferredSource = isBuiltinMCPServer(server) ? 'builtin' : 'unknown'
return {
...server,
installSource: inferredSource
}
})
}
return state
} catch (error) {
logger.error('migrate 169 error', error as Error)
return state
}
}
}

View File

@ -11,7 +11,7 @@ import type { StreamTextParams } from './aiCoreTypes'
import type { Chunk } from './chunk'
import type { FileMetadata } from './file'
import { KnowledgeBase, KnowledgeReference } from './knowledge'
import { MCPConfigSample, McpServerType } from './mcp'
import { MCPConfigSample, MCPServerInstallSource, McpServerType } from './mcp'
import type { Message } from './newMessage'
import type { BaseTool, MCPTool } from './tool'
@ -697,6 +697,14 @@ export interface MCPServer {
shouldConfig?: boolean
/** 用于标记服务器是否运行中 */
isActive: boolean
/** 标记 MCP 安装来源,例如 builtin/manual/protocol */
installSource?: MCPServerInstallSource
/** 指示用户是否已信任该 MCP */
isTrusted?: boolean
/** 首次标记为信任的时间戳 */
trustedAt?: number
/** 安装时间戳 */
installedAt?: number
}
export type BuiltinMCPServer = MCPServer & {

View File

@ -27,6 +27,9 @@ export const McpServerTypeSchema = z
})
.pipe(z.union([z.literal('stdio'), z.literal('sse'), z.literal('streamableHttp'), z.literal('inMemory')])) // 大多数情况下默认使用 stdio
export const MCPServerInstallSourceSchema = z.enum(['builtin', 'manual', 'protocol', 'unknown']).default('unknown')
export type MCPServerInstallSource = z.infer<typeof MCPServerInstallSourceSchema>
/**
* MCP
* FIXME: 为了兼容性
@ -168,7 +171,11 @@ export const McpServerConfigSchema = z
*
*
*/
isActive: z.boolean().optional().describe('Whether the server is active')
isActive: z.boolean().optional().describe('Whether the server is active'),
installSource: MCPServerInstallSourceSchema.optional().describe('Where the MCP server was installed from'),
isTrusted: z.boolean().optional().describe('Whether the MCP server has been trusted by user'),
trustedAt: z.number().optional().describe('Timestamp when the server was trusted'),
installedAt: z.number().optional().describe('Timestamp when the server was installed')
})
.strict()
// 在这里定义额外的校验逻辑