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

@@ -192,13 +192,30 @@ pub fn set_mcp_servers_map(servers: &std::collections::HashMap<String, Value>) -
// 构建 mcpServers 对象:移除 UI 辅助字段enabled/source仅保留实际 MCP 规范
let mut out: Map<String, Value> = Map::new();
for (id, spec) in servers.iter() {
if let Some(mut obj) = spec.as_object().cloned() {
obj.remove("enabled");
obj.remove("source");
out.insert(id.clone(), Value::Object(obj));
let mut obj = if let Some(map) = spec.as_object() {
map.clone()
} else {
return Err(format!("MCP 服务器 '{}' 不是对象", id));
};
if let Some(server_val) = obj.remove("server") {
let server_obj = server_val
.as_object()
.cloned()
.ok_or_else(|| format!("MCP 服务器 '{}' server 字段不是对象", id))?;
obj = server_obj;
}
obj.remove("enabled");
obj.remove("source");
obj.remove("id");
obj.remove("name");
obj.remove("description");
obj.remove("tags");
obj.remove("homepage");
obj.remove("docs");
out.insert(id.clone(), Value::Object(obj));
}
{

View File

@@ -4,9 +4,9 @@ use std::collections::HashMap;
use crate::app_config::{AppType, McpConfig, MultiAppConfig};
/// 基础校验:允许 stdio/http或省略 type视为 stdio。对应必填字段存在
fn validate_mcp_spec(spec: &Value) -> Result<(), String> {
fn validate_server_spec(spec: &Value) -> Result<(), String> {
if !spec.is_object() {
return Err("MCP 服务器定义必须为 JSON 对象".into());
return Err("MCP 服务器连接定义必须为 JSON 对象".into());
}
let t_opt = spec.get("type").and_then(|x| x.as_str());
// 支持两种stdio/http若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
@@ -32,23 +32,99 @@ fn validate_mcp_spec(spec: &Value) -> Result<(), String> {
Ok(())
}
fn validate_mcp_entry(entry: &Value) -> Result<(), String> {
let obj = entry
.as_object()
.ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?;
let server = obj
.get("server")
.ok_or_else(|| "MCP 服务器条目缺少 server 字段".to_string())?;
validate_server_spec(server)?;
for key in ["name", "description", "homepage", "docs"] {
if let Some(val) = obj.get(key) {
if !val.is_string() {
return Err(format!("MCP 服务器 {} 必须为字符串", key));
}
}
}
if let Some(tags) = obj.get("tags") {
let arr = tags
.as_array()
.ok_or_else(|| "MCP 服务器 tags 必须为字符串数组".to_string())?;
if !arr.iter().all(|item| item.is_string()) {
return Err("MCP 服务器 tags 必须为字符串数组".into());
}
}
if let Some(enabled) = obj.get("enabled") {
if !enabled.is_boolean() {
return Err("MCP 服务器 enabled 必须为布尔值".into());
}
}
Ok(())
}
fn extract_server_spec(entry: &Value) -> Result<Value, String> {
let obj = entry
.as_object()
.ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?;
let server = obj
.get("server")
.ok_or_else(|| "MCP 服务器条目缺少 server 字段".to_string())?;
if !server.is_object() {
return Err("MCP 服务器 server 字段必须为 JSON 对象".into());
}
Ok(server.clone())
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
for (id, spec) in cfg.servers.iter() {
let enabled = spec
for (id, entry) in cfg.servers.iter() {
let enabled = entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if enabled {
out.insert(id.clone(), spec.clone());
if !enabled {
continue;
}
match extract_server_spec(entry) {
Ok(spec) => {
out.insert(id.clone(), spec);
}
Err(err) => {
log::warn!("跳过无效的 MCP 条目 '{}': {}", id, err);
}
}
}
out
}
pub fn get_servers_snapshot_for(config: &MultiAppConfig, app: &AppType) -> HashMap<String, Value> {
config.mcp_for(app).servers.clone()
let mut snapshot = config.mcp_for(app).servers.clone();
snapshot.retain(|id, value| {
let Some(obj) = value.as_object_mut() else {
log::warn!("跳过无效的 MCP 条目 '{}': 必须为 JSON 对象", id);
return false;
};
obj.entry(String::from("id")).or_insert(json!(id));
match validate_mcp_entry(value) {
Ok(()) => true,
Err(err) => {
log::error!("config.json 中存在无效的 MCP 条目 '{}': {}", id, err);
false
}
}
});
snapshot
}
pub fn upsert_in_config_for(
@@ -60,16 +136,31 @@ pub fn upsert_in_config_for(
if id.trim().is_empty() {
return Err("MCP 服务器 ID 不能为空".into());
}
validate_mcp_spec(&spec)?;
validate_mcp_entry(&spec)?;
// 默认 enabled 不强制设值;若字段不存在则保持不变(或 UI 决定)
if spec.get("enabled").is_none() {
// 缺省不设,以便后续 set_enabled 独立控制
let mut entry_obj = spec
.as_object()
.cloned()
.ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?;
if let Some(existing_id) = entry_obj.get("id") {
let Some(existing_id_str) = existing_id.as_str() else {
return Err("MCP 服务器 id 必须为字符串".into());
};
if existing_id_str != id {
return Err(format!(
"MCP 服务器条目中的 id '{}' 与参数 id '{}' 不一致",
existing_id_str, id
));
}
} else {
entry_obj.insert(String::from("id"), json!(id));
}
let value = Value::Object(entry_obj);
let servers = &mut config.mcp_for_mut(app).servers;
let before = servers.get(id).cloned();
servers.insert(id.to_string(), spec);
servers.insert(id.to_string(), value);
Ok(before.is_none())
}
@@ -133,28 +224,58 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, String>
let mut changed = 0usize;
for (id, spec) in map.iter() {
// 校验目标 spec
validate_mcp_spec(spec)?;
validate_server_spec(spec)?;
// 规范化为对象
let mut obj = spec.as_object().cloned().ok_or_else(|| "MCP 服务器定义必须为 JSON 对象".to_string())?;
obj.insert("enabled".into(), json!(true));
let entry = config.mcp_for_mut(&AppType::Claude).servers.entry(id.clone());
let entry = config
.mcp_for_mut(&AppType::Claude)
.servers
.entry(id.clone());
use std::collections::hash_map::Entry;
match entry {
Entry::Vacant(vac) => {
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec.clone());
obj.insert(String::from("enabled"), json!(true));
vac.insert(Value::Object(obj));
changed += 1;
}
Entry::Occupied(mut occ) => {
// 只确保 enabled=true不覆盖其他字段
if let Some(mut existing) = occ.get().as_object().cloned() {
let prev = existing.get("enabled").and_then(|b| b.as_bool()).unwrap_or(false);
if !prev {
existing.insert("enabled".into(), json!(true));
occ.insert(Value::Object(existing));
let value = occ.get_mut();
let Some(existing) = value.as_object_mut() else {
log::warn!("MCP 条目 '{}' 不是 JSON 对象,覆盖为导入数据", id);
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec.clone());
obj.insert(String::from("enabled"), json!(true));
occ.insert(Value::Object(obj));
changed += 1;
continue;
};
let mut modified = false;
let prev_enabled = existing
.get("enabled")
.and_then(|b| b.as_bool())
.unwrap_or(false);
if !prev_enabled {
existing.insert(String::from("enabled"), json!(true));
modified = true;
}
if existing.get("server").is_none() {
log::warn!("MCP 条目 '{}' 缺少 server 字段,覆盖为导入数据", id);
existing.insert(String::from("server"), spec.clone());
modified = true;
}
if existing.get("id").is_none() {
log::warn!("MCP 条目 '{}' 缺少 id 字段,自动填充", id);
existing.insert(String::from("id"), json!(id));
modified = true;
}
if modified {
changed += 1;
}
}
}
@@ -246,7 +367,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, String> {
let spec_v = serde_json::Value::Object(spec);
// 校验
if let Err(e) = validate_mcp_spec(&spec_v) {
if let Err(e) = validate_server_spec(&spec_v) {
log::warn!("跳过无效 Codex MCP 项 '{}': {}", id, e);
continue;
}
@@ -259,22 +380,49 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, String> {
.entry(id.clone());
match entry {
Entry::Vacant(vac) => {
let mut obj = spec_v.as_object().cloned().unwrap_or_default();
obj.insert("enabled".into(), json!(true));
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec_v.clone());
obj.insert(String::from("enabled"), json!(true));
vac.insert(serde_json::Value::Object(obj));
changed += 1;
}
Entry::Occupied(mut occ) => {
if let Some(mut existing) = occ.get().as_object().cloned() {
let value = occ.get_mut();
let Some(existing) = value.as_object_mut() else {
log::warn!("MCP 条目 '{}' 不是 JSON 对象,覆盖为导入数据", id);
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec_v.clone());
obj.insert(String::from("enabled"), json!(true));
occ.insert(serde_json::Value::Object(obj));
changed += 1;
continue;
};
let mut modified = false;
let prev = existing
.get("enabled")
.and_then(|b| b.as_bool())
.unwrap_or(false);
if !prev {
existing.insert("enabled".into(), json!(true));
occ.insert(serde_json::Value::Object(existing));
changed += 1;
existing.insert(String::from("enabled"), json!(true));
modified = true;
}
if existing.get("server").is_none() {
log::warn!("MCP 条目 '{}' 缺少 server 字段,覆盖为导入数据", id);
existing.insert(String::from("server"), spec_v.clone());
modified = true;
}
if existing.get("id").is_none() {
log::warn!("MCP 条目 '{}' 缺少 id 字段,自动填充", id);
existing.insert(String::from("id"), json!(id));
modified = true;
}
if modified {
changed += 1;
}
}
}

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,