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 { McpServer } from "../../types";
|
||||||
import { buttonStyles, inputStyles } from "../../lib/styles";
|
import { buttonStyles, inputStyles } from "../../lib/styles";
|
||||||
import McpWizardModal from "./McpWizardModal";
|
import McpWizardModal from "./McpWizardModal";
|
||||||
|
import { extractErrorMessage } from "../../utils/errorUtils";
|
||||||
|
|
||||||
interface McpFormModalProps {
|
interface McpFormModalProps {
|
||||||
editingId?: string;
|
editingId?: string;
|
||||||
@@ -53,7 +54,41 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
|
|
||||||
const handleJsonChange = (value: string) => {
|
const handleJsonChange = (value: string) => {
|
||||||
setFormJson(value);
|
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) => {
|
const handleWizardApply = (json: string) => {
|
||||||
@@ -81,6 +116,16 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
if (formJson.trim()) {
|
if (formJson.trim()) {
|
||||||
// 解析 JSON 配置
|
// 解析 JSON 配置
|
||||||
server = JSON.parse(formJson) as McpServer;
|
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 {
|
} else {
|
||||||
// 空 JSON 时提供默认值(注意:后端会校验 stdio 需要非空 command / http 需要 url)
|
// 空 JSON 时提供默认值(注意:后端会校验 stdio 需要非空 command / http 需要 url)
|
||||||
server = {
|
server = {
|
||||||
@@ -98,8 +143,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
// 显式等待父组件保存流程,以便正确处理成功/失败
|
// 显式等待父组件保存流程,以便正确处理成功/失败
|
||||||
await onSave(formId.trim(), server);
|
await onSave(formId.trim(), server);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// 将后端错误信息直接提示给用户(例如缺少 command/url 等)
|
// 提取后端错误信息(支持 string / {message} / tauri payload)
|
||||||
const msg = error?.message || t("mcp.error.saveFailed");
|
const detail = extractErrorMessage(error);
|
||||||
|
const msg = detail || t("mcp.error.saveFailed");
|
||||||
alert(msg);
|
alert(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { McpServer, McpStatus } from "../../types";
|
|||||||
import McpListItem from "./McpListItem";
|
import McpListItem from "./McpListItem";
|
||||||
import McpFormModal from "./McpFormModal";
|
import McpFormModal from "./McpFormModal";
|
||||||
import { ConfirmDialog } from "../ConfirmDialog";
|
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 {
|
interface McpPanelProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -63,10 +67,16 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
|
|||||||
const handleToggle = async (id: string, enabled: boolean) => {
|
const handleToggle = async (id: string, enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
const server = servers[id];
|
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 as McpServer);
|
||||||
await window.api.upsertClaudeMcpServer(id, updatedServer);
|
|
||||||
await reload();
|
await reload();
|
||||||
onNotify?.(
|
onNotify?.(
|
||||||
enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"),
|
enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"),
|
||||||
@@ -74,7 +84,12 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
|
|||||||
1500,
|
1500,
|
||||||
);
|
);
|
||||||
} catch (e: any) {
|
} 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);
|
setConfirmDialog(null);
|
||||||
onNotify?.(t("mcp.msg.deleted"), "success", 1500);
|
onNotify?.(t("mcp.msg.deleted"), "success", 1500);
|
||||||
} catch (e: any) {
|
} 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);
|
setEditingId(null);
|
||||||
onNotify?.(t("mcp.msg.saved"), "success", 1500);
|
onNotify?.(t("mcp.msg.saved"), "success", 1500);
|
||||||
} catch (e: any) {
|
} 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;
|
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">
|
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
{t("mcp.loading")}
|
{t("mcp.loading")}
|
||||||
</div>
|
</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="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">
|
<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
|
<Server
|
||||||
@@ -192,11 +225,15 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
|
|||||||
{t("mcp.emptyDescription")}
|
{t("mcp.emptyDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* 已安装 */}
|
||||||
{serverEntries.map(([id, server]) => (
|
{serverEntries.map(([id, server]) => (
|
||||||
<McpListItem
|
<McpListItem
|
||||||
key={id}
|
key={`installed-${id}`}
|
||||||
id={id}
|
id={id}
|
||||||
server={server}
|
server={server}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
@@ -204,7 +241,46 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
|
|||||||
onDelete={handleDelete}
|
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>
|
||||||
|
<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>
|
||||||
</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",
|
"idRequired": "Please enter identifier",
|
||||||
"jsonInvalid": "Invalid JSON format",
|
"jsonInvalid": "Invalid JSON format",
|
||||||
"commandRequired": "Please enter command",
|
"commandRequired": "Please enter command",
|
||||||
|
"singleServerObjectRequired": "Please paste a single MCP server object (do not include top-level mcpServers)",
|
||||||
"saveFailed": "Save failed",
|
"saveFailed": "Save failed",
|
||||||
"deleteFailed": "Delete failed"
|
"deleteFailed": "Delete failed"
|
||||||
},
|
},
|
||||||
@@ -328,6 +329,14 @@
|
|||||||
"confirm": {
|
"confirm": {
|
||||||
"deleteTitle": "Delete MCP Server",
|
"deleteTitle": "Delete MCP Server",
|
||||||
"deleteMessage": "Are you sure you want to delete MCP server \"{{id}}\"? This action cannot be undone."
|
"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": "请填写标识",
|
"idRequired": "请填写标识",
|
||||||
"jsonInvalid": "JSON 格式错误,请检查",
|
"jsonInvalid": "JSON 格式错误,请检查",
|
||||||
"commandRequired": "请填写命令",
|
"commandRequired": "请填写命令",
|
||||||
|
"singleServerObjectRequired": "此处只需单个服务器对象,请不要粘贴包含 mcpServers 的整份配置",
|
||||||
"saveFailed": "保存失败",
|
"saveFailed": "保存失败",
|
||||||
"deleteFailed": "删除失败"
|
"deleteFailed": "删除失败"
|
||||||
},
|
},
|
||||||
@@ -328,6 +329,14 @@
|
|||||||
"confirm": {
|
"confirm": {
|
||||||
"deleteTitle": "删除 MCP 服务器",
|
"deleteTitle": "删除 MCP 服务器",
|
||||||
"deleteMessage": "确定要删除 MCP 服务器 \"{{id}}\" 吗?此操作无法撤销。"
|
"deleteMessage": "确定要删除 MCP 服务器 \"{{id}}\" 吗?此操作无法撤销。"
|
||||||
|
},
|
||||||
|
"presets": {
|
||||||
|
"title": "预设库",
|
||||||
|
"enable": "启用",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"installed": "已安装",
|
||||||
|
"docs": "文档",
|
||||||
|
"requiresEnv": "需要环境变量"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user