Files
CC-Switch/src/components/mcp/McpPanel.tsx
T
Jason 511980e3ea fix(mcp): remove SSE support; keep stdio default when type is omitted
- Backend: reject "sse" in validators; accept missing type as stdio; require url only for http (mcp.rs, claude_mcp.rs)
- Frontend: McpServer.type narrowed to "stdio" | "http" (optional) (src/types.ts)
- UI: avoid undefined in list item details (McpListItem)
- Claude-only sync after delete to update ~/.claude.json (commands.rs)

Notes:
- Ran typecheck and cargo check: both pass
- Clippy shows advisory warnings unrelated to this change
- Prettier check warns on a few files; limited scope changes kept minimal
2025-10-09 22:02:56 +08:00

326 lines
11 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.
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Plus, Server } from "lucide-react";
import { McpServer, McpStatus } from "../../types";
import McpListItem from "./McpListItem";
import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog";
import { extractErrorMessage } from "../../utils/errorUtils";
import { mcpPresets } from "../../config/mcpPresets";
import McpToggle from "./McpToggle";
import { cardStyles, cn } from "../../lib/styles";
interface McpPanelProps {
onClose: () => void;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
}
/**
* MCP 管理面板
* 采用与主界面一致的设计风格,右上角添加按钮,每个 MCP 占一行
*/
const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
const { t } = useTranslation();
const [status, setStatus] = useState<McpStatus | null>(null);
const [servers, setServers] = useState<Record<string, McpServer>>({});
const [loading, setLoading] = useState(true);
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
} | null>(null);
const reload = async () => {
setLoading(true);
try {
const cfg = await window.api.getMcpConfig("claude");
setStatus({
userConfigPath: cfg.configPath,
userConfigExists: true,
serverCount: Object.keys(cfg.servers || {}).length,
});
setServers(cfg.servers || {});
} finally {
setLoading(false);
}
};
useEffect(() => {
const setup = async () => {
try {
// 先从 ~/.claude.json 导入已存在的 MCP(设为 enabled=true
await window.api.importMcpFromClaude();
// 读取现有 config.json 内容
const cfg = await window.api.getMcpConfig("claude");
const existing = cfg.servers || {};
// 将预设落库为禁用(若缺失)
const missing = mcpPresets.filter((p) => !existing[p.id]);
for (const p of missing) {
const seed: McpServer = {
...(p.server as McpServer),
enabled: false,
source: "preset",
} as unknown as McpServer;
await window.api.upsertMcpServerInConfig("claude", p.id, seed);
}
} catch (e) {
console.warn("MCP 初始化导入/落库失败(忽略继续)", e);
} finally {
await reload();
}
};
setup();
}, []);
const handleToggle = async (id: string, enabled: boolean) => {
try {
const server = servers[id];
if (!server) {
const preset = mcpPresets.find((p) => p.id === id);
if (!preset) return;
await window.api.upsertMcpServerInConfig("claude", id, preset.server as McpServer);
}
await window.api.setMcpEnabled("claude", id, enabled);
await reload();
onNotify?.(
enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"),
"success",
1500,
);
} catch (e: any) {
const detail = extractErrorMessage(e);
onNotify?.(
detail || t("mcp.error.saveFailed"),
"error",
detail ? 6000 : 5000,
);
}
};
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
const handleDelete = (id: string) => {
setConfirmDialog({
isOpen: true,
title: t("mcp.confirm.deleteTitle"),
message: t("mcp.confirm.deleteMessage", { id }),
onConfirm: async () => {
try {
await window.api.deleteMcpServerInConfig("claude", id);
await reload();
setConfirmDialog(null);
onNotify?.(t("mcp.msg.deleted"), "success", 1500);
} catch (e: any) {
const detail = extractErrorMessage(e);
onNotify?.(
detail || t("mcp.error.deleteFailed"),
"error",
detail ? 6000 : 5000,
);
}
},
});
};
const handleSave = async (id: string, server: McpServer) => {
try {
await window.api.upsertMcpServerInConfig("claude", id, server);
await reload();
setIsFormOpen(false);
setEditingId(null);
onNotify?.(t("mcp.msg.saved"), "success", 1500);
} catch (e: any) {
const detail = extractErrorMessage(e);
onNotify?.(
detail || t("mcp.error.saveFailed"),
"error",
detail ? 6000 : 5000,
);
// 继续抛出错误,让表单层可以给到直观反馈(避免被更高层遮挡)
throw e;
}
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingId(null);
};
const serverEntries = useMemo(() => Object.entries(servers), [servers]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-4xl w-full mx-4 overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex-shrink-0 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{t("mcp.title")}
</h3>
<div className="flex items-center gap-3">
<button
onClick={handleAdd}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700 transition-colors"
>
<Plus size={16} />
{t("mcp.add")}
</button>
<button
onClick={onClose}
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Info Section */}
<div className="flex-shrink-0 px-6 pt-4 pb-2">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t("mcp.configPath")}:{" "}
<span className="text-xs break-all">{status?.userConfigPath}</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t("mcp.serverCount", { count: status?.serverCount || 0 })}
</div>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")}
</div>
) : (
(() => {
const notInstalledPresets = mcpPresets.filter(
(p) => !servers[p.id],
);
const hasAny =
serverEntries.length > 0 || notInstalledPresets.length > 0;
if (!hasAny) {
return (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("mcp.empty")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
);
}
return (
<div className="space-y-3">
{/* 已安装 */}
{serverEntries.map(([id, server]) => (
<McpListItem
key={`installed-${id}`}
id={id}
server={server}
onToggle={handleToggle}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
{/* 预设(未安装) */}
{notInstalledPresets.map((p) => {
const s = {
...(p.server as McpServer),
enabled: false,
} as McpServer;
const details = [s.type, s.command, ...(s.args || [])].join(
" · ",
);
return (
<div
key={`preset-${p.id}`}
className={cn(
cardStyles.interactive,
"!p-4 opacity-95",
)}
>
<div className="flex items-center gap-4">
<div className="flex-shrink-0">
<McpToggle
enabled={false}
onChange={(en) => handleToggle(p.id, en)}
/>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{p.id}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{details}
</p>
</div>
</div>
</div>
);
})}
</div>
);
})()
)}
</div>
</div>
{/* Form Modal */}
{isFormOpen && (
<McpFormModal
editingId={editingId || undefined}
initialData={editingId ? servers[editingId] : undefined}
onSave={handleSave}
onClose={handleCloseForm}
/>
)}
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</div>
);
};
export default McpPanel;