//! `oxy mcp` (oxygen#159): a Model Context Protocol server over stdio for //! the repository it starts in, so an agent works through typed tools //! instead of parsing a shell's output. //! //! Each tool runs this same oxy binary with the command's `--json`, in the //! repository, with the server's environment: so a tool returns exactly //! what the command does, and signs as whatever `OXY_HOME` configures. In //! an agent home every change is sealed via the agent, with no environment //! variable to forget per call. Tools that can lose work (undo, a forced //! push) are offered only with `--allow-destructive`. use anyhow::{bail, Result}; use oxy_core::mcp::{self, arg_str, arg_strings, ServerInfo, Tool, ToolOutput, Tools}; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; /// A tool's text is cut here; its structured result is then left out. const MAX_TEXT: usize = 256 * 1024; type Build = fn(&Value, bool) -> Result, String>; struct Spec { name: &'static str, description: &'static str, schema: fn() -> Value, build: Build, json: bool, read_only: bool, destructive: bool, } fn obj(props: Value, required: &[&str]) -> Value { json!({ "type": "object", "properties": props, "required": required, "additionalProperties": false }) } fn none() -> Value { obj(json!({}), &[]) } /// An option's value as `--flag=value`, so a value starting with `-` is /// never read as another flag. fn opt(flag: &str, value: &str) -> String { format!("{flag}={value}") } /// Positional values after `--`, for the same reason. fn positional(out: &mut Vec, values: Vec) { if !values.is_empty() { out.push("--".into()); out.extend(values); } } fn required<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> { arg_str(args, key) .filter(|s| !s.trim().is_empty()) .ok_or_else(|| format!("{key} is required")) } /// Paths to act on, or every path when `all` is true; never neither, so a /// commit or amend is always a deliberate choice of what goes in. fn paths_or_all(args: &Value) -> Result, String> { let paths = arg_strings(args, "paths")?; let all = args.get("all").and_then(Value::as_bool).unwrap_or(false); match (paths.is_empty(), all) { (false, false) => Ok(paths), (true, true) => Ok(vec![]), (false, true) => Err("give paths or all: true, not both".into()), (true, false) => { Err("give the paths to include, or all: true for every changed path".into()) } } } fn limit(args: &Value, default: u64) -> String { args.get("limit") .and_then(Value::as_u64) .unwrap_or(default) .clamp(1, 1000) .to_string() } fn specs() -> Vec { vec![ Spec { name: "status", description: "The working copy: the bookmark, changed paths, and every conflict with its kind and the changes behind it.", schema: none, build: |_, _| Ok(vec!["status".into(), "--json".into()]), json: true, read_only: true, destructive: false, }, Spec { name: "diff", description: "Uncommitted edits in the working copy, or one change's edits when `change` is given (a ChangeId or #hash prefix).", schema: || obj(json!({ "change": { "type": "string" } }), &[]), build: |a, _| { let mut v = vec!["diff".into(), "--json".into()]; positional(&mut v, arg_str(a, "change").map(|c| vec![c.to_string()]).unwrap_or_default()); Ok(v) }, json: true, read_only: true, destructive: false, }, Spec { name: "log", description: "Changes, newest first: a revset (`main`, `@`, …) or the current bookmark, optionally only those touching `path`; `limit` defaults to 20.", schema: || obj(json!({ "revset": { "type": "string" }, "path": { "type": "string" }, "limit": { "type": "integer", "minimum": 1 } }), &[]), build: |a, _| { let mut v = vec!["log".into(), "--json".into(), opt("--limit", &limit(a, 20))]; if let Some(r) = arg_str(a, "revset") { v.push(opt("--revset", r)); } if let Some(p) = arg_str(a, "path") { v.push(opt("--path", p)); } Ok(v) }, json: true, read_only: true, destructive: false, }, Spec { name: "show", description: "One change: its message, author, signatures and edits (`stat: true` for files and line counts only). `change` defaults to @, the working copy.", schema: || obj(json!({ "change": { "type": "string" }, "stat": { "type": "boolean" } }), &[]), build: |a, _| { let mut v = vec!["show".into(), "--json".into()]; if a.get("stat").and_then(Value::as_bool).unwrap_or(false) { v.push("--stat".into()); } positional(&mut v, vec![arg_str(a, "change").unwrap_or("@").to_string()]); Ok(v) }, json: true, read_only: true, destructive: false, }, Spec { name: "cat", description: "A file's content as a bookmark, tag or change has it (`change`), or as committed on the working copy's base.", schema: || obj(json!({ "path": { "type": "string" }, "change": { "type": "string" } }), &["path"]), build: |a, _| { let mut v = vec!["cat".into()]; if let Some(c) = arg_str(a, "change") { v.push(opt("--change", c)); } positional(&mut v, vec![required(a, "path")?.to_string()]); Ok(v) }, json: false, read_only: true, destructive: false, }, Spec { name: "blame", description: "Which change wrote each line of a file, with its line atoms.", schema: || obj(json!({ "path": { "type": "string" } }), &["path"]), build: |a, _| { let mut v = vec!["blame".into(), "--json".into()]; positional(&mut v, vec![required(a, "path")?.to_string()]); Ok(v) }, json: true, read_only: true, destructive: false, }, Spec { name: "bookmarks", description: "Bookmarks with their heads and how far each is ahead of or behind its remote; `all: true` adds remotes' bookmarks.", schema: || obj(json!({ "all": { "type": "boolean" } }), &[]), build: |a, _| { let mut v = vec!["bookmark".into(), "list".into(), "--json".into()]; if a.get("all").and_then(Value::as_bool).unwrap_or(false) { v.push("--all".into()); } Ok(v) }, json: true, read_only: true, destructive: false, }, Spec { name: "tags", description: "Tags with their states, messages and signers.", schema: none, build: |_, _| Ok(vec!["tag".into(), "list".into(), "--json".into()]), json: true, read_only: true, destructive: false, }, Spec { name: "conflicts", description: "Which conflicts of the working copy's base the current files resolve, and which remain.", schema: none, build: |_, _| Ok(vec!["resolve".into(), "--json".into()]), json: true, read_only: true, destructive: false, }, Spec { name: "op_log", description: "The operation log, newest first (what undo would take back); `limit` defaults to 20.", schema: || obj(json!({ "limit": { "type": "integer", "minimum": 1 } }), &[]), build: |a, _| Ok(vec!["op".into(), "log".into(), "--json".into(), opt("--limit", &limit(a, 20))]), json: true, read_only: true, destructive: false, }, Spec { name: "identity", description: "Who changes are signed as here: the author, the key, delegates and the signer (an agent home signs keyless, via the agent).", schema: none, build: |_, _| Ok(vec!["identity".into(), "show".into()]), json: false, read_only: true, destructive: false, }, Spec { name: "commit", description: "Record a change with a message: only `paths`, or every changed path with `all: true`. Signed as this server's identity.", schema: || obj(json!({ "message": { "type": "string" }, "paths": { "type": "array", "items": { "type": "string" } }, "all": { "type": "boolean" } }), &["message"]), build: |a, _| { let mut v = vec!["commit".into(), opt("--message", required(a, "message")?)]; positional(&mut v, paths_or_all(a)?); Ok(v) }, json: false, read_only: false, destructive: false, }, Spec { name: "amend", description: "Fold edits into the change the working copy sits on: only `paths`, or all with `all: true`; `message` replaces its message too.", schema: || obj(json!({ "message": { "type": "string" }, "paths": { "type": "array", "items": { "type": "string" } }, "all": { "type": "boolean" } }), &[]), build: |a, _| { let mut v = vec!["amend".into()]; if let Some(m) = arg_str(a, "message") { v.push(opt("--message", m)); } positional(&mut v, paths_or_all(a)?); Ok(v) }, json: false, read_only: false, destructive: false, }, Spec { name: "describe", description: "Change a head's message (`change` defaults to the one the working copy sits on).", schema: || obj(json!({ "message": { "type": "string" }, "change": { "type": "string" } }), &["message"]), build: |a, _| { let mut v = vec!["describe".into(), "--json".into(), opt("--message", required(a, "message")?)]; positional(&mut v, arg_str(a, "change").map(|c| vec![c.to_string()]).unwrap_or_default()); Ok(v) }, json: true, read_only: false, destructive: false, }, Spec { name: "fetch", description: "Fetch from a remote (default origin) without moving local bookmarks.", schema: || obj(json!({ "remote": { "type": "string" } }), &[]), build: |a, _| remote_cmd("fetch", a), json: true, read_only: false, destructive: false, }, Spec { name: "pull", description: "Fetch from a remote (default origin) and bring the current bookmark and working copy up to date.", schema: || obj(json!({ "remote": { "type": "string" } }), &[]), build: |a, _| remote_cmd("pull", a), json: true, read_only: false, destructive: false, }, Spec { name: "push", description: "Push the current bookmark (or `bookmark`) to a remote (default origin). Changes are sealed at push as this server's identity. `force` (drop heads the remote has) needs the server started with --allow-destructive.", schema: || obj(json!({ "remote": { "type": "string" }, "bookmark": { "type": "string" }, "force": { "type": "boolean" } }), &[]), build: |a, destructive| { let mut v = vec!["push".to_string(), "--json".into()]; if let Some(b) = arg_str(a, "bookmark") { v.push(opt("--bookmark", b)); } if a.get("force").and_then(Value::as_bool).unwrap_or(false) { if !destructive { return Err("a forced push can drop the remote's heads; start oxy mcp with --allow-destructive to allow it".into()); } v.push("--force".into()); } positional(&mut v, arg_str(a, "remote").map(|r| vec![r.to_string()]).unwrap_or_default()); Ok(v) }, json: true, read_only: false, destructive: false, }, Spec { name: "tlog_verify", description: "Check the hub's transparency log: that it shows everyone the same history, with this repository's entries and each witness's cosignature.", schema: none, build: |_, _| Ok(vec!["tlog".into(), "verify".into(), "--json".into()]), json: true, read_only: true, destructive: false, }, Spec { name: "undo", description: "Take back the last operation (see op_log). Offered only when the server was started with --allow-destructive.", schema: none, build: |_, _| Ok(vec!["undo".into()]), json: false, read_only: false, destructive: true, }, ] } fn remote_cmd(command: &str, a: &Value) -> Result, String> { let mut v = vec![command.to_string(), "--json".into()]; positional( &mut v, arg_str(a, "remote") .map(|r| vec![r.to_string()]) .unwrap_or_default(), ); Ok(v) } struct OxyTools { exe: PathBuf, dir: PathBuf, allow_destructive: bool, } impl OxyTools { fn offered(&self) -> impl Iterator + '_ { specs() .into_iter() .filter(|s| self.allow_destructive || !s.destructive) } fn run(&self, spec: &Spec, argv: Vec) -> ToolOutput { let out = Command::new(&self.exe) .arg("--plain") .args(&argv) .current_dir(&self.dir) .stdin(Stdio::null()) .output(); let out = match out { Ok(o) => o, Err(e) => return ToolOutput::error(format!("could not run oxy: {e}")), }; let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); if !out.status.success() { let why = if stderr.trim().is_empty() { stdout } else { stderr }; return ToolOutput::error(format!("oxy {} failed: {}", argv.join(" "), why.trim())); } let mut result = ToolOutput::ok(stdout.clone()); if spec.json { result.structured = serde_json::from_str(&stdout).ok(); } else if !stderr.trim().is_empty() && stdout.trim().is_empty() { // Commands that report on stderr (commit's summary) still say what they did. result.text = stderr; } if result.text.len() > MAX_TEXT { let mut cut = MAX_TEXT; while !result.text.is_char_boundary(cut) { cut -= 1; } result.text.truncate(cut); result.text.push_str( "\n… (cut at 256 KiB: narrow the request, e.g. a smaller limit or one path)", ); result.structured = None; } result } } impl Tools for OxyTools { fn list(&self) -> Vec { self.offered() .map(|s| Tool { name: s.name.into(), description: s.description.into(), input_schema: (s.schema)(), read_only: s.read_only, destructive: s.destructive, }) .collect() } fn call(&self, name: &str, args: &Value) -> Option { let spec = self.offered().find(|s| s.name == name)?; Some(match (spec.build)(args, self.allow_destructive) { Ok(argv) => self.run(&spec, argv), Err(why) => ToolOutput::error(why), }) } } /// The first line of `oxy identity show`, said in `initialize` so the client /// knows who its changes will be signed as. fn signer_line(exe: &Path, dir: &Path) -> String { let out = Command::new(exe) .args(["--plain", "identity", "show"]) .current_dir(dir) .stdin(Stdio::null()) .output(); match out { Ok(o) if o.status.success() => { let text = String::from_utf8_lossy(&o.stdout); let who = text.lines().next().unwrap_or("").trim_start_matches(['#', '▍', ' ']).trim().to_string(); let signing = text.lines().find(|l| l.trim_start().starts_with("signing")).map(|l| l.trim().to_string()); match signing { Some(s) => format!("{who}; {s}"), None => who, } } _ => "no identity is configured: commits will be refused until one is (oxy identity create, or an agent home with OXY_HOME)".into(), } } pub fn serve(dir: Option, allow_destructive: bool) -> Result { let dir = match dir { Some(d) => d, None => std::env::current_dir()?, }; if !dir.join(oxy_core::repo::OXY_DIR).is_dir() { bail!("{} is not the root of an Oxygen repository (no {}): start oxy mcp in a checkout's root, or name it", dir.display(), oxy_core::repo::OXY_DIR); } let exe = std::env::current_exe()?; let signer = signer_line(&exe, &dir); let info = ServerInfo { name: "oxy".into(), version: env!("CARGO_PKG_VERSION").into(), instructions: format!( "Oxygen version control for the repository at {}. Changes are signed as: {signer}. Prefer these tools to running oxy in a shell. \ commit and amend take explicit paths (or all: true). {}", dir.display(), if allow_destructive { "Destructive tools (undo, forced push) are enabled: use them only when asked." } else { "Destructive tools (undo, forced push) are off." } ), }; let tools = OxyTools { exe, dir, allow_destructive, }; let stdin = std::io::stdin(); mcp::serve(&info, &tools, stdin.lock(), std::io::stdout().lock())?; Ok(0) } #[cfg(test)] mod tests { use super::*; fn build(name: &str, args: Value, destructive: bool) -> Result, String> { let spec = specs().into_iter().find(|s| s.name == name).unwrap(); (spec.build)(&args, destructive) } #[test] fn values_never_read_as_flags() { assert_eq!( build( "commit", json!({"message": "-m oops", "paths": ["-rf"]}), false ) .unwrap(), ["commit", "--message=-m oops", "--", "-rf"] ); assert_eq!( build("cat", json!({"path": "--help", "change": "main"}), false).unwrap(), ["cat", "--change=main", "--", "--help"] ); assert_eq!( build("log", json!({"limit": 5000}), false).unwrap(), ["log", "--json", "--limit=1000"], "limit is capped" ); } #[test] fn commits_name_what_goes_in() { assert!(build("commit", json!({"message": "x"}), false) .unwrap_err() .contains("all: true")); assert!(build( "commit", json!({"message": "x", "paths": ["a"], "all": true}), false ) .is_err()); assert_eq!( build("commit", json!({"message": "x", "all": true}), false).unwrap(), ["commit", "--message=x"] ); assert!(build("commit", json!({"message": " ", "all": true}), false) .unwrap_err() .contains("message")); } #[test] fn destructive_tools_need_the_flag() { let off = OxyTools { exe: "oxy".into(), dir: ".".into(), allow_destructive: false, }; let on = OxyTools { exe: "oxy".into(), dir: ".".into(), allow_destructive: true, }; assert!(!off.list().iter().any(|t| t.name == "undo")); assert!( off.call("undo", &json!({})).is_none(), "an unoffered tool is unknown" ); assert!(on.list().iter().any(|t| t.name == "undo" && t.destructive)); assert!(build("push", json!({"force": true}), false) .unwrap_err() .contains("--allow-destructive")); assert_eq!( build("push", json!({"force": true, "remote": "earth"}), true).unwrap(), ["push", "--json", "--force", "--", "earth"] ); } }