/** * MCPSettings — MCP 服务管理 Tab * * 从 SettingsModal.tsx 提取(v0.4.1 拆分)。 * 功能:MCP Server 列表/连接/断开/移除(Dialog 二次确认)/添加。 */ import { useState, useEffect, useCallback } from 'react'; import { Dialog, DialogContent, DialogTitle, DialogActions, Button, TextField, Stack, Typography, Box, Alert, } from '@mui/material'; // v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import) import { t } from '@renderer/lib/i18n'; import '@renderer/lib/i18n-strings'; export function MCPSettings() { const [servers, setServers] = useState< Array<{ name: string; status: string; toolCount: number; error?: string }> >([]); const [showAdd, setShowAdd] = useState(false); const [newName, setNewName] = useState(''); const [newCommand, setNewCommand] = useState(''); const [newArgs, setNewArgs] = useState(''); // v0.4.1: 新增传输方式选择(stdio / streamable-http)与 URL 字段 const [newTransport, setNewTransport] = useState<'stdio' | 'streamable-http'>('stdio'); const [newUrl, setNewUrl] = useState(''); // v0.7.2 P2-8: 远程传输自定义请求头(JSON 对象字符串,如 {"Authorization": "Bearer xxx"}) const [newHeaders, setNewHeaders] = useState(''); // L-11 修复: 用 MUI Dialog 替换浏览器原生 confirm(),保持 UI 一致性 const [confirmRemove, setConfirmRemove] = useState(null); // L-20 修复: loadServers 改为多行 async/await 写法,提升可读性 const loadServers = useCallback(async () => { if (!window.metona?.mcp?.listServers) return; try { const list = await window.metona.mcp.listServers(); setServers(list as MetonaMCPServerStatus[]); } catch (err) { console.error('[MCPSettings]', err); } }, []); // 审计补充修复: useEffect 添加 cancelled 标志,防止卸载后 setState useEffect(() => { let cancelled = false; (async () => { if (!window.metona?.mcp?.listServers) return; try { const list = await window.metona.mcp.listServers(); if (!cancelled) setServers(list as MetonaMCPServerStatus[]); } catch (err) { if (!cancelled) console.error('[MCPSettings]', err); } })(); return () => { cancelled = true; }; }, []); const handleAdd = async () => { // v0.4.1: 按传输方式校验必填字段(stdio→命令,streamable-http→URL) if (!newName.trim()) return; if (newTransport === 'stdio' && !newCommand.trim()) return; if (newTransport === 'streamable-http' && !newUrl.trim()) return; // v0.7.2 P2-8: 自定义请求头解析 —— 留空视为匿名连接; // 非空时必须是 JSON 对象(值统一转为字符串,最终由 IPC 层逐项校验) let headers: Record | undefined; if (newTransport !== 'stdio' && newHeaders.trim()) { try { const parsed: unknown = JSON.parse(newHeaders.trim()); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(t('settings.mcp.headersInvalid')); } headers = Object.fromEntries( Object.entries(parsed as Record).map(([k, v]) => [k, String(v)]), ); } catch { import('@metona-team/metona-toast') .then((mod) => mod.default.error(t('settings.mcp.headersInvalid'))) .catch(() => {}); return; } } try { const config = newTransport === 'stdio' ? { name: newName.trim(), transport: 'stdio' as const, command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [], enabled: true, } : { name: newName.trim(), transport: 'streamable-http' as const, url: newUrl.trim(), headers, enabled: true, }; const r = await window.metona?.mcp?.addServer(config); if (r?.success) { setNewName(''); setNewCommand(''); setNewArgs(''); setNewUrl(''); setNewHeaders(''); setNewTransport('stdio'); setShowAdd(false); loadServers(); } else { import('@metona-team/metona-toast') .then((mod) => mod.default.error(r?.error ?? t('settings.mcp.addFailed'))) .catch(() => {}); } } catch (err) { console.error('[MCPSettings]', err); import('@metona-team/metona-toast') .then((mod) => mod.default.error(t('settings.mcp.addFailedMsg', { message: (err as Error).message })), ) .catch(() => {}); } }; const statusColors: Record = { connected: 'success.main', connecting: 'warning.main', // v0.7.3 P4-2: reconnecting —— 自动重连排程中(信息态,非故障) reconnecting: 'info.main', disconnected: 'text.secondary', error: 'error.main', }; /** v0.7.3 P4-2: reconnecting 状态显示第 N/3 次尝试 */ const statusLabel = (s: { status: string; reconnectAttempt?: number }): string => s.status === 'reconnecting' && s.reconnectAttempt ? `${s.status} (${s.reconnectAttempt}/3)` : s.status; // L-11 修复: 确认移除 MCP 服务 // 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭 const [removeError, setRemoveError] = useState(null); const handleConfirmRemove = async () => { if (!confirmRemove) return; try { setRemoveError(null); const r = await window.metona?.mcp?.removeServer(confirmRemove); if (r?.success) { setConfirmRemove(null); loadServers(); } else { setRemoveError(r?.error ?? t('settings.mcp.removeFailed')); } } catch (err) { setRemoveError((err as Error).message ?? t('settings.mcp.removeFailed')); } }; // v0.8.0 P2-5: Resources / Prompts 发现视图(按 server 名懒加载展开) const [contentsFor, setContentsFor] = useState(null); const [contents, setContents] = useState<{ resources: Array<{ uri: string; name: string; description?: string; mimeType?: string }>; prompts: Array<{ name: string; description?: string; arguments?: Array<{ name: string; description?: string; required?: boolean }>; }>; } | null>(null); const handleToggleContents = async (name: string): Promise => { if (contentsFor === name) { setContentsFor(null); setContents(null); return; } try { const r = await window.metona?.mcp?.listServerContents?.(name); if (r?.success && r.data) { setContentsFor(name); setContents(r.data); } } catch (err) { console.error('[MCPSettings] listServerContents failed:', err); } }; return ( {t('settings.mcp.title')} {servers.length === 0 ? ( {t('settings.mcp.empty')} ) : ( servers.map((s) => ( {s.name} {statusLabel(s)} {/* v0.8.2 P2-7: 禁用态 server 现在也会出现在列表中(后端补齐),显式标注 */} {(s as { enabled?: boolean }).enabled === false && ( {t('settings.mcp.disabled')} )} {s.toolCount > 0 && ( {t('settings.mcp.toolCount', { count: s.toolCount })} )} {/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */} {s.status === 'connected' && ( )} {contentsFor === s.name && contents && ( {contents.resources.length === 0 && contents.prompts.length === 0 && ( {t('settings.mcp.contents.empty')} )} {contents.resources.map((r) => ( 📄 {r.name} · {r.uri} ))} {contents.prompts.map((p) => ( 💬 {p.name} {p.arguments?.length ? ` (${p.arguments.map((a) => a.name).join(', ')})` : ''} ))} )} )) )} {/* L-11 修复: MUI Dialog 替代原生 confirm() */} { setConfirmRemove(null); setRemoveError(null); }} maxWidth="xs" fullWidth > {t('settings.mcp.removeConfirmTitle')} {t('settings.mcp.removeConfirmBody', { name: confirmRemove ?? '' })} {removeError && ( {t('settings.mcp.removeFailedMsg', { message: removeError })} )} {showAdd ? ( setNewName(e.target.value)} placeholder={t('settings.mcp.serverName')} /> {/* v0.4.1: 传输方式选择(stdio 本地命令 / streamable-http 远程) */} {newTransport === 'stdio' ? ( <> setNewCommand(e.target.value)} placeholder={t('settings.mcp.commandPlaceholder')} /> setNewArgs(e.target.value)} placeholder={t('settings.mcp.argsPlaceholder')} /> ) : ( <> setNewUrl(e.target.value)} placeholder={t('settings.mcp.urlPlaceholder')} /> {/* v0.7.2 P2-8: 远程传输自定义请求头(鉴权/网关路由)—— 留空匿名连接 */} setNewHeaders(e.target.value)} placeholder={t('settings.mcp.headersPlaceholder')} multiline minRows={2} slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 11 } } }} /> )} ) : ( )} ); }