478 lines
17 KiB
TypeScript
478 lines
17 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|