refactor(mcp): improve data structure with metadata/spec separation

- Separate MCP server metadata from connection spec for cleaner architecture
- Add comprehensive server entry fields: name, description, tags, homepage, docs
- Remove legacy format compatibility logic from extract_server_spec
- Implement data validation and filtering in get_servers_snapshot_for
- Add strict id consistency check in upsert_in_config_for
- Enhance import logic with defensive programming for corrupted data
- Simplify frontend by removing normalization logic (moved to backend)
- Improve error messages with contextual information
- Add comprehensive i18n support for new metadata fields
This commit is contained in:
Jason
2025-10-12 00:08:37 +08:00
parent 668ab710c6
commit fb137c4a78
14 changed files with 477 additions and 115 deletions

View File

@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Save, AlertCircle } from "lucide-react";
import { McpServer } from "../../types";
import { McpServer, McpServerSpec } from "../../types";
import { mcpPresets } from "../../config/mcpPresets";
import { buttonStyles, inputStyles } from "../../lib/styles";
import McpWizardModal from "./McpWizardModal";
@@ -69,19 +69,25 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
return `${t("mcp.error.tomlInvalid")}: ${err}`;
};
const [formId, setFormId] = useState(editingId || "");
const [formDescription, setFormDescription] = useState(
(initialData as any)?.description || "",
const [formId, setFormId] = useState(
() => editingId || initialData?.id || "",
);
const [formName, setFormName] = useState(initialData?.name || "");
const [formDescription, setFormDescription] = useState(
initialData?.description || "",
);
const [formHomepage, setFormHomepage] = useState(initialData?.homepage || "");
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
// 根据 appType 决定初始格式
const [formConfig, setFormConfig] = useState(() => {
if (!initialData) return "";
const spec = initialData?.server;
if (!spec) return "";
if (appType === "codex") {
return mcpServerToToml(initialData);
} else {
return JSON.stringify(initialData, null, 2);
return mcpServerToToml(spec);
}
return JSON.stringify(spec, null, 2);
});
const [configError, setConfigError] = useState("");
@@ -123,7 +129,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const p = mcpPresets[index];
const id = ensureUniqueId(p.id);
setFormId(id);
setFormName(p.name || p.id);
setFormDescription(p.description || "");
setFormHomepage(p.homepage || "");
setFormDocs(p.docs || "");
setFormTags(p.tags?.join(", ") || "");
// 根据格式转换配置
if (useToml) {
@@ -146,7 +156,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setSelectedPreset(-1);
// 恢复到空白模板
setFormId("");
setFormName("");
setFormDescription("");
setFormHomepage("");
setFormDocs("");
setFormTags("");
setFormConfig("");
setConfigError("");
};
@@ -227,10 +241,13 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const handleWizardApply = (title: string, json: string) => {
setFormId(title);
if (!formName.trim()) {
setFormName(title);
}
// Wizard 返回的是 JSON根据格式决定是否需要转换
if (useToml) {
try {
const server = JSON.parse(json) as McpServer;
const server = JSON.parse(json) as McpServerSpec;
const toml = mcpServerToToml(server);
setFormConfig(toml);
const err = validateToml(toml);
@@ -245,19 +262,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
};
const handleSubmit = async () => {
if (!formId.trim()) {
const trimmedId = formId.trim();
if (!trimmedId) {
onNotify?.(t("mcp.error.idRequired"), "error", 3000);
return;
}
// 新增模式:阻止提交重名 ID
if (!isEditing && existingIds.includes(formId.trim())) {
if (!isEditing && existingIds.includes(trimmedId)) {
setIdError(t("mcp.error.idExists"));
return;
}
// 验证配置格式
let server: McpServer;
let serverSpec: McpServerSpec;
if (useToml) {
// TOML 模式
@@ -270,14 +288,14 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
if (!formConfig.trim()) {
// 空配置
server = {
serverSpec = {
type: "stdio",
command: "",
args: [],
};
} else {
try {
server = tomlToMcpServer(formConfig);
serverSpec = tomlToMcpServer(formConfig);
} catch (e: any) {
const msg = e?.message || String(e);
setConfigError(formatTomlError(msg));
@@ -296,14 +314,14 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
if (!formConfig.trim()) {
// 空配置
server = {
serverSpec = {
type: "stdio",
command: "",
args: [],
};
} else {
try {
server = JSON.parse(formConfig) as McpServer;
serverSpec = JSON.parse(formConfig) as McpServerSpec;
} catch (e: any) {
setConfigError(t("mcp.error.jsonInvalid"));
onNotify?.(t("mcp.error.jsonInvalid"), "error", 4000);
@@ -313,29 +331,65 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
// 前置必填校验
if (server?.type === "stdio" && !server?.command?.trim()) {
if (serverSpec?.type === "stdio" && !serverSpec?.command?.trim()) {
onNotify?.(t("mcp.error.commandRequired"), "error", 3000);
return;
}
if (server?.type === "http" && !server?.url?.trim()) {
if (serverSpec?.type === "http" && !serverSpec?.url?.trim()) {
onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000);
return;
}
setSaving(true);
try {
// 保留原有的 enabled 状态
const entry: McpServer = {
...(initialData ? { ...initialData } : {}),
id: trimmedId,
server: serverSpec,
};
if (initialData?.enabled !== undefined) {
server.enabled = initialData.enabled;
entry.enabled = initialData.enabled;
} else if (!initialData) {
delete entry.enabled;
}
// 保存 description 到 server 对象
if (formDescription.trim()) {
(server as any).description = formDescription.trim();
const nameTrimmed = (formName || trimmedId).trim();
entry.name = nameTrimmed || trimmedId;
const descriptionTrimmed = formDescription.trim();
if (descriptionTrimmed) {
entry.description = descriptionTrimmed;
} else {
delete entry.description;
}
const homepageTrimmed = formHomepage.trim();
if (homepageTrimmed) {
entry.homepage = homepageTrimmed;
} else {
delete entry.homepage;
}
const docsTrimmed = formDocs.trim();
if (docsTrimmed) {
entry.docs = docsTrimmed;
} else {
delete entry.docs;
}
const parsedTags = formTags
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
if (parsedTags.length > 0) {
entry.tags = parsedTags;
} else {
delete entry.tags;
}
// 显式等待父组件保存流程
await onSave(formId.trim(), server);
await onSave(trimmedId, entry);
} catch (error: any) {
const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t);
@@ -409,7 +463,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}`}
title={p.description}
>
{p.name || p.id}
{p.id}
</button>
))}
</div>
@@ -436,6 +490,19 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.name")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.namePlaceholder")}
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
{/* Description (描述) */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -449,6 +516,45 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.tags")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.tagsPlaceholder")}
value={formTags}
onChange={(e) => setFormTags(e.target.value)}
/>
</div>
{/* Homepage */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.homepage")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.homepagePlaceholder")}
value={formHomepage}
onChange={(e) => setFormHomepage(e.target.value)}
/>
</div>
{/* Docs */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.docs")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.docsPlaceholder")}
value={formDocs}
onChange={(e) => setFormDocs(e.target.value)}
/>
</div>
{/* 配置输入框(根据格式显示 JSON 或 TOML */}
<div>
<div className="flex items-center justify-between mb-2">

View File

@@ -29,15 +29,19 @@ const McpListItem: React.FC<McpListItemProps> = ({
// 仅当显式为 true 时视为启用;避免 undefined 被误判为启用
const enabled = server.enabled === true;
const name = server.name || id;
// 只显示 description没有则留空
const description = (server as any).description || "";
const description = server.description || "";
// 匹配预设元信息(用于展示文档链接等)
const meta = mcpPresets.find((p) => p.id === id);
const docsUrl = server.docs || meta?.docs;
const homepageUrl = server.homepage || meta?.homepage;
const tags = server.tags || meta?.tags;
const openDocs = async () => {
const url = meta?.docs || meta?.homepage;
const url = docsUrl || homepageUrl;
if (!url) return;
try {
await window.api.openExternal(url);
@@ -60,19 +64,24 @@ const McpListItem: React.FC<McpListItemProps> = ({
{/* 中间:名称和详细信息 */}
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{id}
{name}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{description}
</p>
)}
{!description && tags && tags.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
{tags.join(", ")}
</p>
)}
{/* 预设标记已移除 */}
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
{meta?.docs && (
{docsUrl && (
<button
onClick={openDocs}
className={buttonStyles.ghost}

View File

@@ -137,7 +137,8 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
const handleSave = async (id: string, server: McpServer) => {
try {
await window.api.upsertMcpServerInConfig(appType, id, server);
const payload: McpServer = { ...server, id };
await window.api.upsertMcpServerInConfig(appType, id, payload);
await reload();
setIsFormOpen(false);
setEditingId(null);
@@ -160,7 +161,10 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
setEditingId(null);
};
const serverEntries = useMemo(() => Object.entries(servers), [servers]);
const serverEntries = useMemo(
() => Object.entries(servers) as Array<[string, McpServer]>,
[servers],
);
const panelTitle =
appType === "claude" ? t("mcp.claudeTitle") : t("mcp.codexTitle");

View File

@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Save } from "lucide-react";
import { McpServer } from "../../types";
import { McpServerSpec } from "../../types";
import { isLinux } from "../../lib/platform";
interface McpWizardModalProps {
@@ -86,7 +86,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
// 生成预览 JSON
const generatePreview = (): string => {
const config: McpServer = {
const config: McpServerSpec = {
type: wizardType,
};

View File

@@ -1,44 +1,80 @@
import { McpServer } from "../types";
import { McpServer, McpServerSpec } from "../types";
export type McpPreset = {
id: string;
name: string;
description: string;
tags?: string[];
server: McpServer;
homepage?: string;
docs?: string;
};
export type McpPreset = Omit<McpServer, "enabled">;
// 预设 MCP逻辑简化版
// - 仅包含最常用、可快速落地的 stdio 模式示例
// - 不涉及分类/模板/测速等复杂逻辑,默认以 disabled 形式回种到 config.json
// - 不涉及分类/模板/测速等复杂逻辑,默认以 disabled 形式"回种"到 config.json
// - 用户可在 MCP 面板中一键启用/编辑
export const mcpPresets: McpPreset[] = [
{
id: "fetch",
name: "mcp-server-fetch",
description:
"通用 HTTP Fetchstdio经 uvx 运行 mcp-server-fetch,适合快速请求接口/抓取数据",
tags: ["stdio", "http"],
"通用 HTTP 请求工具,支持 GET/POST 等 HTTP 方法,适合快速请求接口/抓取网页数据",
tags: ["stdio", "http", "web"],
server: {
type: "stdio",
command: "uvx",
args: ["mcp-server-fetch"],
} as McpServer,
} as McpServerSpec,
homepage: "https://github.com/modelcontextprotocol/servers",
docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
},
{
id: "time",
name: "@modelcontextprotocol/server-time",
description:
"时间查询工具,提供当前时间、时区转换、日期计算等功能,完全无需配置",
tags: ["stdio", "time", "utility"],
server: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-time"],
} as McpServerSpec,
homepage: "https://github.com/modelcontextprotocol/servers",
docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
},
{
id: "memory",
name: "@modelcontextprotocol/server-memory",
description:
"知识图谱记忆系统,支持存储实体、关系和观察,让 AI 记住对话中的重要信息",
tags: ["stdio", "memory", "graph"],
server: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-memory"],
} as McpServerSpec,
homepage: "https://github.com/modelcontextprotocol/servers",
docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
},
{
id: "sequential-thinking",
name: "@modelcontextprotocol/server-sequential-thinking",
description: "顺序思考工具,帮助 AI 将复杂问题分解为多个步骤,逐步深入思考",
tags: ["stdio", "thinking", "reasoning"],
server: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-sequential-thinking"],
} as McpServerSpec,
homepage: "https://github.com/modelcontextprotocol/servers",
docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
},
{
id: "context7",
name: "mcp-context7",
description: "Context7 示例(无需环境变量),可按需在表单中调整参数",
tags: ["stdio", "docs"],
name: "@context7/mcp-server",
description:
"Context7 文档搜索工具,提供最新的库文档和代码示例,完全无需配置",
tags: ["stdio", "docs", "search"],
server: {
type: "stdio",
command: "uvx",
// 使用 fetch 服务器作为基础示例,用户可在表单中补充 args
args: ["mcp-server-fetch"],
} as McpServer,
docs: "https://github.com/context7",
command: "npx",
args: ["-y", "@context7/mcp-server"],
} as McpServerSpec,
homepage: "https://context7.com",
docs: "https://github.com/context7/mcp-server",
},
];

View File

@@ -281,8 +281,16 @@
"form": {
"title": "MCP Title (Unique)",
"titlePlaceholder": "my-mcp-server",
"name": "Display Name",
"namePlaceholder": "e.g. @modelcontextprotocol/server-time",
"description": "Description",
"descriptionPlaceholder": "Optional description",
"tags": "Tags (comma separated)",
"tagsPlaceholder": "stdio, time, utility",
"homepage": "Homepage",
"homepagePlaceholder": "https://example.com",
"docs": "Docs",
"docsPlaceholder": "https://example.com/docs",
"jsonConfig": "JSON Configuration",
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
"tomlConfig": "TOML Configuration",

View File

@@ -281,8 +281,16 @@
"form": {
"title": "MCP 标题(唯一)",
"titlePlaceholder": "my-mcp-server",
"name": "显示名称",
"namePlaceholder": "例如 @modelcontextprotocol/server-time",
"description": "描述",
"descriptionPlaceholder": "可选的描述信息",
"tags": "标签(逗号分隔)",
"tagsPlaceholder": "stdio, time, utility",
"homepage": "主页链接",
"homepagePlaceholder": "https://example.com",
"docs": "文档链接",
"docsPlaceholder": "https://example.com/docs",
"jsonConfig": "JSON 配置",
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
"tomlConfig": "TOML 配置",

View File

@@ -6,6 +6,7 @@ import {
CustomEndpoint,
McpStatus,
McpServer,
McpServerSpec,
McpConfigResponse,
} from "../types";
@@ -309,7 +310,7 @@ export const tauriAPI = {
// Claude MCP新增/更新服务器定义
upsertClaudeMcpServer: async (
id: string,
spec: McpServer | Record<string, any>,
spec: McpServerSpec | Record<string, any>,
): Promise<boolean> => {
try {
return await invoke<boolean>("upsert_claude_mcp_server", { id, spec });
@@ -352,7 +353,7 @@ export const tauriAPI = {
upsertMcpServerInConfig: async (
app: AppType = "claude",
id: string,
spec: McpServer | Record<string, any>,
spec: McpServer,
): Promise<boolean> => {
try {
return await invoke<boolean>("upsert_mcp_server_in_config", {

View File

@@ -55,8 +55,8 @@ export interface Settings {
customEndpointsCodex?: Record<string, CustomEndpoint>;
}
// MCP 服务器定义(宽松:允许扩展字段)
export interface McpServer {
// MCP 服务器连接参数(宽松:允许扩展字段)
export interface McpServerSpec {
// 可选:社区常见 .mcp.json 中 stdio 配置可不写 type
type?: "stdio" | "http";
// stdio 字段
@@ -68,7 +68,20 @@ export interface McpServer {
url?: string;
headers?: Record<string, string>;
// 通用字段
enabled?: boolean; // 是否启用该 MCP 服务器,默认 true
[key: string]: any;
}
// MCP 服务器条目(含元信息)
export interface McpServer {
id: string;
name?: string;
description?: string;
tags?: string[];
homepage?: string;
docs?: string;
enabled?: boolean;
server: McpServerSpec;
source?: string;
[key: string]: any;
}

View File

@@ -55,9 +55,19 @@ export const translateMcpBackendError = (
}
if (
msg.includes("MCP 服务器定义必须为 JSON 对象") ||
msg.includes("MCP 服务器条目必须为 JSON 对象") ||
msg.includes("MCP 服务器条目缺少 server 字段") ||
msg.includes("MCP 服务器 server 字段必须为 JSON 对象") ||
msg.includes("MCP 服务器连接定义必须为 JSON 对象") ||
msg.includes("MCP 服务器 '" /* 不是对象 */) ||
msg.includes("不是对象") ||
msg.includes("服务器配置必须是对象")
msg.includes("服务器配置必须是对象") ||
msg.includes("MCP 服务器 name 必须为字符串") ||
msg.includes("MCP 服务器 description 必须为字符串") ||
msg.includes("MCP 服务器 homepage 必须为字符串") ||
msg.includes("MCP 服务器 docs 必须为字符串") ||
msg.includes("MCP 服务器 tags 必须为字符串数组") ||
msg.includes("MCP 服务器 enabled 必须为布尔值")
) {
return t("mcp.error.jsonInvalid");
}

View File

@@ -1,5 +1,5 @@
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { McpServer } from "../types";
import { McpServerSpec } from "../types";
/**
* 验证 TOML 格式并转换为 JSON 对象
@@ -21,10 +21,10 @@ export const validateToml = (text: string): string => {
};
/**
* 将 McpServer 对象转换为 TOML 字符串
* 将 McpServerSpec 对象转换为 TOML 字符串
* 使用 @iarna/toml 的 stringify自动处理转义与嵌套表
*/
export const mcpServerToToml = (server: McpServer): string => {
export const mcpServerToToml = (server: McpServerSpec): string => {
const obj: any = {};
if (server.type) obj.type = server.type;
@@ -49,7 +49,7 @@ export const mcpServerToToml = (server: McpServer): string => {
};
/**
* 将 TOML 文本转换为 McpServer 对象(单个服务器配置)
* 将 TOML 文本转换为 McpServerSpec 对象(单个服务器配置)
* 支持两种格式:
* 1. 直接的服务器配置type, command, args 等)
* 2. [mcp.servers.<id>] 或 [mcp_servers.<id>] 格式(取第一个服务器)
@@ -57,7 +57,7 @@ export const mcpServerToToml = (server: McpServer): string => {
* @returns McpServer 对象
* @throws 解析或转换失败时抛出错误
*/
export const tomlToMcpServer = (tomlText: string): McpServer => {
export const tomlToMcpServer = (tomlText: string): McpServerSpec => {
if (!tomlText.trim()) {
throw new Error("TOML 内容不能为空");
}
@@ -104,7 +104,7 @@ export const tomlToMcpServer = (tomlText: string): McpServer => {
/**
* 规范化服务器配置对象为 McpServer 格式
*/
function normalizeServerConfig(config: any): McpServer {
function normalizeServerConfig(config: any): McpServerSpec {
if (!config || typeof config !== "object") {
throw new Error("服务器配置必须是对象");
}
@@ -116,7 +116,7 @@ function normalizeServerConfig(config: any): McpServer {
throw new Error("stdio 类型的 MCP 服务器必须包含 command 字段");
}
const server: McpServer = {
const server: McpServerSpec = {
type: "stdio",
command: config.command,
};
@@ -142,7 +142,7 @@ function normalizeServerConfig(config: any): McpServer {
throw new Error("http 类型的 MCP 服务器必须包含 url 字段");
}
const server: McpServer = {
const server: McpServerSpec = {
type: "http",
url: config.url,
};

6
src/vite-env.d.ts vendored
View File

@@ -6,6 +6,8 @@ import {
CustomEndpoint,
McpStatus,
McpConfigResponse,
McpServer,
McpServerSpec,
} from "./types";
import { AppType } from "./lib/tauri-api";
import type { UnlistenFn } from "@tauri-apps/api/event";
@@ -72,7 +74,7 @@ declare global {
readClaudeMcpConfig: () => Promise<string | null>;
upsertClaudeMcpServer: (
id: string,
spec: Record<string, any>,
spec: McpServerSpec | Record<string, any>,
) => Promise<boolean>;
deleteClaudeMcpServer: (id: string) => Promise<boolean>;
validateMcpCommand: (cmd: string) => Promise<boolean>;
@@ -81,7 +83,7 @@ declare global {
upsertMcpServerInConfig: (
app: AppType | undefined,
id: string,
spec: Record<string, any>,
spec: McpServer,
) => Promise<boolean>;
deleteMcpServerInConfig: (
app: AppType | undefined,