Skip to main content

qcode/bridge/
config.rs

1//! Registering the bridge's server in a harness's own settings, beside whatever the person keeps
2//! there.
3//!
4//! Each harness reads the MCP servers it starts from a settings file in its home directory, which
5//! in QCode is the workspace's home volume for the profile. The file belongs to the person and to
6//! the harness: either may have written servers, options and comments into it. So the file is
7//! never written from scratch. It is read, the one entry named [`SERVER_NAME`] is added when it is
8//! missing, and the file is written back only when that changed it:
9//!
10//! - an entry of that name that already starts this server is left as it is, and nothing is
11//!   written;
12//! - an entry of that name that starts something else is the person's own, and is left alone;
13//!   the bridge then does not reach that harness, and QCode says so;
14//! - a file that cannot be read as its format (a comment in a JSON file, a broken TOML) is left
15//!   alone the same way, rather than replaced by one that can.
16//!
17//! JSON files are written back with two-space indentation and the keys in the order they were
18//! read. A TOML file is never rewritten: the entry is added as a table of its own at the end, so
19//! the person's comments and layout stay exactly as they were.
20
21use serde_json::{Map, Value, json};
22use toml::de::{DeTable, DeValue};
23
24use super::{SERVER_NAME, TOKEN_VARIABLE, script_in_container};
25use crate::engine::run::{EngineError, capture, feed};
26use crate::engine::{Engine, Exec};
27use crate::profile::{HarnessKind, McpShape};
28
29/// Why the server could not be registered for a harness, which then does not reach the other
30/// tabs; its tab works as before.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Unregistered {
33    /// The settings file has a server of the same name that starts something else. The file,
34    /// relative to the home directory.
35    Taken(String),
36    /// The settings file cannot be read as its format. The file, and where.
37    Unreadable(String, String),
38    /// The engine could not read or write the file; its own words.
39    Engine(String),
40}
41
42/// The exit code of the reading script for a file that is not there.
43const ABSENT: i32 = 3;
44
45/// Reads the file at `path` under the home directory of the running container `container`, and
46/// answers `None` when it is not there.
47///
48/// A volume is only reached through a container, so the file is read by a shell in it. Runs an
49/// engine command, so it belongs on a background thread.
50pub(crate) fn read_in_home(engine: &Engine, container: &str, path: &str) -> Result<Option<String>, EngineError> {
51    let read = ["sh", "-c", "[ -e \"$HOME/$1\" ] || exit 3; cat -- \"$HOME/$1\"", "sh", path];
52    match capture(&engine.exec_without_terminal(&Exec { container, command: &read })) {
53        Ok(text) => Ok(Some(text)),
54        Err(EngineError::Failed(failure)) if failure.code == Some(ABSENT) => Ok(None),
55        Err(error) => Err(error),
56    }
57}
58
59/// Writes `text` to the file at `path` under the home directory of the running container
60/// `container`.
61///
62/// The new text goes through the shell's input, because a harness's settings can be longer than
63/// one argument may be, and the file is written beside the old one with the old one's mode and
64/// moved over it, so a harness reading at that moment finds the old file or the new one, never
65/// half of one. Runs engine commands, so it belongs on a background thread.
66pub(crate) fn write_in_home(engine: &Engine, container: &str, path: &str, text: &str) -> Result<(), EngineError> {
67    let write = [
68        "sh",
69        "-c",
70        "set -e; file=\"$HOME/$1\"; mkdir -p -- \"$(dirname -- \"$file\")\"; \
71         cp -p -- \"$file\" \"$file.qcode-new\" 2>/dev/null || true; \
72         cat > \"$file.qcode-new\"; mv -f -- \"$file.qcode-new\" \"$file\"",
73        "sh",
74        path,
75    ];
76    feed(&engine.exec_reading(&Exec { container, command: &write }), text.as_bytes()).map(|_| ())
77}
78
79/// Registers the server in the settings of `harness` inside the running container `container`,
80/// whose home is the workspace's home volume for the profile. `token` is the tab's, for the one
81/// harness whose entry carries it instead of its container's environment.
82///
83/// The file is read and written by a shell in the container, because a volume is only reached
84/// through a container, and the new text goes through the shell's input, because a harness's
85/// settings can be longer than one argument may be. It is written beside the old file with the
86/// old file's mode and moved over it, so a harness reading at that moment finds the old file or
87/// the new one, never half of one. Runs engine commands, so it belongs on a background thread.
88///
89/// # Errors
90///
91/// [`Unregistered`] when the file holds a server of the same name, cannot be read as its format,
92/// or the engine refuses.
93pub fn register(engine: &Engine, container: &str, harness: HarnessKind, token: &str) -> Result<(), Unregistered> {
94    // A harness that reads no servers has nothing to register and nothing to complain about.
95    let Some(settings) = harness.record().mcp else { return Ok(()) };
96    let existing =
97        read_in_home(engine, container, settings.path).map_err(|error| Unregistered::Engine(words(&error)))?;
98    let text = match merge(settings.shape, existing.as_deref(), token) {
99        Merged::Unchanged => return Ok(()),
100        Merged::Write(text) => text,
101        Merged::Taken => return Err(Unregistered::Taken(settings.path.to_owned())),
102        Merged::Unreadable(place) => return Err(Unregistered::Unreadable(settings.path.to_owned(), place)),
103    };
104    write_in_home(engine, container, settings.path, &text).map_err(|error| Unregistered::Engine(words(&error)))
105}
106
107/// What an engine said when it refused, for the person to read.
108pub(crate) fn words(error: &EngineError) -> String {
109    match error {
110        EngineError::NotRunnable { error, .. } => error.to_string(),
111        EngineError::Failed(failure) => failure.output.clone(),
112        EngineError::Cancelled { .. } => String::new(),
113        EngineError::TimedOut { command, after } => crate::engine::run::timed_out(command, *after),
114    }
115}
116
117/// What registering the server in a settings file came to.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum Merged {
120    /// The server was registered already; nothing is written.
121    Unchanged,
122    /// The file as it should be written, with the server registered.
123    Write(String),
124    /// An entry of the server's name starts something else; the file is left alone.
125    Taken,
126    /// The file is not the format it should be; it is left alone. Where it could not be read.
127    Unreadable(String),
128}
129
130/// Registers the server in the settings `existing` holds, in the shape of `shape`, for the tab
131/// whose token is `token`; `None` is a file that is not there.
132#[must_use]
133pub fn merge(shape: McpShape, existing: Option<&str>, token: &str) -> Merged {
134    let existing = existing.filter(|text| !text.trim().is_empty());
135    match shape {
136        McpShape::Claude => json_merge(existing, &["mcpServers"], &claude_entry(), &Map::new()),
137        McpShape::OpenCode => {
138            let mut fresh = Map::new();
139            fresh.insert("$schema".to_owned(), Value::String("https://opencode.ai/config.json".to_owned()));
140            json_merge(existing, &["mcp"], &opencode_entry(), &fresh)
141        }
142        McpShape::Gemini => json_merge(existing, &["mcpServers"], &gemini_entry(), &Map::new()),
143        McpShape::Codex => toml_merge(existing),
144        McpShape::Kimi => json_merge(existing, &["mcpServers"], &kimi_entry(), &Map::new()),
145        McpShape::Antigravity => json_merge(existing, &["mcpServers"], &antigravity_entry(token), &Map::new()),
146    }
147}
148
149/// Claude Code's entry: a stdio server by its program and arguments, as `claude mcp add
150/// --scope user` writes it into `~/.claude.json`.
151fn claude_entry() -> Value {
152    json!({ "type": "stdio", "command": "node", "args": [script_in_container()] })
153}
154
155/// opencode's entry: a local server by one command line, turned on.
156fn opencode_entry() -> Value {
157    json!({ "type": "local", "command": ["node", script_in_container()], "enabled": true })
158}
159
160/// Gemini CLI's entry. `trust` lets its tools run without asking, like every tool of a harness
161/// QCode starts unattended; without it Gemini CLI asks before each message.
162fn gemini_entry() -> Value {
163    json!({ "command": "node", "args": [script_in_container()], "trust": true })
164}
165
166/// Kimi Code CLI's entry: the command and its arguments, which is all its schema asks of a server
167/// started on standard input and output. The tab's token is found by the server in the harness's
168/// own process, like Codex's.
169fn kimi_entry() -> Value {
170    json!({ "command": "node", "args": [script_in_container()] })
171}
172
173/// Antigravity IDE's entry: the command alone, and the tab's token in `env`.
174///
175/// Neither Claude Code's `type` nor Gemini CLI's `trust` is written here: the file's schema names
176/// the fields a server may have and refuses the rest. The token goes into the entry rather than
177/// into the container's environment because a window's application is what starts the server, and
178/// what it hands on from its own environment is its business; the variable named here was seen to
179/// reach the server itself.
180fn antigravity_entry(token: &str) -> Value {
181    json!({ "command": "node", "args": [script_in_container()], "env": { TOKEN_VARIABLE: token } })
182}
183
184/// Whether the entry `found` starts the same program as `ours`. Only the fields that say what
185/// is started are compared, so an entry the harness itself added options to is still ours.
186fn same_server(found: &Value, ours: &Value) -> bool {
187    ["command", "args"].iter().all(|key| found.get(key) == ours.get(key))
188}
189
190/// Adds `entry` under `path` then [`SERVER_NAME`] in the JSON `existing`, or in `fresh` when there
191/// is no file.
192fn json_merge(existing: Option<&str>, path: &[&str], entry: &Value, fresh: &Map<String, Value>) -> Merged {
193    let mut root = match existing {
194        None => Value::Object(fresh.clone()),
195        Some(text) => match serde_json::from_str::<Value>(text) {
196            Ok(value @ Value::Object(_)) => value,
197            Ok(_) => return Merged::Unreadable("1:1".to_owned()),
198            Err(error) => return Merged::Unreadable(format!("{}:{}", error.line(), error.column())),
199        },
200    };
201    let mut place = &mut root;
202    for key in path {
203        let Value::Object(object) = place else { return Merged::Taken };
204        place = object.entry((*key).to_owned()).or_insert_with(|| Value::Object(Map::new()));
205    }
206    let Value::Object(servers) = place else { return Merged::Taken };
207    let written = match servers.get(SERVER_NAME) {
208        Some(found) if !same_server(found, entry) => return Merged::Taken,
209        // The entry is this server's, whatever else the harness put into it. Only the fields QCode
210        // writes are brought up to date, which is how a tab that starts again replaces the token of
211        // the tab before it without throwing away anything the harness added.
212        Some(found) => {
213            let brought = brought_up_to_date(found, entry);
214            if brought == *found {
215                return Merged::Unchanged;
216            }
217            brought
218        }
219        None => entry.clone(),
220    };
221    servers.insert(SERVER_NAME.to_owned(), written);
222    let mut text = serde_json::to_string_pretty(&root).unwrap_or_default();
223    text.push('\n');
224    Merged::Write(text)
225}
226
227/// `found` with every field of `ours` set to what `ours` says, and everything else left as it was.
228fn brought_up_to_date(found: &Value, ours: &Value) -> Value {
229    let (Value::Object(found), Value::Object(ours)) = (found, ours) else { return ours.clone() };
230    let mut brought = found.clone();
231    for (key, value) in ours {
232        brought.insert(key.clone(), value.clone());
233    }
234    Value::Object(brought)
235}
236
237/// Codex's entry, as the table `codex mcp add` writes into `~/.codex/config.toml`.
238fn codex_table() -> String {
239    format!("[mcp_servers.{SERVER_NAME}]\ncommand = \"node\"\nargs = [\"{}\"]\n", script_in_container())
240}
241
242/// Adds the Codex table to the TOML `existing`, by appending it.
243fn toml_merge(existing: Option<&str>) -> Merged {
244    let Some(text) = existing else { return Merged::Write(codex_table()) };
245    let root = match DeTable::parse(text) {
246        Ok(root) => root.into_inner(),
247        Err(error) => return Merged::Unreadable(place(text, error.span())),
248    };
249    if let Some(servers) = root.get("mcp_servers") {
250        let DeValue::Table(servers) = servers.get_ref() else { return Merged::Taken };
251        if let Some(found) = servers.get(SERVER_NAME) {
252            return if codex_is_ours(found.get_ref()) { Merged::Unchanged } else { Merged::Taken };
253        }
254    }
255    let mut written = text.to_owned();
256    if !written.ends_with('\n') {
257        written.push('\n');
258    }
259    written.push('\n');
260    written.push_str(&codex_table());
261    // An inline `mcp_servers = { … }` cannot be added to by a table further down; the result is
262    // read again rather than trusted, and a file that would break is left as it is.
263    match DeTable::parse(&written) {
264        Ok(_) => Merged::Write(written),
265        Err(_) => Merged::Taken,
266    }
267}
268
269/// Whether the Codex entry `found` starts this server.
270fn codex_is_ours(found: &DeValue<'_>) -> bool {
271    let DeValue::Table(entry) = found else { return false };
272    let command = entry.get("command").map(toml::Spanned::get_ref);
273    let args = entry.get("args").map(toml::Spanned::get_ref);
274    let script = script_in_container();
275    let command_is_node = matches!(command, Some(DeValue::String(text)) if text == "node");
276    let args_are_ours = match args {
277        Some(DeValue::Array(items)) => {
278            items.len() == 1 && matches!(items[0].get_ref(), DeValue::String(text) if *text == script)
279        }
280        _ => false,
281    };
282    command_is_node && args_are_ours
283}
284
285/// `line:column` of the byte where `span` starts in `text`, counted from 1.
286pub(crate) fn place(text: &str, span: Option<std::ops::Range<usize>>) -> String {
287    let start = span.map_or(0, |span| span.start).min(text.len());
288    let before = &text[..text.floor_char_boundary(start)];
289    let line = before.matches('\n').count() + 1;
290    let column = before.rsplit('\n').next().map_or(0, |last| last.chars().count()) + 1;
291    format!("{line}:{column}")
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    const SCRIPT: &str = "/run/qcode-mcp/qcode-bridge.mjs";
299    /// Not a token any default could produce: only a tab of a running QCode hands one over.
300    const TOKEN: &str = "9f2c41b7e08a5d36c1740be92a3f58dd";
301
302    #[test]
303    fn registering_in_claude_codes_file_keeps_the_answers_to_its_first_questions() {
304        // Both QCode templates put Claude Code's first-start answers into the very file its
305        // servers are registered in. Losing one would bring back a question whose highlighted
306        // answer is "No, exit".
307        let seeded = HarnessKind::ClaudeCode.record().first_start.expect("Claude Code's first start is answered");
308        let mcp = HarnessKind::ClaudeCode.record().mcp.expect("Claude Code reads servers");
309        assert_eq!(seeded.path, mcp.path, "the same file");
310        let text = written(merge(McpShape::Claude, Some(seeded.contents), TOKEN));
311        let before: Value = serde_json::from_str(seeded.contents).expect("the answers are JSON");
312        let after: Value = serde_json::from_str(&text).expect("the merged file is JSON");
313        for (key, value) in before.as_object().expect("an object") {
314            assert_eq!(&after[key], value, "`{key}` is kept");
315        }
316        assert_eq!(after["projects"]["/work"]["hasTrustDialogAccepted"], true, "{text}");
317        assert_eq!(after["mcpServers"][SERVER_NAME]["args"][0], SCRIPT, "{text}");
318        assert_eq!(merge(McpShape::Claude, Some(&text), TOKEN), Merged::Unchanged, "and it is written once");
319    }
320
321    fn written(merged: Merged) -> String {
322        match merged {
323            Merged::Write(text) => text,
324            other => panic!("expected a file to write, got {other:?}"),
325        }
326    }
327
328    fn json(text: &str) -> Value {
329        serde_json::from_str(text).expect("the result is JSON")
330    }
331
332    #[test]
333    fn claude_gets_a_user_server_beside_everything_it_keeps() {
334        let existing = r#"{
335  "numStartups": 4,
336  "mcpServers": { "github": { "type": "stdio", "command": "gh-mcp", "args": [] } },
337  "projects": { "/work": { "allowedTools": [] } }
338}"#;
339        let result = json(&written(merge(McpShape::Claude, Some(existing), TOKEN)));
340        assert_eq!(result["numStartups"], 4);
341        assert_eq!(result["mcpServers"]["github"]["command"], "gh-mcp", "the person's server stays");
342        assert_eq!(result["mcpServers"]["qcode"]["type"], "stdio");
343        assert_eq!(result["mcpServers"]["qcode"]["command"], "node");
344        assert_eq!(result["mcpServers"]["qcode"]["args"][0], SCRIPT);
345        assert!(result["projects"]["/work"].is_object());
346        let order: Vec<&String> = result.as_object().expect("an object").keys().collect();
347        assert_eq!(order, ["numStartups", "mcpServers", "projects"], "keys stay where they were");
348    }
349
350    #[test]
351    fn a_missing_or_empty_file_is_made_with_only_the_server() {
352        for existing in [None, Some(""), Some("  \n")] {
353            let result = json(&written(merge(McpShape::Claude, existing, TOKEN)));
354            assert_eq!(
355                result,
356                json(&format!(
357                    r#"{{"mcpServers":{{"qcode":{{"type":"stdio","command":"node","args":["{SCRIPT}"]}}}}}}"#
358                ))
359            );
360        }
361        let result = json(&written(merge(McpShape::OpenCode, None, TOKEN)));
362        assert_eq!(result["$schema"], "https://opencode.ai/config.json");
363        assert_eq!(result["mcp"]["qcode"]["command"], json(&format!(r#"["node","{SCRIPT}"]"#)));
364    }
365
366    #[test]
367    fn opencode_gets_a_local_server_and_keeps_its_permission() {
368        let existing = "{\n  \"$schema\": \"https://opencode.ai/config.json\",\n  \"permission\": {\n    \"*\": \"allow\"\n  },\n  \"mcp\": {\n    \"docs\": { \"type\": \"remote\", \"url\": \"https://example.org/mcp\" }\n  }\n}\n";
369        let result = json(&written(merge(McpShape::OpenCode, Some(existing), TOKEN)));
370        assert_eq!(result["permission"]["*"], "allow");
371        assert_eq!(result["mcp"]["docs"]["type"], "remote");
372        assert_eq!(result["mcp"]["qcode"]["type"], "local");
373        assert_eq!(result["mcp"]["qcode"]["enabled"], true);
374        assert_eq!(result["mcp"]["qcode"]["command"][1], SCRIPT);
375    }
376
377    #[test]
378    fn gemini_gets_a_trusted_server_and_keeps_its_folder_trust() {
379        let existing = "{\n  \"security\": {\n    \"folderTrust\": {\n      \"enabled\": false\n    }\n  }\n}\n";
380        let result = json(&written(merge(McpShape::Gemini, Some(existing), TOKEN)));
381        assert_eq!(result["security"]["folderTrust"]["enabled"], false);
382        assert_eq!(result["mcpServers"]["qcode"]["trust"], true);
383        assert_eq!(result["mcpServers"]["qcode"]["args"][0], SCRIPT);
384    }
385
386    #[test]
387    fn a_json_file_that_has_the_server_already_is_not_written_again() {
388        for shape in [McpShape::Claude, McpShape::OpenCode, McpShape::Gemini, McpShape::Kimi, McpShape::Antigravity] {
389            let once = written(merge(shape, None, TOKEN));
390            assert_eq!(merge(shape, Some(&once), TOKEN), Merged::Unchanged, "{shape:?}");
391        }
392        // The harness may add options of its own to the entry; it still starts this server.
393        let grown = format!(
394            r#"{{"mcpServers":{{"qcode":{{"command":"node","args":["{SCRIPT}"],"trust":true,"timeout":600000}}}}}}"#
395        );
396        assert_eq!(merge(McpShape::Gemini, Some(&grown), TOKEN), Merged::Unchanged);
397    }
398
399    #[test]
400    fn kimi_code_gets_the_command_alone_and_keeps_the_persons_servers() {
401        let existing = "{\n  \"mcpServers\": {\n    \"notes\": { \"command\": \"notes-mcp\" }\n  }\n}\n";
402        let result = json(&written(merge(McpShape::Kimi, Some(existing), TOKEN)));
403        assert_eq!(result["mcpServers"]["notes"]["command"], "notes-mcp", "the person's server stays");
404        let entry = result["mcpServers"]["qcode"].as_object().expect("an object");
405        assert_eq!(entry.get("command"), Some(&json!("node")));
406        assert_eq!(entry.get("args"), Some(&json!([SCRIPT])));
407        // The token is never written into a file the workspace keeps; the server finds it in the
408        // harness's own process.
409        assert_eq!(entry.len(), 2, "{entry:?}");
410    }
411
412    #[test]
413    fn the_window_gets_its_token_in_the_entry_and_nothing_its_schema_refuses() {
414        let existing = "{\n  \"mcpServers\": {\n    \"notes\": { \"command\": \"notes-mcp\", \"args\": [] }\n  }\n}\n";
415        let result = json(&written(merge(McpShape::Antigravity, Some(existing), TOKEN)));
416        assert_eq!(result["mcpServers"]["notes"]["command"], "notes-mcp", "the person's server stays");
417        assert_eq!(result["mcpServers"]["qcode"]["command"], "node");
418        assert_eq!(result["mcpServers"]["qcode"]["args"][0], SCRIPT);
419        assert_eq!(result["mcpServers"]["qcode"]["env"]["QCODE_BRIDGE"], TOKEN, "the window has no other way to it");
420        let entry = result["mcpServers"]["qcode"].as_object().expect("an object");
421        assert!(entry.get("type").is_none() && entry.get("trust").is_none(), "the schema takes neither: {entry:?}");
422    }
423
424    #[test]
425    fn a_window_that_opens_again_registers_the_token_of_the_tab_that_is_open_now() {
426        // The entry is written once per tab and a tab's token is new every time, so an entry left
427        // by the tab before would speak for a tab that is gone.
428        let before = written(merge(McpShape::Antigravity, None, "0e4d9a1c2b8f7365d40a91fe63c5872b"));
429        let result = json(&written(merge(McpShape::Antigravity, Some(&before), TOKEN)));
430        assert_eq!(result["mcpServers"]["qcode"]["env"]["QCODE_BRIDGE"], TOKEN);
431        let grown = format!(
432            r#"{{"mcpServers":{{"qcode":{{"command":"node","args":["{SCRIPT}"],"env":{{"QCODE_BRIDGE":"{TOKEN}"}},"disabledTools":["send_message"]}}}}}}"#
433        );
434        assert_eq!(merge(McpShape::Antigravity, Some(&grown), TOKEN), Merged::Unchanged, "what the person set stays");
435    }
436
437    #[test]
438    fn a_server_of_the_same_name_that_starts_something_else_is_the_persons() {
439        let theirs = r#"{"mcpServers":{"qcode":{"command":"python3","args":["my-own.py"]}}}"#;
440        assert_eq!(merge(McpShape::Claude, Some(theirs), TOKEN), Merged::Taken);
441        assert_eq!(merge(McpShape::Gemini, Some(theirs), TOKEN), Merged::Taken);
442        assert_eq!(merge(McpShape::Antigravity, Some(theirs), TOKEN), Merged::Taken);
443        let theirs = r#"{"mcp":{"qcode":{"type":"local","command":["deno","run","x.ts"]}}}"#;
444        assert_eq!(merge(McpShape::OpenCode, Some(theirs), TOKEN), Merged::Taken);
445        // A list of servers that is not an object has no place for an entry.
446        assert_eq!(merge(McpShape::Claude, Some(r#"{"mcpServers":[]}"#), TOKEN), Merged::Taken);
447    }
448
449    #[test]
450    fn a_json_file_that_does_not_read_as_json_is_left_alone_and_says_where() {
451        let commented = "{\n  // the person's note\n  \"theme\": \"dark\"\n}\n";
452        assert_eq!(merge(McpShape::Gemini, Some(commented), TOKEN), Merged::Unreadable("2:3".to_owned()));
453        assert_eq!(merge(McpShape::Claude, Some("[1, 2]"), TOKEN), Merged::Unreadable("1:1".to_owned()));
454    }
455
456    #[test]
457    fn codex_gets_a_table_of_its_own_at_the_end_and_the_rest_stays_byte_for_byte() {
458        let existing = "# my settings\napproval_policy = \"never\"\nsandbox_mode = \"danger-full-access\"\n\n[mcp_servers.github]\ncommand = \"gh-mcp\" # mine\n";
459        let result = written(merge(McpShape::Codex, Some(existing), TOKEN));
460        assert!(result.starts_with(existing), "{result}");
461        assert!(
462            result.ends_with(&format!("\n[mcp_servers.qcode]\ncommand = \"node\"\nargs = [\"{SCRIPT}\"]\n")),
463            "{result}"
464        );
465        assert_eq!(merge(McpShape::Codex, Some(&result), TOKEN), Merged::Unchanged, "added once");
466        let without_end = "model = \"o3\"";
467        assert!(
468            written(merge(McpShape::Codex, Some(without_end), TOKEN))
469                .starts_with("model = \"o3\"\n\n[mcp_servers.qcode]")
470        );
471        assert_eq!(written(merge(McpShape::Codex, None, TOKEN)), codex_table());
472    }
473
474    #[test]
475    fn codex_keeps_a_table_of_the_same_name_the_person_wrote() {
476        let theirs = "[mcp_servers.qcode]\ncommand = \"python3\"\nargs = [\"my-own.py\"]\n";
477        assert_eq!(merge(McpShape::Codex, Some(theirs), TOKEN), Merged::Taken);
478        let dotted = format!("mcp_servers.qcode.command = \"node\"\nmcp_servers.qcode.args = [\"{SCRIPT}\"]\n");
479        assert_eq!(
480            merge(McpShape::Codex, Some(&dotted), TOKEN),
481            Merged::Unchanged,
482            "the same entry written with dotted keys"
483        );
484    }
485
486    #[test]
487    fn codex_settings_that_would_break_or_do_not_read_are_left_alone() {
488        let inline = "mcp_servers = { github = { command = \"gh-mcp\" } }\n";
489        assert_eq!(merge(McpShape::Codex, Some(inline), TOKEN), Merged::Taken);
490        assert_eq!(merge(McpShape::Codex, Some("mcp_servers = 3\n"), TOKEN), Merged::Taken);
491        assert_eq!(
492            merge(McpShape::Codex, Some("model = \"o3\"\nbroken = \n"), TOKEN),
493            Merged::Unreadable("2:10".to_owned())
494        );
495    }
496}