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:
@@ -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);
|
||||
|
||||
@@ -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
38
src/config/mcpPresets.ts
Normal 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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "需要环境变量"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user