mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
67bd8f5c11
BREAKING CHANGE: The [mcp.servers] format was completely incorrect and not any official Codex format. The only correct format is [mcp_servers] at the top level of config.toml. Changes: - Remove incorrect [mcp.servers] nested table support - Always use [mcp_servers] top-level table (official Codex format) - Auto-migrate and cleanup erroneous [mcp.servers] entries on write - Preserve error-tolerant import for migrating old incorrect configs - Simplify sync logic by removing format selection branches (~60 lines) - Update all documentation and tests to reflect correct format - Add warning logs when detecting and cleaning incorrect format Backend (Rust): - mcp.rs: Simplify sync_enabled_to_codex by removing Target enum - mcp.rs: sync_single_server_to_codex now always uses [mcp_servers] - mcp.rs: remove_server_from_codex cleans both locations - mcp.rs: Update import_from_codex comments to clarify format status - tests: Rename test to sync_enabled_to_codex_migrates_erroneous_* - tests: Update assertions to verify migration behavior Frontend (TypeScript): - tomlUtils.ts: Prioritize [mcp_servers] format in parsing - tomlUtils.ts: Update error messages to guide correct format Documentation: - README.md: Correct MCP format reference to [mcp_servers] - CLAUDE.md: Add comprehensive format specification with examples All 79 tests pass. This ensures backward compatibility while enforcing the correct Codex official standard going forward. Refs: https://github.com/openai/codex/issues/3441
222 lines
6.6 KiB
TypeScript
222 lines
6.6 KiB
TypeScript
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||
import { normalizeTomlText } from "@/utils/textNormalization";
|
||
import { McpServerSpec } from "../types";
|
||
|
||
/**
|
||
* 验证 TOML 格式并转换为 JSON 对象
|
||
* @param text TOML 文本
|
||
* @returns 错误信息(空字符串表示成功)
|
||
*/
|
||
export const validateToml = (text: string): string => {
|
||
if (!text.trim()) return "";
|
||
try {
|
||
const normalized = normalizeTomlText(text);
|
||
const parsed = parseToml(normalized);
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
return "mustBeObject";
|
||
}
|
||
return "";
|
||
} catch (e: any) {
|
||
// 返回底层错误信息,由上层进行 i18n 包装
|
||
return e?.message || "parseError";
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 将 McpServerSpec 对象转换为 TOML 字符串
|
||
* 使用 @iarna/toml 的 stringify,自动处理转义与嵌套表
|
||
* 保留所有字段(包括扩展字段如 timeout_ms)
|
||
*/
|
||
export const mcpServerToToml = (server: McpServerSpec): string => {
|
||
// 先复制所有字段(保留扩展字段)
|
||
const obj: any = { ...server };
|
||
|
||
// 去除未定义字段,确保输出更干净
|
||
for (const k of Object.keys(obj)) {
|
||
if (obj[k] === undefined) delete obj[k];
|
||
}
|
||
|
||
// stringify 默认会带换行,做一次 trim 以适配文本框展示
|
||
return stringifyToml(obj).trim();
|
||
};
|
||
|
||
/**
|
||
* 将 TOML 文本转换为 McpServerSpec 对象(单个服务器配置)
|
||
* 支持两种格式:
|
||
* 1. 直接的服务器配置(type, command, args 等)
|
||
* 2. [mcp_servers.<id>] 格式(推荐,取第一个服务器)
|
||
* 3. [mcp.servers.<id>] 错误格式(容错解析,同样取第一个服务器)
|
||
* @param tomlText TOML 文本
|
||
* @returns McpServer 对象
|
||
* @throws 解析或转换失败时抛出错误
|
||
*/
|
||
export const tomlToMcpServer = (tomlText: string): McpServerSpec => {
|
||
if (!tomlText.trim()) {
|
||
throw new Error("TOML 内容不能为空");
|
||
}
|
||
|
||
const parsed = parseToml(normalizeTomlText(tomlText));
|
||
|
||
// 情况 1: 直接是服务器配置(包含 type/command/url 等字段)
|
||
if (
|
||
parsed.type ||
|
||
parsed.command ||
|
||
parsed.url ||
|
||
parsed.args ||
|
||
parsed.env
|
||
) {
|
||
return normalizeServerConfig(parsed);
|
||
}
|
||
|
||
// 情况 2: [mcp_servers.<id>] 格式(推荐)
|
||
if (parsed.mcp_servers && typeof parsed.mcp_servers === "object") {
|
||
const serverIds = Object.keys(parsed.mcp_servers);
|
||
if (serverIds.length > 0) {
|
||
const firstServer = (parsed.mcp_servers as any)[serverIds[0]];
|
||
return normalizeServerConfig(firstServer);
|
||
}
|
||
}
|
||
|
||
// 情况 3: [mcp.servers.<id>] 错误格式(容错解析)
|
||
if (parsed.mcp && typeof parsed.mcp === "object") {
|
||
const mcpObj = parsed.mcp as any;
|
||
if (mcpObj.servers && typeof mcpObj.servers === "object") {
|
||
const serverIds = Object.keys(mcpObj.servers);
|
||
if (serverIds.length > 0) {
|
||
const firstServer = mcpObj.servers[serverIds[0]];
|
||
return normalizeServerConfig(firstServer);
|
||
}
|
||
}
|
||
}
|
||
|
||
throw new Error(
|
||
"无法识别的 TOML 格式。请提供单个 MCP 服务器配置,或使用 [mcp_servers.<id>] 格式",
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 规范化服务器配置对象为 McpServer 格式
|
||
* 保留所有字段(包括扩展字段如 timeout_ms)
|
||
*/
|
||
function normalizeServerConfig(config: any): McpServerSpec {
|
||
if (!config || typeof config !== "object") {
|
||
throw new Error("服务器配置必须是对象");
|
||
}
|
||
|
||
const type = (config.type as string) || "stdio";
|
||
|
||
// 已知字段列表(用于后续排除)
|
||
const knownFields = new Set<string>();
|
||
|
||
if (type === "stdio") {
|
||
if (!config.command || typeof config.command !== "string") {
|
||
throw new Error("stdio 类型的 MCP 服务器必须包含 command 字段");
|
||
}
|
||
|
||
const server: McpServerSpec = {
|
||
type: "stdio",
|
||
command: config.command,
|
||
};
|
||
knownFields.add("type");
|
||
knownFields.add("command");
|
||
|
||
// 可选字段
|
||
if (config.args && Array.isArray(config.args)) {
|
||
server.args = config.args.map((arg: any) => String(arg));
|
||
knownFields.add("args");
|
||
}
|
||
if (config.env && typeof config.env === "object") {
|
||
const env: Record<string, string> = {};
|
||
for (const [k, v] of Object.entries(config.env)) {
|
||
env[k] = String(v);
|
||
}
|
||
server.env = env;
|
||
knownFields.add("env");
|
||
}
|
||
if (config.cwd && typeof config.cwd === "string") {
|
||
server.cwd = config.cwd;
|
||
knownFields.add("cwd");
|
||
}
|
||
|
||
// 保留所有未知字段(如 timeout_ms 等扩展字段)
|
||
for (const key of Object.keys(config)) {
|
||
if (!knownFields.has(key)) {
|
||
server[key] = config[key];
|
||
}
|
||
}
|
||
|
||
return server;
|
||
} else if (type === "http" || type === "sse") {
|
||
if (!config.url || typeof config.url !== "string") {
|
||
throw new Error(`${type} 类型的 MCP 服务器必须包含 url 字段`);
|
||
}
|
||
|
||
const server: McpServerSpec = {
|
||
type: type as "http" | "sse",
|
||
url: config.url,
|
||
};
|
||
knownFields.add("type");
|
||
knownFields.add("url");
|
||
|
||
// 可选字段
|
||
if (config.headers && typeof config.headers === "object") {
|
||
const headers: Record<string, string> = {};
|
||
for (const [k, v] of Object.entries(config.headers)) {
|
||
headers[k] = String(v);
|
||
}
|
||
server.headers = headers;
|
||
knownFields.add("headers");
|
||
}
|
||
|
||
// 保留所有未知字段
|
||
for (const key of Object.keys(config)) {
|
||
if (!knownFields.has(key)) {
|
||
server[key] = config[key];
|
||
}
|
||
}
|
||
|
||
return server;
|
||
} else {
|
||
throw new Error(`不支持的 MCP 服务器类型: ${type}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 尝试从 TOML 中提取合理的服务器 ID/标题
|
||
* @param tomlText TOML 文本
|
||
* @returns 建议的 ID,失败返回空字符串
|
||
*/
|
||
export const extractIdFromToml = (tomlText: string): string => {
|
||
try {
|
||
const parsed = parseToml(normalizeTomlText(tomlText));
|
||
|
||
// 尝试从 [mcp_servers.<id>] 或 [mcp.servers.<id>] 中提取 ID
|
||
if (parsed.mcp_servers && typeof parsed.mcp_servers === "object") {
|
||
const serverIds = Object.keys(parsed.mcp_servers);
|
||
if (serverIds.length > 0) {
|
||
return serverIds[0];
|
||
}
|
||
}
|
||
|
||
if (parsed.mcp && typeof parsed.mcp === "object") {
|
||
const mcpObj = parsed.mcp as any;
|
||
if (mcpObj.servers && typeof mcpObj.servers === "object") {
|
||
const serverIds = Object.keys(mcpObj.servers);
|
||
if (serverIds.length > 0) {
|
||
return serverIds[0];
|
||
}
|
||
}
|
||
}
|
||
|
||
// 尝试从 command 中推断
|
||
if (parsed.command && typeof parsed.command === "string") {
|
||
const cmd = parsed.command.split(/[\\/]/).pop() || "";
|
||
return cmd.replace(/\.(exe|bat|sh|js|py)$/i, "");
|
||
}
|
||
} catch {
|
||
// 解析失败,返回空
|
||
}
|
||
|
||
return "";
|
||
};
|