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:
@@ -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));
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 Fetch(stdio,经 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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 配置",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
19
src/types.ts
19
src/types.ts
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
6
src/vite-env.d.ts
vendored
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user