feat(mcp): inline presets in panel with one-click enable

- Show not-installed MCP presets directly in the list, consistent with existing UI (no modal)
- Toggle now supports enabling presets by writing to ~/.claude.json (mcpServers) and refreshing list
- Keep installed MCP entries unchanged (edit/delete/toggle)

fix(mcp): robust error handling and pre-submit validation

- Use extractErrorMessage in MCP panel and form to surface backend details
- Prevent pasting full config (with mcpServers) into single-server JSON field
- Add required-field checks: stdio requires non-empty command; http requires non-empty url

i18n: add messages for single-server validation and preset labels

chore: add data-only MCP presets file (no new dependencies)
This commit is contained in:
Jason
2025-10-09 17:21:03 +08:00
parent 2bb847cb3d
commit 0be596afb5
5 changed files with 214 additions and 36 deletions

View File

@@ -5,6 +5,10 @@ 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;
@@ -63,10 +67,16 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
const handleToggle = async (id: string, enabled: boolean) => {
try {
const server = servers[id];
if (!server) return;
let updatedServer: McpServer | null = null;
if (server) {
updatedServer = { ...server, enabled };
} else {
const preset = mcpPresets.find((p) => p.id === id);
if (!preset) return; // 既不是已安装项也不是预设,忽略
updatedServer = { ...(preset.server as McpServer), enabled };
}
const updatedServer = { ...server, enabled };
await window.api.upsertClaudeMcpServer(id, updatedServer);
await window.api.upsertClaudeMcpServer(id, updatedServer as McpServer);
await reload();
onNotify?.(
enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"),
@@ -74,7 +84,12 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
1500,
);
} catch (e: any) {
onNotify?.(e?.message || t("mcp.error.saveFailed"), "error", 5000);
const detail = extractErrorMessage(e);
onNotify?.(
detail || t("mcp.error.saveFailed"),
"error",
detail ? 6000 : 5000,
);
}
};
@@ -100,7 +115,12 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
setConfirmDialog(null);
onNotify?.(t("mcp.msg.deleted"), "success", 1500);
} catch (e: any) {
onNotify?.(e?.message || t("mcp.error.deleteFailed"), "error", 5000);
const detail = extractErrorMessage(e);
onNotify?.(
detail || t("mcp.error.deleteFailed"),
"error",
detail ? 6000 : 5000,
);
}
},
});
@@ -114,7 +134,12 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
setEditingId(null);
onNotify?.(t("mcp.msg.saved"), "success", 1500);
} catch (e: any) {
onNotify?.(e?.message || t("mcp.error.saveFailed"), "error", 6000);
const detail = extractErrorMessage(e);
onNotify?.(
detail || t("mcp.error.saveFailed"),
"error",
detail ? 6000 : 5000,
);
// 继续抛出错误,让表单层可以给到直观反馈(避免被更高层遮挡)
throw e;
}
@@ -177,34 +202,85 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")}
</div>
) : serverEntries.length === 0 ? (
<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>
) : (
<div className="space-y-3">
{serverEntries.map(([id, server]) => (
<McpListItem
key={id}
id={id}
server={server}
onToggle={handleToggle}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</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>