//! `oxy mcp` (oxygen#159) end to end: an MCP client talks to the server over //! stdio, lists its tools, reads the repository, commits by path as the //! configured identity, and is refused what it may not do. use serde_json::{json, Value}; use std::io::{BufRead, BufReader, Write}; use std::path::Path; use std::process::{Command, Stdio}; fn oxy(dir: &Path, home: &Path, args: &[&str]) -> i32 { Command::new(env!("CARGO_BIN_EXE_oxy")) .arg("--plain") .args(args) .current_dir(dir) .env("OXY_HOME", home) .env("NO_COLOR", "1") .status() .unwrap() .code() .unwrap_or(-1) } /// Send each request in turn and collect the replies, one per request (the /// notification gets none). fn session(dir: &Path, home: &Path, extra: &[&str], requests: &[Value]) -> Vec { let mut child = Command::new(env!("CARGO_BIN_EXE_oxy")) .arg("mcp") .args(extra) .current_dir(dir) .env("OXY_HOME", home) .env("NO_COLOR", "1") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .unwrap(); let mut stdin = child.stdin.take().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); let mut replies = vec![]; for r in requests { writeln!(stdin, "{r}").unwrap(); stdin.flush().unwrap(); if r.get("id").is_some() { let mut line = String::new(); stdout.read_line(&mut line).unwrap(); replies.push( serde_json::from_str(&line).unwrap_or_else(|e| panic!("not JSON ({e}): {line}")), ); } } drop(stdin); assert!( child.wait().unwrap().success(), "the server ends cleanly when its input does" ); replies } fn call(id: u64, name: &str, args: Value) -> Value { json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": args}}) } #[test] fn an_agent_reads_and_commits_through_mcp() { let tmp = tempfile::tempdir().unwrap(); let home = tmp.path().join("home"); std::fs::create_dir_all(&home).unwrap(); let repo = tmp.path().join("repo"); assert_eq!(oxy(tmp.path(), &home, &["init", repo.to_str().unwrap()]), 0); assert_eq!( oxy( &repo, &home, &[ "identity", "create", "--name", "Ada", "--email", "ada@example.org" ] ), 0 ); assert_eq!( oxy(&repo, &home, &["config", "--user", "core.sign", "change"]), 0 ); std::fs::write(repo.join("a.txt"), "one\n").unwrap(); std::fs::write(repo.join("b.txt"), "two\n").unwrap(); let r = session( &repo, &home, &[], &[ json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}}), json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), call(3, "status", json!({})), call(4, "commit", json!({"message": "everything"})), call(5, "commit", json!({"message": "Add a", "paths": ["a.txt"]})), call(6, "log", json!({"limit": 5})), call(7, "status", json!({})), call(8, "undo", json!({})), call(9, "push", json!({"force": true})), call(10, "cat", json!({"path": "a.txt", "change": "@"})), ], ); let init = &r[0]["result"]; assert_eq!(init["serverInfo"]["name"], "oxy"); let said = init["instructions"].as_str().unwrap(); assert!( said.contains("Ada"), "initialize says who changes are signed as: {said}" ); assert!( said.contains("Destructive tools (undo, forced push) are off"), "{said}" ); let names: Vec<&str> = r[1]["result"]["tools"] .as_array() .unwrap() .iter() .map(|t| t["name"].as_str().unwrap()) .collect(); for t in [ "status", "diff", "log", "show", "cat", "blame", "bookmarks", "commit", "amend", "push", "pull", "tlog_verify", ] { assert!(names.contains(&t), "{t} is offered: {names:?}"); } assert!(!names.contains(&"undo"), "undo needs --allow-destructive"); let before = &r[2]["result"]; assert_eq!(before["isError"], false, "{before}"); assert!( before["structuredContent"].is_object(), "status comes back as JSON: {before}" ); assert!(before["content"][0]["text"] .as_str() .unwrap() .contains("b.txt")); assert_eq!( r[3]["result"]["isError"], true, "a commit names its paths or all: {}", r[3] ); assert!(r[3]["result"]["content"][0]["text"] .as_str() .unwrap() .contains("all: true")); assert_eq!(r[4]["result"]["isError"], false, "commit by path: {}", r[4]); let log = r[5]["result"]["content"][0]["text"].as_str().unwrap(); assert!(log.contains("Add a"), "{log}"); assert!( r[5]["result"]["structuredContent"].is_object(), "a JSON array comes wrapped: {}", r[5] ); let after = r[6]["result"]["content"][0]["text"].as_str().unwrap(); assert!( after.contains("b.txt") && !after.contains("a.txt"), "only a.txt went in: {after}" ); assert!( r[7]["error"]["message"] .as_str() .unwrap() .contains("no tool named undo"), "{}", r[7] ); assert_eq!(r[8]["result"]["isError"], true); assert!(r[8]["result"]["content"][0]["text"] .as_str() .unwrap() .contains("--allow-destructive")); assert_eq!(r[9]["result"]["content"][0]["text"], "one\n"); } #[test] fn destructive_tools_come_with_the_flag_and_a_bad_directory_is_refused() { let tmp = tempfile::tempdir().unwrap(); let home = tmp.path().join("home"); std::fs::create_dir_all(&home).unwrap(); let repo = tmp.path().join("repo"); assert_eq!(oxy(tmp.path(), &home, &["init", repo.to_str().unwrap()]), 0); let r = session( &repo, &home, &["--allow-destructive"], &[json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})], ); let undo = r[0]["result"]["tools"] .as_array() .unwrap() .iter() .find(|t| t["name"] == "undo") .cloned(); assert_eq!( undo.expect("undo is offered")["annotations"]["destructiveHint"], true ); let out = Command::new(env!("CARGO_BIN_EXE_oxy")) .args(["mcp", tmp.path().to_str().unwrap()]) .env("OXY_HOME", &home) .output() .unwrap(); assert!(!out.status.success()); assert!(String::from_utf8_lossy(&out.stderr).contains("not the root of an Oxygen repository")); }