Files
metona-ai-desktop/src/components/settings/MCPSettings.tsx
T
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

478 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string | null>(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<string, string> | 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<string, unknown>).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<string, string> = {
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<string | null>(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<string | null>(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<void> => {
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 (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t('settings.mcp.title')}
</Typography>
{servers.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>
{t('settings.mcp.empty')}
</Typography>
) : (
servers.map((s) => (
<Box key={s.name}>
<Stack
direction="row"
sx={{
py: 1,
px: 1.5,
borderRadius: 1.5,
bgcolor: 'secondary.main',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Box
sx={{ width: 8, height: 8, borderRadius: '50', bgcolor: statusColors[s.status] }}
/>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{s.name}
</Typography>
<Typography variant="caption" sx={{ color: statusColors[s.status] }}>
{statusLabel(s)}
</Typography>
{/* v0.8.2 P2-7: 禁用态 server 现在也会出现在列表中(后端补齐),显式标注 */}
{(s as { enabled?: boolean }).enabled === false && (
<Typography
variant="caption"
sx={{
color: 'text.disabled',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
px: 0.5,
}}
>
{t('settings.mcp.disabled')}
</Typography>
)}
{s.toolCount > 0 && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('settings.mcp.toolCount', { count: s.toolCount })}
</Typography>
)}
</Stack>
<Stack direction="row" spacing={0.5}>
<Button
size="small"
sx={{ fontSize: 10, minWidth: 40 }}
onClick={async () => {
try {
const r = await window.metona?.mcp?.toggleServer(
s.name,
s.status !== 'connected',
);
if (r?.success) {
loadServers();
} else {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.error(r?.error ?? t('settings.mcp.toggleFailed')),
)
.catch(() => {});
}
} catch (err) {
console.error('[MCPSettings]', err);
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.error(
t('settings.mcp.toggleFailedMsg', { message: (err as Error).message }),
),
)
.catch(() => {});
}
}}
>
{s.status === 'connected'
? t('settings.mcp.disconnect')
: t('settings.mcp.connect')}
</Button>
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
<Button
size="small"
color="error"
sx={{ fontSize: 10, minWidth: 40 }}
onClick={() => setConfirmRemove(s.name)}
>
{t('settings.mcp.remove')}
</Button>
</Stack>
</Stack>
{s.status === 'connected' && (
<Button
size="small"
sx={{ fontSize: 10, mt: 0.5, alignSelf: 'flex-start' }}
onClick={() => void handleToggleContents(s.name)}
>
{contentsFor === s.name
? t('settings.mcp.contents.hide')
: t('settings.mcp.contents.show')}
</Button>
)}
{contentsFor === s.name && contents && (
<Stack spacing={0.5} sx={{ mt: 0.5, pl: 1 }}>
{contents.resources.length === 0 && contents.prompts.length === 0 && (
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 11 }}>
{t('settings.mcp.contents.empty')}
</Typography>
)}
{contents.resources.map((r) => (
<Typography
key={r.uri}
variant="caption"
noWrap
sx={{ fontSize: 11, color: 'text.secondary' }}
title={`${r.uri}${r.description ?? ''}`}
>
📄 {r.name} · {r.uri}
</Typography>
))}
{contents.prompts.map((p) => (
<Typography
key={p.name}
variant="caption"
noWrap
sx={{ fontSize: 11, color: 'text.secondary' }}
title={p.description ?? ''}
>
💬 {p.name}
{p.arguments?.length ? ` (${p.arguments.map((a) => a.name).join(', ')})` : ''}
</Typography>
))}
</Stack>
)}
</Box>
))
)}
{/* L-11 修复: MUI Dialog 替代原生 confirm() */}
<Dialog
open={confirmRemove !== null}
onClose={() => {
setConfirmRemove(null);
setRemoveError(null);
}}
maxWidth="xs"
fullWidth
>
<DialogTitle>{t('settings.mcp.removeConfirmTitle')}</DialogTitle>
<DialogContent>
<Typography variant="body2">
{t('settings.mcp.removeConfirmBody', { name: confirmRemove ?? '' })}
</Typography>
{removeError && (
<Alert severity="error" sx={{ mt: 1, fontSize: 12 }}>
{t('settings.mcp.removeFailedMsg', { message: removeError })}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setConfirmRemove(null);
setRemoveError(null);
}}
color="inherit"
>
{t('common.cancel')}
</Button>
<Button onClick={handleConfirmRemove} color="error" variant="contained">
{t('settings.mcp.remove')}
</Button>
</DialogActions>
</Dialog>
{showAdd ? (
<Stack
spacing={1}
sx={{
p: 1.5,
borderRadius: 1.5,
bgcolor: 'secondary.main',
border: '1px solid',
borderColor: 'divider',
}}
>
<TextField
size="small"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('settings.mcp.serverName')}
/>
{/* v0.4.1: 传输方式选择(stdio 本地命令 / streamable-http 远程) */}
<Stack direction="row" spacing={1}>
<Button
variant={newTransport === 'stdio' ? 'contained' : 'outlined'}
size="small"
onClick={() => setNewTransport('stdio')}
sx={{ flex: 1 }}
>
{t('settings.mcp.localStdio')}
</Button>
<Button
variant={newTransport === 'streamable-http' ? 'contained' : 'outlined'}
size="small"
onClick={() => setNewTransport('streamable-http')}
sx={{ flex: 1 }}
>
{t('settings.mcp.remoteHttp')}
</Button>
</Stack>
{newTransport === 'stdio' ? (
<>
<TextField
size="small"
value={newCommand}
onChange={(e) => setNewCommand(e.target.value)}
placeholder={t('settings.mcp.commandPlaceholder')}
/>
<TextField
size="small"
value={newArgs}
onChange={(e) => setNewArgs(e.target.value)}
placeholder={t('settings.mcp.argsPlaceholder')}
/>
</>
) : (
<>
<TextField
size="small"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder={t('settings.mcp.urlPlaceholder')}
/>
{/* v0.7.2 P2-8: 远程传输自定义请求头(鉴权/网关路由)—— 留空匿名连接 */}
<TextField
size="small"
value={newHeaders}
onChange={(e) => setNewHeaders(e.target.value)}
placeholder={t('settings.mcp.headersPlaceholder')}
multiline
minRows={2}
slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 11 } } }}
/>
</>
)}
<Stack direction="row" spacing={1}>
<Button
variant="contained"
size="small"
onClick={handleAdd}
disabled={
!newName.trim() || (newTransport === 'stdio' ? !newCommand.trim() : !newUrl.trim())
}
sx={{ flex: 1 }}
>
{t('settings.mcp.add')}
</Button>
<Button
variant="outlined"
size="small"
onClick={() => setShowAdd(false)}
sx={{ flex: 1 }}
>
{t('common.cancel')}
</Button>
</Stack>
</Stack>
) : (
<Button variant="outlined" fullWidth size="small" onClick={() => setShowAdd(true)}>
{t('settings.mcp.addServer')}
</Button>
)}
</Stack>
);
}