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

@@ -4,6 +4,7 @@ import { X, Save, AlertCircle } from "lucide-react";
import { McpServer } from "../../types";
import { buttonStyles, inputStyles } from "../../lib/styles";
import McpWizardModal from "./McpWizardModal";
import { extractErrorMessage } from "../../utils/errorUtils";
interface McpFormModalProps {
editingId?: string;
@@ -53,7 +54,41 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const handleJsonChange = (value: string) => {
setFormJson(value);
setJsonError(validateJson(value));
// 基础 JSON 校验
const baseErr = validateJson(value);
if (baseErr) {
setJsonError(baseErr);
return;
}
// 进一步结构校验:仅允许单个服务器对象,禁止整份配置
if (value.trim()) {
try {
const obj = JSON.parse(value);
if (obj && typeof obj === "object") {
if (Object.prototype.hasOwnProperty.call(obj, "mcpServers")) {
setJsonError(t("mcp.error.singleServerObjectRequired"));
return;
}
// 若带有类型,做必填字段提示(不阻止输入,仅给出即时反馈)
const typ = (obj as any)?.type;
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
setJsonError(t("mcp.error.commandRequired"));
return;
}
if (typ === "http" && !(obj as any)?.url?.trim()) {
setJsonError(t("mcp.wizard.urlRequired"));
return;
}
}
} catch {
// 解析异常已在基础校验覆盖
}
}
setJsonError("");
};
const handleWizardApply = (json: string) => {
@@ -81,6 +116,16 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
if (formJson.trim()) {
// 解析 JSON 配置
server = JSON.parse(formJson) as McpServer;
// 前置必填校验,避免后端拒绝后才提示
if (server?.type === "stdio" && !server?.command?.trim()) {
alert(t("mcp.error.commandRequired"));
return;
}
if (server?.type === "http" && !server?.url?.trim()) {
alert(t("mcp.wizard.urlRequired"));
return;
}
} else {
// 空 JSON 时提供默认值(注意:后端会校验 stdio 需要非空 command / http 需要 url
server = {
@@ -98,8 +143,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
// 显式等待父组件保存流程,以便正确处理成功/失败
await onSave(formId.trim(), server);
} catch (error: any) {
// 后端错误信息直接提示给用户(例如缺少 command/url 等
const msg = error?.message || t("mcp.error.saveFailed");
// 提取后端错误信息(支持 string / {message} / tauri payload
const detail = extractErrorMessage(error);
const msg = detail || t("mcp.error.saveFailed");
alert(msg);
} finally {
setSaving(false);

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,7 +202,15 @@ 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 ? (
) : (
(() => {
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
@@ -192,11 +225,15 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
{t("mcp.emptyDescription")}
</p>
</div>
) : (
);
}
return (
<div className="space-y-3">
{/* 已安装 */}
{serverEntries.map(([id, server]) => (
<McpListItem
key={id}
key={`installed-${id}`}
id={id}
server={server}
onToggle={handleToggle}
@@ -204,7 +241,46 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
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>

38
src/config/mcpPresets.ts Normal file
View File

@@ -0,0 +1,38 @@
import { McpServer } from "../types";
export type McpPreset = {
id: string;
name: string;
description: string;
tags?: string[];
server: McpServer;
homepage?: string;
docs?: string;
requiresEnv?: string[];
};
// 预设库(数据文件,当前未接入 UI便于后续“一键启用”
export const mcpPresets: McpPreset[] = [
{
id: "github-issues",
name: "GitHub Issues",
description: "查询与管理 GitHub Issues示例",
tags: ["productivity", "dev"],
server: { type: "http", url: "https://mcp.example.com/github-issues" },
docs: "https://example.com/mcp/github-issues",
requiresEnv: ["GITHUB_TOKEN"],
},
{
id: "local-notes",
name: "本地笔记",
description: "访问本地笔记数据库(示例)",
tags: ["local"],
server: {
type: "stdio",
command: "/usr/local/bin/notes-mcp",
args: ["--db", "~/.notes/notes.db"],
},
},
];
export default mcpPresets;

View File

@@ -318,6 +318,7 @@
"idRequired": "Please enter identifier",
"jsonInvalid": "Invalid JSON format",
"commandRequired": "Please enter command",
"singleServerObjectRequired": "Please paste a single MCP server object (do not include top-level mcpServers)",
"saveFailed": "Save failed",
"deleteFailed": "Delete failed"
},
@@ -328,6 +329,14 @@
"confirm": {
"deleteTitle": "Delete MCP Server",
"deleteMessage": "Are you sure you want to delete MCP server \"{{id}}\"? This action cannot be undone."
},
"presets": {
"title": "Presets",
"enable": "Enable",
"enabled": "Enabled",
"installed": "Installed",
"docs": "Docs",
"requiresEnv": "Requires env"
}
}
}

View File

@@ -318,6 +318,7 @@
"idRequired": "请填写标识",
"jsonInvalid": "JSON 格式错误,请检查",
"commandRequired": "请填写命令",
"singleServerObjectRequired": "此处只需单个服务器对象,请不要粘贴包含 mcpServers 的整份配置",
"saveFailed": "保存失败",
"deleteFailed": "删除失败"
},
@@ -328,6 +329,14 @@
"confirm": {
"deleteTitle": "删除 MCP 服务器",
"deleteMessage": "确定要删除 MCP 服务器 \"{{id}}\" 吗?此操作无法撤销。"
},
"presets": {
"title": "预设库",
"enable": "启用",
"enabled": "已启用",
"installed": "已安装",
"docs": "文档",
"requiresEnv": "需要环境变量"
}
}
}