fix(mcp): stop writing type into Codex mcp_servers (#7735)

Codex has no `type` key under `[mcp_servers.*]`; it infers the transport
from `command` (stdio) or `url` (streamable HTTP). Since Codex 0.158 the
`type` line CC Switch wrote on every MCP sync is reported as an
unrecognized setting, and `--strict-config` refuses to start.

Stop emitting the key from the shared Codex serializer while still using
the stored transport type to pick which fields to write. On import, infer
the transport from `url` when `type` is absent so url-only entries are no
longer treated as stdio and skipped. Grok Build output is unchanged.

Fixes #7734
Fixes #7554
Fixes #5820
This commit is contained in:
tingfeng347 authored and GitHub committed 2026-10-02 21:33:19 +08:00
1 parent b9e9620265
commit 4e46e6b630
5 files changed
+107 -13

No files matched your search

+62 -8
View File
@@ -42,6 +42,23 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
out
}
/// 推断 Codex `[mcp_servers.*]` 条目的传输类型。
///
/// Codex 没有 `type` 字段:传输方式由 `command`(stdio)或 `url`
/// (streamable HTTP)推断。手写配置以及新版本工具写出的配置都可能只有
/// `command`/`url`,因此显式 `type` 缺失时按 `url` 存在与否兜底,
/// 避免 HTTP server 被误判成 stdio 而丢失 `url`。
fn codex_entry_transport_type(entry_tbl: &toml::value::Table) -> &str {
if let Some(explicit) = entry_tbl.get("type").and_then(|v| v.as_str()) {
return explicit;
}
if entry_tbl.contains_key("url") {
"http"
} else {
"stdio"
}
}
/// 从 ~/.codex/config.toml 导入 MCP 到统一结构(v3.7.0+)
///
/// 格式支持:
@@ -71,11 +88,8 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
continue;
};
// type 缺省为 stdio
let typ = entry_tbl
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("stdio");
// 传输类型:显式 `type` 优先;缺失时由 command/url 推断(见 helper)
let typ = codex_entry_transport_type(entry_tbl);
// 构建 JSON 规范
let mut spec = serde_json::Map::new();
@@ -605,17 +619,22 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit:
/// Helper: 将 JSON MCP 服务器规范转换为 toml_edit::Table
///
/// 策略:
/// 1. 核心字段(type, command, args, url, headers, env, cwd)使用强类型处理
/// 1. 核心字段(command, args, url, headers, env, cwd)使用强类型处理
/// 2. 扩展字段(timeout、retry 等)通过白名单列表自动转换
/// 3. 其他未知字段使用通用转换器尝试转换
///
/// 注意:**不向 Codex 写出 `type`**。`[mcp_servers.*]` 没有 `type` 字段,
/// Codex 由 `command`(stdio)或 `url`(streamable HTTP)推断传输方式;
/// 写出 `type = "stdio"` 会被 Codex 0.158+ 的配置校验当作未知字段告警
/// (`unknown configuration field mcp_servers.<id>.type`)。DB 中仍保留 `type`
/// 以便在各客户端之间共享同一份 server 规范。
pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
use toml_edit::{Array, Item, Table};
let mut t = Table::new();
let typ = spec.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
t["type"] = toml_edit::value(typ);
// 定义核心字段(已在下方处理,跳过通用转换)
// 定义核心字段(已在下方处理,跳过通用转换;`type` 仅为兼容 DB 规范而跳过)
let core_fields = match typ {
"stdio" => vec!["type", "command", "args", "env", "cwd"],
"http" | "sse" => vec!["type", "url", "headers", "http_headers"],
@@ -845,4 +864,39 @@ mod tests {
Some(30)
);
}
#[test]
fn codex_mcp_table_omits_type_field() {
// Codex 的 [mcp_servers.*] 没有 `type` 字段:传输方式由 command / url 推断。
// 写出 `type` 会被 Codex 0.158+ 当作未知配置字段告警。
for spec in [
json!({ "type": "stdio", "command": "echo", "args": ["hi"] }),
json!({ "type": "http", "url": "https://mcp.example.com/mcp" }),
] {
let table = json_server_to_toml_table(&spec).unwrap();
assert!(
table.get("type").is_none(),
"Codex MCP table must not contain a `type` field"
);
}
}
#[test]
fn codex_entry_transport_type_infers_from_url() {
// 显式 type 优先
let explicit: toml::value::Table =
toml::from_str("type = \"sse\"\nurl = \"https://x/sse\"\n").expect("fixture parses");
assert_eq!(codex_entry_transport_type(&explicit), "sse");
// 无 type:有 url → http(否则 HTTP server 会被误判成 stdio)
let http: toml::value::Table =
toml::from_str("url = \"https://x/mcp\"\n").expect("fixture parses");
assert_eq!(codex_entry_transport_type(&http), "http");
// 无 type:只有 command → stdio
let stdio: toml::value::Table =
toml::from_str("command = \"npx\"\nargs = [\"-y\", \"server\"]\n")
.expect("fixture parses");
assert_eq!(codex_entry_transport_type(&stdio), "stdio");
}
}
+2 -1
View File
@@ -26,7 +26,8 @@ fn read_config_text() -> Result<String, AppError> {
fn json_server_to_grokbuild_toml_table(server_spec: &Value) -> Result<toml_edit::Table, AppError> {
let mut table = json_server_to_toml_table(server_spec)?;
// Grok infers transport from `command` or `url` and uses `headers`, while
// Codex writes an explicit `type` plus `http_headers`.
// the shared converter writes `http_headers` (and no longer writes `type`).
// `remove("type")` 保留为防御:兼容旧版规范或显式传入的 type。
table.remove("type");
if let Some(headers) = table.remove("http_headers") {
table.insert("headers", headers);
@@ -15,14 +15,12 @@ trust_level = "trusted"
command = "external"
[mcp_servers.http-server]
type = "http"
url = "https://mcp.example.com/mcp"
[mcp_servers.http-server.http_headers]
Authorization = "Bearer mcp-header"
[mcp_servers.stdio-server]
type = "stdio"
command = "example-mcp-server"
args = ["--stdio"]
@@ -1,14 +1,12 @@
[mcp_servers]
[mcp_servers.http-server]
type = "http"
url = "https://mcp.example.com/mcp"
[mcp_servers.http-server.http_headers]
Authorization = "Bearer mcp-header"
[mcp_servers.stdio-server]
type = "stdio"
command = "example-mcp-server"
args = ["--stdio"]
+43
View File
@@ -498,6 +498,49 @@ command = "echo"
);
}
#[test]
fn import_mcp_from_codex_infers_http_from_url_without_type() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
// Codex 的 [mcp_servers.*] 没有 `type`:HTTP server 只带 `url`。
fs::write(
codex_dir.join("config.toml"),
"[mcp_servers.remote]\nurl = \"https://mcp.example.com/mcp\"\n\n\
[mcp_servers.remote.http_headers]\nAuthorization = \"Bearer x\"\n",
)
.expect("seed codex config");
let state = create_test_state().expect("create test state");
let changed = McpService::import_from_codex(&state).expect("import from codex");
assert!(changed > 0, "should import the url-only server");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("remote").expect("url-only server imported");
assert_eq!(
entry.server.get("type").and_then(|v| v.as_str()),
Some("http"),
"url-only Codex server must import as http, not stdio"
);
assert_eq!(
entry.server.get("url").and_then(|v| v.as_str()),
Some("https://mcp.example.com/mcp"),
"url must be preserved"
);
assert_eq!(
entry
.server
.get("headers")
.and_then(|v| v.get("Authorization"))
.and_then(|v| v.as_str()),
Some("Bearer x"),
"http_headers must map to headers"
);
}
#[test]
fn import_mcp_from_claude_does_not_sync_existing_codex_enabled_server() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());