Skip to main content

tuff_core/
mcp.rs

1use std::path::Path;
2
3use crate::error::{Result, TuffError};
4use crate::policy::{OPENCODE_SCHEMA, OrderedJson, is_opencode_config};
5
6/// The object MCP servers live under: `mcpServers` in a harness's MCP
7/// config file, `mcp` in OpenCode's `opencode.json`, `mcp_servers` in
8/// Codex's `config.toml`.
9pub fn servers_key(mcp_config_path: &Path) -> &'static str {
10    if is_toml(mcp_config_path) {
11        "mcp_servers"
12    } else if is_opencode(mcp_config_path) {
13        "mcp"
14    } else {
15        "mcpServers"
16    }
17}
18
19/// Whether the file is a TOML config, such as Codex's `.codex/config.toml`.
20/// A user hand-edits that file, so it is edited in place with its comments
21/// and formatting kept, rather than re-serialised.
22pub fn is_toml(mcp_config_path: &Path) -> bool {
23    mcp_config_path
24        .extension()
25        .is_some_and(|extension| extension == "toml")
26}
27
28/// Settings on a Codex `[mcp_servers.<id>]` table that policy rules own
29/// (`crate::policy`): a server entry never writes them, a rewrite of the
30/// entry keeps them, and the entry's hash leaves them out.
31const POLICY_KEYS: [&str; 2] = ["disabled_tools", "tools"];
32
33/// The `mcp_servers.<id>` table of a TOML config as a JSON value, for the
34/// same hash `managed_mcp_entry_baseline` computes from the adapter's
35/// entry, without the settings policy rules own. `Ok(None)` when the file
36/// or the entry is absent; an error when the file is not TOML.
37pub fn toml_entry(mcp_config_path: &Path, server_id: &str) -> Result<Option<serde_json::Value>> {
38    if !mcp_config_path.is_file() {
39        return Ok(None);
40    }
41    let raw = std::fs::read_to_string(mcp_config_path)?;
42    let document: toml::Value = toml::from_str(&raw)
43        .map_err(|error| corrupt(mcp_config_path, &format!("not valid TOML: {error}")))?;
44    let Some(entry) = document
45        .get("mcp_servers")
46        .and_then(|servers| servers.get(server_id))
47    else {
48        return Ok(None);
49    };
50    let mut entry = serde_json::to_value(entry)?;
51    if let Some(table) = entry.as_object_mut() {
52        for key in POLICY_KEYS {
53            table.remove(key);
54        }
55    }
56    Ok(Some(entry))
57}
58
59fn read_toml_document(mcp_config_path: &Path) -> Result<toml_edit::DocumentMut> {
60    let raw = if mcp_config_path.exists() {
61        std::fs::read_to_string(mcp_config_path)?
62    } else {
63        String::new()
64    };
65    let document: toml_edit::DocumentMut = raw
66        .parse()
67        .map_err(|error| corrupt(mcp_config_path, &format!("not valid TOML: {error}")))?;
68    if document
69        .get("mcp_servers")
70        .is_some_and(|servers| !servers.is_table_like())
71    {
72        return Err(corrupt(
73            mcp_config_path,
74            "field 'mcp_servers' must be a table",
75        ));
76    }
77    Ok(document)
78}
79
80/// A JSON entry as a TOML table: nested objects become inline tables, so a
81/// server reads as one `[mcp_servers.<id>]` block with `env = { ... }`
82/// inside it, the shape Codex's documentation shows.
83fn toml_table_from_json(
84    entry: &serde_json::Value,
85    mcp_config_path: &Path,
86) -> Result<toml_edit::Table> {
87    let serde_json::Value::Object(fields) = entry else {
88        return Err(corrupt(mcp_config_path, "an MCP entry must be an object"));
89    };
90    // The launch line first, as Codex's own examples write it; the JSON
91    // value's keys are sorted, and the hash `managed_mcp_entry_baseline`
92    // records is over that value, so this order is presentation only.
93    const FIRST: [&str; 3] = ["command", "args", "url"];
94    let mut table = toml_edit::Table::new();
95    let ordered = FIRST
96        .iter()
97        .filter_map(|key| fields.get_key_value(*key))
98        .chain(
99            fields
100                .iter()
101                .filter(|(key, _)| !FIRST.contains(&key.as_str())),
102        );
103    for (key, value) in ordered {
104        table.insert(
105            key,
106            toml_edit::Item::Value(toml_value_from_json(value, mcp_config_path)?),
107        );
108    }
109    Ok(table)
110}
111
112fn toml_value_from_json(
113    value: &serde_json::Value,
114    mcp_config_path: &Path,
115) -> Result<toml_edit::Value> {
116    Ok(match value {
117        serde_json::Value::String(text) => toml_edit::Value::from(text.as_str()),
118        serde_json::Value::Bool(flag) => toml_edit::Value::from(*flag),
119        serde_json::Value::Number(number) => match (number.as_i64(), number.as_f64()) {
120            (Some(integer), _) => toml_edit::Value::from(integer),
121            (None, Some(float)) => toml_edit::Value::from(float),
122            (None, None) => {
123                return Err(corrupt(
124                    mcp_config_path,
125                    "an MCP entry holds a number TOML cannot represent",
126                ));
127            }
128        },
129        serde_json::Value::Array(items) => {
130            let mut array = toml_edit::Array::new();
131            for item in items {
132                array.push(toml_value_from_json(item, mcp_config_path)?);
133            }
134            toml_edit::Value::Array(array)
135        }
136        serde_json::Value::Object(fields) => {
137            let mut inline = toml_edit::InlineTable::new();
138            for (key, value) in fields {
139                inline.insert(key, toml_value_from_json(value, mcp_config_path)?);
140            }
141            toml_edit::Value::InlineTable(inline)
142        }
143        serde_json::Value::Null => {
144            return Err(corrupt(
145                mcp_config_path,
146                "an MCP entry holds a null, which TOML cannot represent",
147            ));
148        }
149    })
150}
151
152/// `register_server` for a TOML config: replace or append the
153/// `[mcp_servers.<id>]` table and leave every other line as written.
154fn register_server_toml(
155    mcp_config_path: &Path,
156    server_id: &str,
157    entry: serde_json::Value,
158    allow_overwrite: bool,
159) -> Result<()> {
160    let mut document = read_toml_document(mcp_config_path)?;
161    let mut table = toml_table_from_json(&entry, mcp_config_path)?;
162    let servers = document
163        .entry("mcp_servers")
164        .or_insert_with(|| {
165            let mut parent = toml_edit::Table::new();
166            parent.set_implicit(true);
167            toml_edit::Item::Table(parent)
168        })
169        .as_table_like_mut()
170        .expect("read_toml_document validates mcp_servers");
171    if !allow_overwrite && servers.contains_key(server_id) {
172        return Err(untracked_server(mcp_config_path, server_id));
173    }
174    if let Some(previous) = servers
175        .get(server_id)
176        .and_then(toml_edit::Item::as_table_like)
177    {
178        for key in POLICY_KEYS {
179            if let Some(item) = previous.get(key) {
180                table.insert(key, item.clone());
181            }
182        }
183    }
184    servers.insert(server_id, toml_edit::Item::Table(table));
185    if let Some(parent) = mcp_config_path.parent() {
186        std::fs::create_dir_all(parent)?;
187    }
188    std::fs::write(mcp_config_path, document.to_string())?;
189    Ok(())
190}
191
192/// `remove_tool` for a TOML config. An emptied `mcp_servers` table is
193/// removed, and a file left with nothing else is removed too.
194fn remove_server_toml(mcp_config_path: &Path, server_id: &str) -> Result<()> {
195    let mut document = read_toml_document(mcp_config_path)?;
196    let Some(servers) = document
197        .get_mut("mcp_servers")
198        .and_then(|item| item.as_table_like_mut())
199    else {
200        return Ok(());
201    };
202    if servers.remove(server_id).is_none() {
203        return Ok(());
204    }
205    if servers.is_empty() {
206        document.remove("mcp_servers");
207    }
208    if document.as_table().is_empty() && document.to_string().trim().is_empty() {
209        std::fs::remove_file(mcp_config_path)?;
210        return Ok(());
211    }
212    std::fs::write(mcp_config_path, document.to_string())?;
213    Ok(())
214}
215
216fn has_server_toml(mcp_config_path: &Path, server_id: &str) -> Result<bool> {
217    let document = read_toml_document(mcp_config_path)?;
218    Ok(document
219        .get("mcp_servers")
220        .and_then(|servers| servers.as_table_like())
221        .is_some_and(|servers| servers.contains_key(server_id)))
222}
223
224/// Whether the file is an OpenCode config, whose other keys (`permission`)
225/// are order-sensitive, so it is read and written keeping key order.
226fn is_opencode(mcp_config_path: &Path) -> bool {
227    mcp_config_path.to_str().is_some_and(is_opencode_config)
228}
229
230pub fn validate_config(mcp_config_path: &Path) -> Result<()> {
231    if is_toml(mcp_config_path) {
232        return read_toml_document(mcp_config_path).map(|_| ());
233    }
234    read_config(mcp_config_path).map(|_| ())
235}
236
237pub fn register_tool(
238    mcp_config_path: &Path,
239    tool_id: &str,
240    command: &str,
241    args: &[String],
242) -> Result<()> {
243    register_server(
244        mcp_config_path,
245        tool_id,
246        serde_json::json!({"command": command, "args": args}),
247        true,
248    )
249}
250
251/// Insert or replace `mcpServers.<id>` with `entry`, leaving every other
252/// key in the file untouched.
253///
254/// With `allow_overwrite = false` an existing entry under the same id is a
255/// hard error: MCP config files are shared ground that users hand-edit, and
256/// for an `mcp-server` capability the JSON entry *is* the product, so
257/// clobbering one Tuff never wrote would violate the never-silently-
258/// overwrite invariant. Callers pass `true` only when the lockfile already
259/// tracks that id for this target.
260pub fn register_server(
261    mcp_config_path: &Path,
262    server_id: &str,
263    entry: serde_json::Value,
264    allow_overwrite: bool,
265) -> Result<()> {
266    if is_toml(mcp_config_path) {
267        return register_server_toml(mcp_config_path, server_id, entry, allow_overwrite);
268    }
269    if is_opencode(mcp_config_path) {
270        return register_server_ordered(mcp_config_path, server_id, entry, allow_overwrite);
271    }
272    let key = servers_key(mcp_config_path);
273    let mut config = read_config(mcp_config_path)?;
274    let config_object = config
275        .as_object_mut()
276        .expect("read_config returns a JSON object");
277    let servers = config_object
278        .entry(key)
279        .or_insert_with(|| serde_json::json!({}))
280        .as_object_mut()
281        .expect("read_config validates the servers object");
282
283    if !allow_overwrite && servers.contains_key(server_id) {
284        return Err(untracked_server(mcp_config_path, server_id));
285    }
286
287    servers.insert(server_id.to_string(), entry);
288
289    if let Some(parent) = mcp_config_path.parent() {
290        std::fs::create_dir_all(parent)?;
291    }
292    write_config(mcp_config_path, &config)
293}
294
295fn untracked_server(mcp_config_path: &Path, server_id: &str) -> TuffError {
296    TuffError::refused(format!(
297        "refusing to overwrite untracked MCP server '{}' in {}",
298        server_id,
299        mcp_config_path.display()
300    ))
301    .with_hint("remove it by hand, or choose a different capability id")
302}
303
304/// `register_server` for an OpenCode config. Every other key keeps its
305/// position, since OpenCode reads the order of `permission` rules as
306/// precedence, and a new file starts with OpenCode's `$schema`.
307fn register_server_ordered(
308    mcp_config_path: &Path,
309    server_id: &str,
310    entry: serde_json::Value,
311    allow_overwrite: bool,
312) -> Result<()> {
313    let mut root = read_ordered(mcp_config_path)?;
314    let OrderedJson::Object(object) = &mut root else {
315        return Err(corrupt(mcp_config_path, "root must be a JSON object"));
316    };
317    let servers = object
318        .entry("mcp".to_string())
319        .or_insert_with(|| OrderedJson::Object(indexmap::IndexMap::new()));
320    let OrderedJson::Object(servers) = servers else {
321        return Err(corrupt(
322            mcp_config_path,
323            "field 'mcp' must be a JSON object",
324        ));
325    };
326    if !allow_overwrite && servers.contains_key(server_id) {
327        return Err(untracked_server(mcp_config_path, server_id));
328    }
329    let entry: OrderedJson = serde_json::from_value(entry)?;
330    match servers.get_mut(server_id) {
331        Some(existing) => *existing = entry,
332        None => {
333            servers.insert(server_id.to_string(), entry);
334        }
335    }
336    if let Some(parent) = mcp_config_path.parent() {
337        std::fs::create_dir_all(parent)?;
338    }
339    write_ordered(mcp_config_path, &root)
340}
341
342/// `remove_tool` for an OpenCode config. An `mcp` object this call empties
343/// is removed, and a file left holding only `$schema` is removed too.
344fn remove_server_ordered(mcp_config_path: &Path, server_id: &str) -> Result<()> {
345    let mut root = read_ordered(mcp_config_path)?;
346    let OrderedJson::Object(object) = &mut root else {
347        return Err(corrupt(mcp_config_path, "root must be a JSON object"));
348    };
349    let Some(OrderedJson::Object(servers)) = object.get_mut("mcp") else {
350        return Ok(());
351    };
352    if servers.shift_remove(server_id).is_none() {
353        return Ok(());
354    }
355    if servers.is_empty() {
356        object.shift_remove("mcp");
357    }
358    if object.keys().all(|key| key == "$schema") {
359        std::fs::remove_file(mcp_config_path)?;
360        return Ok(());
361    }
362    write_ordered(mcp_config_path, &root)
363}
364
365fn read_ordered(mcp_config_path: &Path) -> Result<OrderedJson> {
366    let raw = if mcp_config_path.exists() {
367        std::fs::read_to_string(mcp_config_path)?
368    } else {
369        String::new()
370    };
371    if raw.trim().is_empty() {
372        return Ok(OrderedJson::Object(indexmap::IndexMap::from([(
373            "$schema".to_string(),
374            OrderedJson::Scalar(serde_json::Value::String(OPENCODE_SCHEMA.to_string())),
375        )])));
376    }
377    let root: OrderedJson =
378        serde_json::from_str(&raw).map_err(|error| corrupt(mcp_config_path, &error.to_string()))?;
379    let OrderedJson::Object(object) = &root else {
380        return Err(corrupt(mcp_config_path, "root must be a JSON object"));
381    };
382    if object
383        .get("mcp")
384        .is_some_and(|servers| !matches!(servers, OrderedJson::Object(_)))
385    {
386        return Err(corrupt(
387            mcp_config_path,
388            "field 'mcp' must be a JSON object",
389        ));
390    }
391    Ok(root)
392}
393
394fn write_ordered(mcp_config_path: &Path, root: &OrderedJson) -> Result<()> {
395    std::fs::write(mcp_config_path, serde_json::to_string_pretty(root)? + "\n")?;
396    Ok(())
397}
398
399fn corrupt(mcp_config_path: &Path, detail: &str) -> TuffError {
400    TuffError::corrupt(format!(
401        "invalid MCP config at {}: {detail}",
402        mcp_config_path.display()
403    ))
404}
405
406/// Whether `mcpServers.<id>` already exists. Used as a preflight so an
407/// install can refuse *before* writing anything, rather than discovering the
408/// collision after the capability's files are already on disk.
409pub fn has_server(mcp_config_path: &Path, server_id: &str) -> Result<bool> {
410    if !mcp_config_path.exists() {
411        return Ok(false);
412    }
413    if is_toml(mcp_config_path) {
414        return has_server_toml(mcp_config_path, server_id);
415    }
416    let config = read_config(mcp_config_path)?;
417    Ok(config
418        .get(servers_key(mcp_config_path))
419        .and_then(serde_json::Value::as_object)
420        .is_some_and(|servers| servers.contains_key(server_id)))
421}
422
423pub fn remove_tool(mcp_config_path: &Path, tool_id: &str) -> Result<()> {
424    if !mcp_config_path.exists() {
425        return Ok(());
426    }
427    if is_toml(mcp_config_path) {
428        return remove_server_toml(mcp_config_path, tool_id);
429    }
430    if is_opencode(mcp_config_path) {
431        return remove_server_ordered(mcp_config_path, tool_id);
432    }
433
434    let key = servers_key(mcp_config_path);
435    let mut config = read_config(mcp_config_path)?;
436    let Some(servers) = config
437        .as_object_mut()
438        .and_then(|object| object.get_mut(key))
439        .and_then(serde_json::Value::as_object_mut)
440    else {
441        return Ok(());
442    };
443    if servers.remove(tool_id).is_none() {
444        return Ok(());
445    }
446
447    write_config(mcp_config_path, &config)
448}
449
450fn read_config(mcp_config_path: &Path) -> Result<serde_json::Value> {
451    let config = if mcp_config_path.exists() {
452        let raw = std::fs::read_to_string(mcp_config_path)?;
453        if raw.trim().is_empty() {
454            serde_json::json!({})
455        } else {
456            serde_json::from_str(&raw).map_err(|error| {
457                TuffError::corrupt(format!(
458                    "invalid MCP config at {}: {error}",
459                    mcp_config_path.display()
460                ))
461            })?
462        }
463    } else {
464        serde_json::json!({})
465    };
466
467    let object = config.as_object().ok_or_else(|| {
468        TuffError::corrupt(format!(
469            "invalid MCP config at {}: root must be a JSON object",
470            mcp_config_path.display()
471        ))
472    })?;
473    let key = servers_key(mcp_config_path);
474    if object.get(key).is_some_and(|servers| !servers.is_object()) {
475        return Err(TuffError::corrupt(format!(
476            "invalid MCP config at {}: field '{key}' must be a JSON object",
477            mcp_config_path.display()
478        )));
479    }
480
481    Ok(config)
482}
483
484fn write_config(mcp_config_path: &Path, config: &serde_json::Value) -> Result<()> {
485    std::fs::write(
486        mcp_config_path,
487        serde_json::to_string_pretty(config)? + "\n",
488    )?;
489    Ok(())
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn register_rejects_malformed_json_without_changing_it() {
498        let temp = tempfile::tempdir().expect("tempdir");
499        let path = temp.path().join("mcp.json");
500        let original = b"{ not-json\n";
501        std::fs::write(&path, original).expect("write config");
502
503        let error = register_tool(&path, "demo", "python", &[]).expect_err("invalid config");
504
505        assert!(error.to_string().contains("invalid MCP config"));
506        assert_eq!(std::fs::read(&path).expect("read config"), original);
507    }
508
509    #[test]
510    fn remove_rejects_malformed_json_without_changing_it() {
511        let temp = tempfile::tempdir().expect("tempdir");
512        let path = temp.path().join("mcp.json");
513        let original = b"[invalid";
514        std::fs::write(&path, original).expect("write config");
515
516        let error = remove_tool(&path, "demo").expect_err("invalid config");
517
518        assert!(error.to_string().contains("invalid MCP config"));
519        assert_eq!(std::fs::read(&path).expect("read config"), original);
520    }
521
522    #[test]
523    fn rewriting_a_toml_server_keeps_the_settings_policy_rules_own() {
524        let temp = tempfile::tempdir().expect("tempdir");
525        let path = temp.path().join("config.toml");
526        register_server(
527            &path,
528            "github",
529            serde_json::json!({"command": "old"}),
530            false,
531        )
532        .unwrap();
533        let raw = std::fs::read_to_string(&path).unwrap();
534        std::fs::write(
535            &path,
536            format!("{raw}disabled_tools = [\"delete_repo\"]\n\n[mcp_servers.github.tools.merge]\napproval_mode = \"prompt\"\n"),
537        )
538        .unwrap();
539        assert_eq!(
540            toml_entry(&path, "github").unwrap(),
541            Some(serde_json::json!({"command": "old"})),
542            "the entry's hash leaves policy settings out"
543        );
544
545        register_server(&path, "github", serde_json::json!({"command": "new"}), true).unwrap();
546        let parsed: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
547        let github = &parsed["mcp_servers"]["github"];
548        assert_eq!(github["command"].as_str(), Some("new"));
549        assert_eq!(github["disabled_tools"][0].as_str(), Some("delete_repo"));
550        assert_eq!(
551            github["tools"]["merge"]["approval_mode"].as_str(),
552            Some("prompt")
553        );
554    }
555
556    #[test]
557    fn a_toml_config_keeps_the_users_lines_and_loses_mcp_servers_when_emptied() {
558        let temp = tempfile::tempdir().expect("tempdir");
559        let path = temp.path().join(".codex").join("config.toml");
560        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
561        let original = "# Codex project settings\nmodel = \"gpt-5-codex\"\n\n[mcp_servers.mine]\ncommand = \"node\"\nargs = [\"server.js\"]\n";
562        std::fs::write(&path, original).unwrap();
563
564        register_server(
565            &path,
566            "everything",
567            serde_json::json!({
568                "command": "npx",
569                "args": ["-y", "pkg"],
570                "env_vars": ["TOKEN"],
571                "env_http_headers": {"X-Key": "KEY"},
572            }),
573            false,
574        )
575        .unwrap();
576        let raw = std::fs::read_to_string(&path).unwrap();
577        assert!(
578            raw.starts_with("# Codex project settings\nmodel = \"gpt-5-codex\"\n"),
579            "{raw}"
580        );
581        assert!(
582            raw.contains("[mcp_servers.mine]\ncommand = \"node\""),
583            "{raw}"
584        );
585        assert!(raw.contains("[mcp_servers.everything]"), "{raw}");
586        assert!(raw.contains("env_vars = [\"TOKEN\"]"), "{raw}");
587        assert!(raw.contains("X-Key = \"KEY\""), "{raw}");
588        assert!(
589            raw.contains("[mcp_servers.everything]\ncommand = \"npx\"\nargs = [\"-y\", \"pkg\"]\n"),
590            "the launch line comes first: {raw}"
591        );
592        assert!(has_server(&path, "everything").unwrap());
593        assert_eq!(
594            toml_entry(&path, "everything").unwrap(),
595            Some(serde_json::json!({
596                "command": "npx",
597                "args": ["-y", "pkg"],
598                "env_vars": ["TOKEN"],
599                "env_http_headers": {"X-Key": "KEY"},
600            })),
601            "the entry reads back as the value it was written from"
602        );
603        let error =
604            register_server(&path, "mine", serde_json::json!({}), false).expect_err("untracked");
605        assert!(
606            error.to_string().contains("refusing to overwrite"),
607            "{error}"
608        );
609
610        remove_tool(&path, "everything").unwrap();
611        let after = std::fs::read_to_string(&path).unwrap();
612        assert!(after.contains("[mcp_servers.mine]"), "{after}");
613        assert!(!after.contains("everything"), "{after}");
614        assert!(after.starts_with("# Codex project settings"), "{after}");
615
616        // A file Tuff created is removed once its last server goes.
617        let fresh = temp.path().join("fresh").join("config.toml");
618        register_server(
619            &fresh,
620            "s",
621            serde_json::json!({"command": "x", "args": []}),
622            false,
623        )
624        .unwrap();
625        assert!(
626            std::fs::read_to_string(&fresh)
627                .unwrap()
628                .contains("[mcp_servers.s]")
629        );
630        remove_tool(&fresh, "s").unwrap();
631        assert!(!fresh.exists());
632
633        let bad = temp.path().join("bad").join("config.toml");
634        std::fs::create_dir_all(bad.parent().unwrap()).unwrap();
635        std::fs::write(&bad, "mcp_servers = 3\n").unwrap();
636        let error = validate_config(&bad).expect_err("not a table");
637        assert!(
638            error.to_string().contains("'mcp_servers' must be a table"),
639            "{error}"
640        );
641    }
642
643    #[test]
644    fn an_opencode_config_keeps_its_key_order_and_loses_mcp_when_emptied() {
645        let temp = tempfile::tempdir().expect("tempdir");
646        let path = temp.path().join(".opencode").join("opencode.json");
647        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
648        // Rule order is precedence in OpenCode: "*" must stay before "git push *".
649        let original = "{\n  \"$schema\": \"https://opencode.ai/config.json\",\n  \"permission\": {\n    \"bash\": {\n      \"*\": \"allow\",\n      \"git push *\": \"allow\"\n    }\n  },\n  \"model\": \"x\"\n}\n";
650        std::fs::write(&path, original).unwrap();
651
652        register_server(
653            &path,
654            "everything",
655            serde_json::json!({"type": "local", "command": ["npx", "-y", "pkg"]}),
656            false,
657        )
658        .unwrap();
659        let raw = std::fs::read_to_string(&path).unwrap();
660        let position = |needle: &str| raw.find(needle).unwrap_or_else(|| panic!("{needle}"));
661        assert!(
662            position("\"$schema\"") < position("\"permission\""),
663            "{raw}"
664        );
665        assert!(
666            position("\"*\": \"allow\"") < position("\"git push *\""),
667            "{raw}"
668        );
669        assert!(
670            position("\"model\"") < position("\"mcp\""),
671            "new keys go last: {raw}"
672        );
673        assert!(!raw.contains("mcpServers"), "{raw}");
674        assert!(has_server(&path, "everything").unwrap());
675
676        let error = register_server(&path, "everything", serde_json::json!({}), false)
677            .expect_err("untracked entry");
678        assert!(
679            error.to_string().contains("refusing to overwrite"),
680            "{error}"
681        );
682
683        remove_tool(&path, "everything").unwrap();
684        let after = std::fs::read_to_string(&path).unwrap();
685        assert!(
686            !after.contains("\"mcp\""),
687            "an emptied mcp object is removed: {after}"
688        );
689        assert!(after.contains("\"model\": \"x\""), "{after}");
690
691        // A file Tuff created holds only $schema once its last server goes.
692        let fresh = temp.path().join("fresh").join("opencode.json");
693        register_server(&fresh, "s", serde_json::json!({"type": "local"}), false).unwrap();
694        let created: serde_json::Value =
695            serde_json::from_str(&std::fs::read_to_string(&fresh).unwrap()).unwrap();
696        assert_eq!(created["$schema"], "https://opencode.ai/config.json");
697        remove_tool(&fresh, "s").unwrap();
698        assert!(!fresh.exists());
699    }
700
701    #[test]
702    fn register_preserves_unrelated_fields() {
703        let temp = tempfile::tempdir().expect("tempdir");
704        let path = temp.path().join("mcp.json");
705        std::fs::write(
706            &path,
707            r#"{"custom":{"enabled":true},"mcpServers":{"existing":{"command":"node"}}}"#,
708        )
709        .expect("write config");
710
711        register_tool(&path, "demo", "python", &["server.py".to_string()]).expect("register tool");
712
713        let config: serde_json::Value =
714            serde_json::from_slice(&std::fs::read(path).expect("read config"))
715                .expect("parse config");
716        assert_eq!(config["custom"]["enabled"], true);
717        assert_eq!(config["mcpServers"]["existing"]["command"], "node");
718        assert_eq!(config["mcpServers"]["demo"]["command"], "python");
719    }
720
721    #[test]
722    fn validation_rejects_non_object_mcp_servers() {
723        let temp = tempfile::tempdir().expect("tempdir");
724        let path = temp.path().join("mcp.json");
725        std::fs::write(&path, r#"{"mcpServers":[]}"#).expect("write config");
726
727        let error = validate_config(&path).expect_err("invalid mcpServers");
728
729        assert!(
730            error
731                .to_string()
732                .contains("'mcpServers' must be a JSON object")
733        );
734    }
735
736    #[test]
737    fn register_server_writes_entry_and_preserves_neighbours() {
738        let tmp = tempfile::TempDir::new().unwrap();
739        let path = tmp.path().join("mcp.json");
740        std::fs::write(
741            &path,
742            "{\"custom\":true,\"mcpServers\":{\"other\":{\"command\":\"x\"}}}",
743        )
744        .unwrap();
745
746        register_server(
747            &path,
748            "github",
749            serde_json::json!({"command": "npx", "args": ["-y", "srv"], "env": {"T": "${T}"}}),
750            false,
751        )
752        .unwrap();
753
754        let config: serde_json::Value =
755            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
756        assert_eq!(config["custom"], true);
757        assert_eq!(config["mcpServers"]["other"]["command"], "x");
758        assert_eq!(config["mcpServers"]["github"]["env"]["T"], "${T}");
759        assert!(has_server(&path, "github").unwrap());
760        assert!(!has_server(&path, "missing").unwrap());
761    }
762
763    #[test]
764    fn register_server_refuses_untracked_collision_unless_overwrite_allowed() {
765        let tmp = tempfile::TempDir::new().unwrap();
766        let path = tmp.path().join("mcp.json");
767        let original = "{\"mcpServers\":{\"github\":{\"command\":\"hand\"}}}";
768        std::fs::write(&path, original).unwrap();
769
770        let error = register_server(
771            &path,
772            "github",
773            serde_json::json!({"command": "npx"}),
774            false,
775        )
776        .unwrap_err()
777        .to_string();
778        assert!(
779            error.contains("refusing to overwrite untracked MCP server"),
780            "{error}"
781        );
782        assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
783
784        register_server(&path, "github", serde_json::json!({"command": "npx"}), true).unwrap();
785        let config: serde_json::Value =
786            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
787        assert_eq!(config["mcpServers"]["github"]["command"], "npx");
788    }
789}