1use 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#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Unregistered {
33 Taken(String),
36 Unreadable(String, String),
38 Engine(String),
40}
41
42const ABSENT: i32 = 3;
44
45pub(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
59pub(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
79pub fn register(engine: &Engine, container: &str, harness: HarnessKind, token: &str) -> Result<(), Unregistered> {
94 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
107pub(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#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum Merged {
120 Unchanged,
122 Write(String),
124 Taken,
126 Unreadable(String),
128}
129
130#[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
149fn claude_entry() -> Value {
152 json!({ "type": "stdio", "command": "node", "args": [script_in_container()] })
153}
154
155fn opencode_entry() -> Value {
157 json!({ "type": "local", "command": ["node", script_in_container()], "enabled": true })
158}
159
160fn gemini_entry() -> Value {
163 json!({ "command": "node", "args": [script_in_container()], "trust": true })
164}
165
166fn kimi_entry() -> Value {
170 json!({ "command": "node", "args": [script_in_container()] })
171}
172
173fn antigravity_entry(token: &str) -> Value {
181 json!({ "command": "node", "args": [script_in_container()], "env": { TOKEN_VARIABLE: token } })
182}
183
184fn same_server(found: &Value, ours: &Value) -> bool {
187 ["command", "args"].iter().all(|key| found.get(key) == ours.get(key))
188}
189
190fn 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 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
227fn 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
237fn codex_table() -> String {
239 format!("[mcp_servers.{SERVER_NAME}]\ncommand = \"node\"\nargs = [\"{}\"]\n", script_in_container())
240}
241
242fn 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 match DeTable::parse(&written) {
264 Ok(_) => Merged::Write(written),
265 Err(_) => Merged::Taken,
266 }
267}
268
269fn 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
285pub(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 const TOKEN: &str = "9f2c41b7e08a5d36c1740be92a3f58dd";
301
302 #[test]
303 fn registering_in_claude_codes_file_keeps_the_answers_to_its_first_questions() {
304 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 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 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 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 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}