diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 4014983..e21e000 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -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 = ({ 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 = ({ 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 = ({ // 显式等待父组件保存流程,以便正确处理成功/失败 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); diff --git a/src/components/mcp/McpPanel.tsx b/src/components/mcp/McpPanel.tsx index 7c861c6..03c3767 100644 --- a/src/components/mcp/McpPanel.tsx +++ b/src/components/mcp/McpPanel.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ onClose, onNotify }) => {
{t("mcp.loading")}
- ) : serverEntries.length === 0 ? ( -
-
- -
-

- {t("mcp.empty")} -

-

- {t("mcp.emptyDescription")} -

-
) : ( -
- {serverEntries.map(([id, server]) => ( - - ))} -
+ (() => { + const notInstalledPresets = mcpPresets.filter( + (p) => !servers[p.id], + ); + const hasAny = + serverEntries.length > 0 || notInstalledPresets.length > 0; + if (!hasAny) { + return ( +
+
+ +
+

+ {t("mcp.empty")} +

+

+ {t("mcp.emptyDescription")} +

+
+ ); + } + + return ( +
+ {/* 已安装 */} + {serverEntries.map(([id, server]) => ( + + ))} + + {/* 预设(未安装) */} + {notInstalledPresets.map((p) => { + const s = { + ...(p.server as McpServer), + enabled: false, + } as McpServer; + const details = [s.type, s.command, ...(s.args || [])].join( + " · ", + ); + return ( +
+
+
+ handleToggle(p.id, en)} + /> +
+
+

+ {p.id} +

+

+ {details} +

+
+
+
+ ); + })} +
+ ); + })() )} diff --git a/src/config/mcpPresets.ts b/src/config/mcpPresets.ts new file mode 100644 index 0000000..a33c2ee --- /dev/null +++ b/src/config/mcpPresets.ts @@ -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; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5b94499..09dc819 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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" } } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index d0bc287..6daf1de 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "需要环境变量" } } }