diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs index 050bafb..8f46820 100644 --- a/src-tauri/src/claude_mcp.rs +++ b/src-tauri/src/claude_mcp.rs @@ -192,13 +192,30 @@ pub fn set_mcp_servers_map(servers: &std::collections::HashMap) - // 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范 let mut out: Map = 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)); } { diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index a190b71..55b449a 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -4,15 +4,15 @@ 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 一致) let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); let is_http = t_opt.map(|t| t == "http").unwrap_or(false); - + if !(is_stdio || is_http) { return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into()); } @@ -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 { + 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 { 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 { - 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 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)); - changed += 1; - } + 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 { 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 { .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 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; - } + 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(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; } } } diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 25d540f..d762af9 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -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 = ({ } 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 = ({ 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 = ({ setSelectedPreset(-1); // 恢复到空白模板 setFormId(""); + setFormName(""); setFormDescription(""); + setFormHomepage(""); + setFormDocs(""); + setFormTags(""); setFormConfig(""); setConfigError(""); }; @@ -227,10 +241,13 @@ const McpFormModal: React.FC = ({ 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 = ({ }; 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 = ({ 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 = ({ 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 = ({ } // 前置必填校验 - 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 = ({ }`} title={p.description} > - {p.name || p.id} + {p.id} ))} @@ -436,6 +490,19 @@ const McpFormModal: React.FC = ({ /> + {/* Name */} +
+ + setFormName(e.target.value)} + /> +
+ {/* Description (描述) */}
+ {/* Tags */} +
+ + setFormTags(e.target.value)} + /> +
+ + {/* Homepage */} +
+ + setFormHomepage(e.target.value)} + /> +
+ + {/* Docs */} +
+ + setFormDocs(e.target.value)} + /> +
+ {/* 配置输入框(根据格式显示 JSON 或 TOML) */}
diff --git a/src/components/mcp/McpListItem.tsx b/src/components/mcp/McpListItem.tsx index 98e9967..11a27d1 100644 --- a/src/components/mcp/McpListItem.tsx +++ b/src/components/mcp/McpListItem.tsx @@ -29,15 +29,19 @@ const McpListItem: React.FC = ({ // 仅当显式为 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 = ({ {/* 中间:名称和详细信息 */}

- {id} + {name}

{description && (

{description}

)} + {!description && tags && tags.length > 0 && ( +

+ {tags.join(", ")} +

+ )} {/* 预设标记已移除 */}
{/* 右侧:操作按钮 */}
- {meta?.docs && ( + {docsUrl && (