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 规范 // 构建 mcpServers 对象:移除 UI 辅助字段enabled/source仅保留实际 MCP 规范
let mut out: Map<String, Value> = Map::new(); let mut out: Map<String, Value> = Map::new();
for (id, spec) in servers.iter() { for (id, spec) in servers.iter() {
if let Some(mut obj) = spec.as_object().cloned() { let mut obj = if let Some(map) = spec.as_object() {
obj.remove("enabled"); map.clone()
obj.remove("source");
out.insert(id.clone(), Value::Object(obj));
} else { } else {
return Err(format!("MCP 服务器 '{}' 不是对象", id)); 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,15 +4,15 @@ use std::collections::HashMap;
use crate::app_config::{AppType, McpConfig, MultiAppConfig}; use crate::app_config::{AppType, McpConfig, MultiAppConfig};
/// 基础校验:允许 stdio/http或省略 type视为 stdio。对应必填字段存在 /// 基础校验:允许 stdio/http或省略 type视为 stdio。对应必填字段存在
fn validate_mcp_spec(spec: &Value) -> Result<(), String> { fn validate_server_spec(spec: &Value) -> Result<(), String> {
if !spec.is_object() { 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()); let t_opt = spec.get("type").and_then(|x| x.as_str());
// 支持两种stdio/http若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致) // 支持两种stdio/http若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
let is_http = t_opt.map(|t| t == "http").unwrap_or(false); let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
if !(is_stdio || is_http) { if !(is_stdio || is_http) {
return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio".into()); return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio".into());
} }
@@ -32,23 +32,99 @@ fn validate_mcp_spec(spec: &Value) -> Result<(), String> {
Ok(()) 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 /// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> { fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new(); let mut out = HashMap::new();
for (id, spec) in cfg.servers.iter() { for (id, entry) in cfg.servers.iter() {
let enabled = spec let enabled = entry
.get("enabled") .get("enabled")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
if enabled { if !enabled {
out.insert(id.clone(), spec.clone()); continue;
}
match extract_server_spec(entry) {
Ok(spec) => {
out.insert(id.clone(), spec);
}
Err(err) => {
log::warn!("跳过无效的 MCP 条目 '{}': {}", id, err);
}
} }
} }
out out
} }
pub fn get_servers_snapshot_for(config: &MultiAppConfig, app: &AppType) -> HashMap<String, Value> { 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( pub fn upsert_in_config_for(
@@ -60,16 +136,31 @@ pub fn upsert_in_config_for(
if id.trim().is_empty() { if id.trim().is_empty() {
return Err("MCP 服务器 ID 不能为空".into()); return Err("MCP 服务器 ID 不能为空".into());
} }
validate_mcp_spec(&spec)?; validate_mcp_entry(&spec)?;
// 默认 enabled 不强制设值;若字段不存在则保持不变(或 UI 决定) let mut entry_obj = spec
if spec.get("enabled").is_none() { .as_object()
// 缺省不设,以便后续 set_enabled 独立控制 .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 servers = &mut config.mcp_for_mut(app).servers;
let before = servers.get(id).cloned(); let before = servers.get(id).cloned();
servers.insert(id.to_string(), spec); servers.insert(id.to_string(), value);
Ok(before.is_none()) Ok(before.is_none())
} }
@@ -133,28 +224,58 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, String>
let mut changed = 0usize; let mut changed = 0usize;
for (id, spec) in map.iter() { for (id, spec) in map.iter() {
// 校验目标 spec // 校验目标 spec
validate_mcp_spec(spec)?; validate_server_spec(spec)?;
// 规范化为对象 let entry = config
let mut obj = spec.as_object().cloned().ok_or_else(|| "MCP 服务器定义必须为 JSON 对象".to_string())?; .mcp_for_mut(&AppType::Claude)
obj.insert("enabled".into(), json!(true)); .servers
.entry(id.clone());
let entry = config.mcp_for_mut(&AppType::Claude).servers.entry(id.clone());
use std::collections::hash_map::Entry; use std::collections::hash_map::Entry;
match entry { match entry {
Entry::Vacant(vac) => { 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)); vac.insert(Value::Object(obj));
changed += 1; changed += 1;
} }
Entry::Occupied(mut occ) => { Entry::Occupied(mut occ) => {
// 只确保 enabled=true不覆盖其他字段 let value = occ.get_mut();
if let Some(mut existing) = occ.get().as_object().cloned() { let Some(existing) = value.as_object_mut() else {
let prev = existing.get("enabled").and_then(|b| b.as_bool()).unwrap_or(false); log::warn!("MCP 条目 '{}' 不是 JSON 对象,覆盖为导入数据", id);
if !prev { let mut obj = serde_json::Map::new();
existing.insert("enabled".into(), json!(true)); obj.insert(String::from("id"), json!(id));
occ.insert(Value::Object(existing)); obj.insert(String::from("name"), json!(id));
changed += 1; 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); 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); log::warn!("跳过无效 Codex MCP 项 '{}': {}", id, e);
continue; continue;
} }
@@ -259,22 +380,49 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, String> {
.entry(id.clone()); .entry(id.clone());
match entry { match entry {
Entry::Vacant(vac) => { Entry::Vacant(vac) => {
let mut obj = spec_v.as_object().cloned().unwrap_or_default(); let mut obj = serde_json::Map::new();
obj.insert("enabled".into(), json!(true)); 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)); vac.insert(serde_json::Value::Object(obj));
changed += 1; changed += 1;
} }
Entry::Occupied(mut occ) => { Entry::Occupied(mut occ) => {
if let Some(mut existing) = occ.get().as_object().cloned() { let value = occ.get_mut();
let prev = existing let Some(existing) = value.as_object_mut() else {
.get("enabled") log::warn!("MCP 条目 '{}' 不是 JSON 对象,覆盖为导入数据", id);
.and_then(|b| b.as_bool()) let mut obj = serde_json::Map::new();
.unwrap_or(false); obj.insert(String::from("id"), json!(id));
if !prev { obj.insert(String::from("name"), json!(id));
existing.insert("enabled".into(), json!(true)); obj.insert(String::from("server"), spec_v.clone());
occ.insert(serde_json::Value::Object(existing)); obj.insert(String::from("enabled"), json!(true));
changed += 1; 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(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 React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { X, Save, AlertCircle } from "lucide-react"; import { X, Save, AlertCircle } from "lucide-react";
import { McpServer } from "../../types"; import { McpServer, McpServerSpec } from "../../types";
import { mcpPresets } from "../../config/mcpPresets"; import { mcpPresets } from "../../config/mcpPresets";
import { buttonStyles, inputStyles } from "../../lib/styles"; import { buttonStyles, inputStyles } from "../../lib/styles";
import McpWizardModal from "./McpWizardModal"; import McpWizardModal from "./McpWizardModal";
@@ -69,19 +69,25 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
} }
return `${t("mcp.error.tomlInvalid")}: ${err}`; return `${t("mcp.error.tomlInvalid")}: ${err}`;
}; };
const [formId, setFormId] = useState(editingId || ""); const [formId, setFormId] = useState(
const [formDescription, setFormDescription] = useState( () => editingId || initialData?.id || "",
(initialData as any)?.description || "",
); );
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 决定初始格式 // 根据 appType 决定初始格式
const [formConfig, setFormConfig] = useState(() => { const [formConfig, setFormConfig] = useState(() => {
if (!initialData) return ""; const spec = initialData?.server;
if (!spec) return "";
if (appType === "codex") { if (appType === "codex") {
return mcpServerToToml(initialData); return mcpServerToToml(spec);
} else {
return JSON.stringify(initialData, null, 2);
} }
return JSON.stringify(spec, null, 2);
}); });
const [configError, setConfigError] = useState(""); const [configError, setConfigError] = useState("");
@@ -123,7 +129,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const p = mcpPresets[index]; const p = mcpPresets[index];
const id = ensureUniqueId(p.id); const id = ensureUniqueId(p.id);
setFormId(id); setFormId(id);
setFormName(p.name || p.id);
setFormDescription(p.description || ""); setFormDescription(p.description || "");
setFormHomepage(p.homepage || "");
setFormDocs(p.docs || "");
setFormTags(p.tags?.join(", ") || "");
// 根据格式转换配置 // 根据格式转换配置
if (useToml) { if (useToml) {
@@ -146,7 +156,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setSelectedPreset(-1); setSelectedPreset(-1);
// 恢复到空白模板 // 恢复到空白模板
setFormId(""); setFormId("");
setFormName("");
setFormDescription(""); setFormDescription("");
setFormHomepage("");
setFormDocs("");
setFormTags("");
setFormConfig(""); setFormConfig("");
setConfigError(""); setConfigError("");
}; };
@@ -227,10 +241,13 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const handleWizardApply = (title: string, json: string) => { const handleWizardApply = (title: string, json: string) => {
setFormId(title); setFormId(title);
if (!formName.trim()) {
setFormName(title);
}
// Wizard 返回的是 JSON根据格式决定是否需要转换 // Wizard 返回的是 JSON根据格式决定是否需要转换
if (useToml) { if (useToml) {
try { try {
const server = JSON.parse(json) as McpServer; const server = JSON.parse(json) as McpServerSpec;
const toml = mcpServerToToml(server); const toml = mcpServerToToml(server);
setFormConfig(toml); setFormConfig(toml);
const err = validateToml(toml); const err = validateToml(toml);
@@ -245,19 +262,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!formId.trim()) { const trimmedId = formId.trim();
if (!trimmedId) {
onNotify?.(t("mcp.error.idRequired"), "error", 3000); onNotify?.(t("mcp.error.idRequired"), "error", 3000);
return; return;
} }
// 新增模式:阻止提交重名 ID // 新增模式:阻止提交重名 ID
if (!isEditing && existingIds.includes(formId.trim())) { if (!isEditing && existingIds.includes(trimmedId)) {
setIdError(t("mcp.error.idExists")); setIdError(t("mcp.error.idExists"));
return; return;
} }
// 验证配置格式 // 验证配置格式
let server: McpServer; let serverSpec: McpServerSpec;
if (useToml) { if (useToml) {
// TOML 模式 // TOML 模式
@@ -270,14 +288,14 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
if (!formConfig.trim()) { if (!formConfig.trim()) {
// 空配置 // 空配置
server = { serverSpec = {
type: "stdio", type: "stdio",
command: "", command: "",
args: [], args: [],
}; };
} else { } else {
try { try {
server = tomlToMcpServer(formConfig); serverSpec = tomlToMcpServer(formConfig);
} catch (e: any) { } catch (e: any) {
const msg = e?.message || String(e); const msg = e?.message || String(e);
setConfigError(formatTomlError(msg)); setConfigError(formatTomlError(msg));
@@ -296,14 +314,14 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
if (!formConfig.trim()) { if (!formConfig.trim()) {
// 空配置 // 空配置
server = { serverSpec = {
type: "stdio", type: "stdio",
command: "", command: "",
args: [], args: [],
}; };
} else { } else {
try { try {
server = JSON.parse(formConfig) as McpServer; serverSpec = JSON.parse(formConfig) as McpServerSpec;
} catch (e: any) { } catch (e: any) {
setConfigError(t("mcp.error.jsonInvalid")); setConfigError(t("mcp.error.jsonInvalid"));
onNotify?.(t("mcp.error.jsonInvalid"), "error", 4000); 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); onNotify?.(t("mcp.error.commandRequired"), "error", 3000);
return; return;
} }
if (server?.type === "http" && !server?.url?.trim()) { if (serverSpec?.type === "http" && !serverSpec?.url?.trim()) {
onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000); onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000);
return; return;
} }
setSaving(true); setSaving(true);
try { try {
// 保留原有的 enabled 状态 const entry: McpServer = {
...(initialData ? { ...initialData } : {}),
id: trimmedId,
server: serverSpec,
};
if (initialData?.enabled !== undefined) { if (initialData?.enabled !== undefined) {
server.enabled = initialData.enabled; entry.enabled = initialData.enabled;
} else if (!initialData) {
delete entry.enabled;
} }
// 保存 description 到 server 对象 const nameTrimmed = (formName || trimmedId).trim();
if (formDescription.trim()) { entry.name = nameTrimmed || trimmedId;
(server as any).description = formDescription.trim();
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) { } catch (error: any) {
const detail = extractErrorMessage(error); const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t); const mapped = translateMcpBackendError(detail, t);
@@ -409,7 +463,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}`} }`}
title={p.description} title={p.description}
> >
{p.name || p.id} {p.id}
</button> </button>
))} ))}
</div> </div>
@@ -436,6 +490,19 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/> />
</div> </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 (描述) */} {/* Description (描述) */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <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> </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 */} {/* 配置输入框(根据格式显示 JSON 或 TOML */}
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">

View File

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

View File

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

View File

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

View File

@@ -1,44 +1,80 @@
import { McpServer } from "../types"; import { McpServer, McpServerSpec } from "../types";
export type McpPreset = { export type McpPreset = Omit<McpServer, "enabled">;
id: string;
name: string;
description: string;
tags?: string[];
server: McpServer;
homepage?: string;
docs?: string;
};
// 预设 MCP逻辑简化版 // 预设 MCP逻辑简化版
// - 仅包含最常用、可快速落地的 stdio 模式示例 // - 仅包含最常用、可快速落地的 stdio 模式示例
// - 不涉及分类/模板/测速等复杂逻辑,默认以 disabled 形式回种到 config.json // - 不涉及分类/模板/测速等复杂逻辑,默认以 disabled 形式"回种"到 config.json
// - 用户可在 MCP 面板中一键启用/编辑 // - 用户可在 MCP 面板中一键启用/编辑
export const mcpPresets: McpPreset[] = [ export const mcpPresets: McpPreset[] = [
{ {
id: "fetch", id: "fetch",
name: "mcp-server-fetch", name: "mcp-server-fetch",
description: description:
"通用 HTTP Fetchstdio经 uvx 运行 mcp-server-fetch,适合快速请求接口/抓取数据", "通用 HTTP 请求工具,支持 GET/POST 等 HTTP 方法,适合快速请求接口/抓取网页数据",
tags: ["stdio", "http"], tags: ["stdio", "http", "web"],
server: { server: {
type: "stdio", type: "stdio",
command: "uvx", command: "uvx",
args: ["mcp-server-fetch"], 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", id: "context7",
name: "mcp-context7", name: "@context7/mcp-server",
description: "Context7 示例(无需环境变量),可按需在表单中调整参数", description:
tags: ["stdio", "docs"], "Context7 文档搜索工具,提供最新的库文档和代码示例,完全无需配置",
tags: ["stdio", "docs", "search"],
server: { server: {
type: "stdio", type: "stdio",
command: "uvx", command: "npx",
// 使用 fetch 服务器作为基础示例,用户可在表单中补充 args args: ["-y", "@context7/mcp-server"],
args: ["mcp-server-fetch"], } as McpServerSpec,
} as McpServer, homepage: "https://context7.com",
docs: "https://github.com/context7", docs: "https://github.com/context7/mcp-server",
}, },
]; ];

View File

@@ -281,8 +281,16 @@
"form": { "form": {
"title": "MCP Title (Unique)", "title": "MCP Title (Unique)",
"titlePlaceholder": "my-mcp-server", "titlePlaceholder": "my-mcp-server",
"name": "Display Name",
"namePlaceholder": "e.g. @modelcontextprotocol/server-time",
"description": "Description", "description": "Description",
"descriptionPlaceholder": "Optional 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", "jsonConfig": "JSON Configuration",
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}", "jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
"tomlConfig": "TOML Configuration", "tomlConfig": "TOML Configuration",

View File

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

View File

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

View File

@@ -55,8 +55,8 @@ export interface Settings {
customEndpointsCodex?: Record<string, CustomEndpoint>; customEndpointsCodex?: Record<string, CustomEndpoint>;
} }
// MCP 服务器定义(宽松:允许扩展字段) // MCP 服务器连接参数(宽松:允许扩展字段)
export interface McpServer { export interface McpServerSpec {
// 可选:社区常见 .mcp.json 中 stdio 配置可不写 type // 可选:社区常见 .mcp.json 中 stdio 配置可不写 type
type?: "stdio" | "http"; type?: "stdio" | "http";
// stdio 字段 // stdio 字段
@@ -68,7 +68,20 @@ export interface McpServer {
url?: string; url?: string;
headers?: Record<string, 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; [key: string]: any;
} }

View File

@@ -55,9 +55,19 @@ export const translateMcpBackendError = (
} }
if ( if (
msg.includes("MCP 服务器定义必须为 JSON 对象") || 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("MCP 服务器 '" /* 不是对象 */) ||
msg.includes("不是对象") || 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"); return t("mcp.error.jsonInvalid");
} }

View File

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

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

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