OXYGENHUB

Coverage / src/cli/mcp.rs 75.2%

237 of 315 lines run, 79 of 123 functions · run #427 on main · run missed

1
//! `oxy mcp` (oxygen#159): a Model Context Protocol server over stdio for
2
//! the repository it starts in, so an agent works through typed tools
3
//! instead of parsing a shell's output.
4
//!
5
//! Each tool runs this same oxy binary with the command's `--json`, in the
6
//! repository, with the server's environment: so a tool returns exactly
7
//! what the command does, and signs as whatever `OXY_HOME` configures. In
8
//! an agent home every change is sealed via the agent, with no environment
9
//! variable to forget per call. Tools that can lose work (undo, a forced
10
//! push) are offered only with `--allow-destructive`.
11
12
use anyhow::{bail, Result};
13
use oxy_core::mcp::{self, arg_str, arg_strings, ServerInfo, Tool, ToolOutput, Tools};
14
use serde_json::{json, Value};
15
use std::path::{Path, PathBuf};
16
use std::process::{Command, Stdio};
17
18
/// A tool's text is cut here; its structured result is then left out.
19
const MAX_TEXT: usize = 256 * 1024;
20
21
type Build = fn(&Value, bool) -> Result<Vec<String>, String>;
22
23
struct Spec {
24
    name: &'static str,
25
    description: &'static str,
26
    schema: fn() -> Value,
27
    build: Build,
28
    json: bool,
29
    read_only: bool,
30
    destructive: bool,
31
}
32
3374×
fn obj(props: Value, required: &[&str]) -> Value {
3474×
    json!({ "type": "object", "properties": props, "required": required, "additionalProperties": false })
3574×
}
36
3722×
fn none() -> Value {
3822×
    obj(json!({}), &[])
3922×
}
40
41
/// An option's value as `--flag=value`, so a value starting with `-` is
42
/// never read as another flag.
4310×
fn opt(flag: &str, value: &str) -> String {
4410×
    format!("{flag}={value}")
4510×
}
46
47
/// Positional values after `--`, for the same reason.
486×
fn positional(out: &mut Vec<String>, values: Vec<String>) {
496×
    if !values.is_empty() {
505×
        out.push("--".into());
515×
        out.extend(values);
525×
    }
536×
}
54
559×
fn required<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
569×
    arg_str(args, key)
579×
        .filter(|s| !s.trim().is_empty())
589×
        .ok_or_else(|| format!("{key} is required"))
599×
}
60
61
/// Paths to act on, or every path when `all` is true; never neither, so a
62
/// commit or amend is always a deliberate choice of what goes in.
636×
fn paths_or_all(args: &Value) -> Result<Vec<String>, String> {
646×
    let paths = arg_strings(args, "paths")?;
656×
    let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);
666×
    match (paths.is_empty(), all) {
672×
        (false, false) => Ok(paths),
681×
        (true, true) => Ok(vec![]),
691×
        (false, true) => Err("give paths or all: true, not both".into()),
70
        (true, false) => {
712×
            Err("give the paths to include, or all: true for every changed path".into())
72
        }
73
    }
746×
}
75
762×
fn limit(args: &Value, default: u64) -> String {
772×
    args.get("limit")
782×
        .and_then(Value::as_u64)
792×
        .unwrap_or(default)
802×
        .clamp(1, 1000)
812×
        .to_string()
822×
}
83
8422×
fn specs() -> Vec<Spec> {
8522×
    vec![
86
        Spec {
87
            name: "status",
88
            description: "The working copy: the bookmark, changed paths, and every conflict with its kind and the changes behind it.",
8922×
            schema: none,
902×
            build: |_, _| Ok(vec!["status".into(), "--json".into()]),
91
            json: true,
92
            read_only: true,
93
            destructive: false,
94
        },
95
        Spec {
96
            name: "diff",
97
            description: "Uncommitted edits in the working copy, or one change's edits when `change` is given (a ChangeId or #hash prefix).",
984×
            schema: || obj(json!({ "change": { "type": "string" } }), &[]),
990×
            build: |a, _| {
1000×
                let mut v = vec!["diff".into(), "--json".into()];
1010×
                positional(&mut v, arg_str(a, "change").map(|c| vec![c.to_string()]).unwrap_or_default());
1020×
                Ok(v)
1030×
            },
104
            json: true,
105
            read_only: true,
106
            destructive: false,
107
        },
108
        Spec {
109
            name: "log",
110
            description: "Changes, newest first: a revset (`main`, `@`, …) or the current bookmark, optionally only those touching `path`; `limit` defaults to 20.",
1114×
            schema: || obj(json!({ "revset": { "type": "string" }, "path": { "type": "string" }, "limit": { "type": "integer", "minimum": 1 } }), &[]),
1122×
            build: |a, _| {
1132×
                let mut v = vec!["log".into(), "--json".into(), opt("--limit", &limit(a, 20))];
1142×
                if let Some(r) = arg_str(a, "revset") {
1150×
                    v.push(opt("--revset", r));
1162×
                }
1172×
                if let Some(p) = arg_str(a, "path") {
1180×
                    v.push(opt("--path", p));
1192×
                }
1202×
                Ok(v)
1212×
            },
122
            json: true,
123
            read_only: true,
124
            destructive: false,
125
        },
126
        Spec {
127
            name: "show",
128
            description: "One change: its message, author, signatures and edits (`stat: true` for files and line counts only). `change` defaults to @, the working copy.",
1294×
            schema: || obj(json!({ "change": { "type": "string" }, "stat": { "type": "boolean" } }), &[]),
1300×
            build: |a, _| {
1310×
                let mut v = vec!["show".into(), "--json".into()];
1320×
                if a.get("stat").and_then(Value::as_bool).unwrap_or(false) {
1330×
                    v.push("--stat".into());
1340×
                }
1350×
                positional(&mut v, vec![arg_str(a, "change").unwrap_or("@").to_string()]);
1360×
                Ok(v)
1370×
            },
138
            json: true,
139
            read_only: true,
140
            destructive: false,
141
        },
142
        Spec {
143
            name: "cat",
144
            description: "A file's content as a bookmark, tag or change has it (`change`), or as committed on the working copy's base.",
1454×
            schema: || obj(json!({ "path": { "type": "string" }, "change": { "type": "string" } }), &["path"]),
1462×
            build: |a, _| {
1472×
                let mut v = vec!["cat".into()];
1482×
                if let Some(c) = arg_str(a, "change") {
1492×
                    v.push(opt("--change", c));
1502×
                }
1512×
                positional(&mut v, vec![required(a, "path")?.to_string()]);
1522×
                Ok(v)
1532×
            },
154
            json: false,
155
            read_only: true,
156
            destructive: false,
157
        },
158
        Spec {
159
            name: "blame",
160
            description: "Which change wrote each line of a file, with its line atoms.",
1614×
            schema: || obj(json!({ "path": { "type": "string" } }), &["path"]),
1620×
            build: |a, _| {
1630×
                let mut v = vec!["blame".into(), "--json".into()];
1640×
                positional(&mut v, vec![required(a, "path")?.to_string()]);
1650×
                Ok(v)
1660×
            },
167
            json: true,
168
            read_only: true,
169
            destructive: false,
170
        },
171
        Spec {
172
            name: "bookmarks",
173
            description: "Bookmarks with their heads and how far each is ahead of or behind its remote; `all: true` adds remotes' bookmarks.",
1744×
            schema: || obj(json!({ "all": { "type": "boolean" } }), &[]),
1750×
            build: |a, _| {
1760×
                let mut v = vec!["bookmark".into(), "list".into(), "--json".into()];
1770×
                if a.get("all").and_then(Value::as_bool).unwrap_or(false) {
1780×
                    v.push("--all".into());
1790×
                }
1800×
                Ok(v)
1810×
            },
182
            json: true,
183
            read_only: true,
184
            destructive: false,
185
        },
186
        Spec {
187
            name: "tags",
188
            description: "Tags with their states, messages and signers.",
18922×
            schema: none,
1900×
            build: |_, _| Ok(vec!["tag".into(), "list".into(), "--json".into()]),
191
            json: true,
192
            read_only: true,
193
            destructive: false,
194
        },
195
        Spec {
196
            name: "conflicts",
197
            description: "Which conflicts of the working copy's base the current files resolve, and which remain.",
19822×
            schema: none,
1990×
            build: |_, _| Ok(vec!["resolve".into(), "--json".into()]),
200
            json: true,
201
            read_only: true,
202
            destructive: false,
203
        },
204
        Spec {
205
            name: "op_log",
206
            description: "The operation log, newest first (what undo would take back); `limit` defaults to 20.",
2074×
            schema: || obj(json!({ "limit": { "type": "integer", "minimum": 1 } }), &[]),
2080×
            build: |a, _| Ok(vec!["op".into(), "log".into(), "--json".into(), opt("--limit", &limit(a, 20))]),
209
            json: true,
210
            read_only: true,
211
            destructive: false,
212
        },
213
        Spec {
214
            name: "identity",
215
            description: "Who changes are signed as here: the author, the key, delegates and the signer (an agent home signs keyless, via the agent).",
21622×
            schema: none,
2170×
            build: |_, _| Ok(vec!["identity".into(), "show".into()]),
218
            json: false,
219
            read_only: true,
220
            destructive: false,
221
        },
222
        Spec {
223
            name: "commit",
224
            description: "Record a change with a message: only `paths`, or every changed path with `all: true`. Signed as this server's identity.",
2254×
            schema: || obj(json!({ "message": { "type": "string" }, "paths": { "type": "array", "items": { "type": "string" } }, "all": { "type": "boolean" } }), &["message"]),
2267×
            build: |a, _| {
2277×
                let mut v = vec!["commit".into(), opt("--message", required(a, "message")?)];
2286×
                positional(&mut v, paths_or_all(a)?);
2293×
                Ok(v)
2307×
            },
231
            json: false,
232
            read_only: false,
233
            destructive: false,
234
        },
235
        Spec {
236
            name: "amend",
237
            description: "Fold edits into the change the working copy sits on: only `paths`, or all with `all: true`; `message` replaces its message too.",
2384×
            schema: || obj(json!({ "message": { "type": "string" }, "paths": { "type": "array", "items": { "type": "string" } }, "all": { "type": "boolean" } }), &[]),
2390×
            build: |a, _| {
2400×
                let mut v = vec!["amend".into()];
2410×
                if let Some(m) = arg_str(a, "message") {
2420×
                    v.push(opt("--message", m));
2430×
                }
2440×
                positional(&mut v, paths_or_all(a)?);
2450×
                Ok(v)
2460×
            },
247
            json: false,
248
            read_only: false,
249
            destructive: false,
250
        },
251
        Spec {
252
            name: "describe",
253
            description: "Change a head's message (`change` defaults to the one the working copy sits on).",
2544×
            schema: || obj(json!({ "message": { "type": "string" }, "change": { "type": "string" } }), &["message"]),
2550×
            build: |a, _| {
2560×
                let mut v = vec!["describe".into(), "--json".into(), opt("--message", required(a, "message")?)];
2570×
                positional(&mut v, arg_str(a, "change").map(|c| vec![c.to_string()]).unwrap_or_default());
2580×
                Ok(v)
2590×
            },
260
            json: true,
261
            read_only: false,
262
            destructive: false,
263
        },
264
        Spec {
265
            name: "fetch",
266
            description: "Fetch from a remote (default origin) without moving local bookmarks.",
2674×
            schema: || obj(json!({ "remote": { "type": "string" } }), &[]),
2680×
            build: |a, _| remote_cmd("fetch", a),
269
            json: true,
270
            read_only: false,
271
            destructive: false,
272
        },
273
        Spec {
274
            name: "pull",
275
            description: "Fetch from a remote (default origin) and bring the current bookmark and working copy up to date.",
2764×
            schema: || obj(json!({ "remote": { "type": "string" } }), &[]),
2770×
            build: |a, _| remote_cmd("pull", a),
278
            json: true,
279
            read_only: false,
280
            destructive: false,
281
        },
282
        Spec {
283
            name: "push",
284
            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.",
2854×
            schema: || obj(json!({ "remote": { "type": "string" }, "bookmark": { "type": "string" }, "force": { "type": "boolean" } }), &[]),
2863×
            build: |a, destructive| {
2873×
                let mut v = vec!["push".to_string(), "--json".into()];
2883×
                if let Some(b) = arg_str(a, "bookmark") {
2890×
                    v.push(opt("--bookmark", b));
2903×
                }
2913×
                if a.get("force").and_then(Value::as_bool).unwrap_or(false) {
2923×
                    if !destructive {
2932×
                        return Err("a forced push can drop the remote's heads; start oxy mcp with --allow-destructive to allow it".into());
2941×
                    }
2951×
                    v.push("--force".into());
2960×
                }
2971×
                positional(&mut v, arg_str(a, "remote").map(|r| vec![r.to_string()]).unwrap_or_default());
2981×
                Ok(v)
2993×
            },
300
            json: true,
301
            read_only: false,
302
            destructive: false,
303
        },
304
        Spec {
305
            name: "tlog_verify",
306
            description: "Check the hub's transparency log: that it shows everyone the same history, with this repository's entries and each witness's cosignature.",
30722×
            schema: none,
3080×
            build: |_, _| Ok(vec!["tlog".into(), "verify".into(), "--json".into()]),
309
            json: true,
310
            read_only: true,
311
            destructive: false,
312
        },
313
        Spec {
314
            name: "undo",
315
            description: "Take back the last operation (see op_log). Offered only when the server was started with --allow-destructive.",
31622×
            schema: none,
3170×
            build: |_, _| Ok(vec!["undo".into()]),
318
            json: false,
319
            read_only: false,
320
            destructive: true,
321
        },
322
    ]
32322×
}
324
3250×
fn remote_cmd(command: &str, a: &Value) -> Result<Vec<String>, String> {
3260×
    let mut v = vec![command.to_string(), "--json".into()];
3270×
    positional(
3280×
        &mut v,
3290×
        arg_str(a, "remote")
3300×
            .map(|r| vec![r.to_string()])
3310×
            .unwrap_or_default(),
332
    );
3330×
    Ok(v)
3340×
}
335
336
struct OxyTools {
337
    exe: PathBuf,
338
    dir: PathBuf,
339
    allow_destructive: bool,
340
}
341
342
impl OxyTools {
34313×
    fn offered(&self) -> impl Iterator<Item = Spec> + '_ {
34413×
        specs()
34513×
            .into_iter()
346165×
            .filter(|s| self.allow_destructive || !s.destructive)
34713×
    }
348
3495×
    fn run(&self, spec: &Spec, argv: Vec<String>) -> ToolOutput {
3505×
        let out = Command::new(&self.exe)
3515×
            .arg("--plain")
3525×
            .args(&argv)
3535×
            .current_dir(&self.dir)
3545×
            .stdin(Stdio::null())
3555×
            .output();
3565×
        let out = match out {
3575×
            Ok(o) => o,
3580×
            Err(e) => return ToolOutput::error(format!("could not run oxy: {e}")),
359
        };
3605×
        let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
3615×
        let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
3625×
        if !out.status.success() {
3630×
            let why = if stderr.trim().is_empty() {
3640×
                stdout
365
            } else {
3660×
                stderr
367
            };
3680×
            return ToolOutput::error(format!("oxy {} failed: {}", argv.join(" "), why.trim()));
3695×
        }
3705×
        let mut result = ToolOutput::ok(stdout.clone());
3715×
        if spec.json {
3723×
            result.structured = serde_json::from_str(&stdout).ok();
3733×
        } else if !stderr.trim().is_empty() && stdout.trim().is_empty() {
3740×
            // Commands that report on stderr (commit's summary) still say what they did.
3750×
            result.text = stderr;
3762×
        }
3775×
        if result.text.len() > MAX_TEXT {
3780×
            let mut cut = MAX_TEXT;
3790×
            while !result.text.is_char_boundary(cut) {
3800×
                cut -= 1;
3810×
            }
3820×
            result.text.truncate(cut);
3830×
            result.text.push_str(
3840×
                "\n… (cut at 256 KiB: narrow the request, e.g. a smaller limit or one path)",
385
            );
3860×
            result.structured = None;
3875×
        }
3885×
        result
3895×
    }
390
}
391
392
impl Tools for OxyTools {
3934×
    fn list(&self) -> Vec<Tool> {
3944×
        self.offered()
3954×
            .map(|s| Tool {
39674×
                name: s.name.into(),
39774×
                description: s.description.into(),
39874×
                input_schema: (s.schema)(),
39974×
                read_only: s.read_only,
40074×
                destructive: s.destructive,
40174×
            })
4024×
            .collect()
4034×
    }
404
4059×
    fn call(&self, name: &str, args: &Value) -> Option<ToolOutput> {
40687×
        let spec = self.offered().find(|s| s.name == name)?;
4077×
        Some(match (spec.build)(args, self.allow_destructive) {
4085×
            Ok(argv) => self.run(&spec, argv),
4092×
            Err(why) => ToolOutput::error(why),
410
        })
4119×
    }
412
}
413
414
/// The first line of `oxy identity show`, said in `initialize` so the client
415
/// knows who its changes will be signed as.
4162×
fn signer_line(exe: &Path, dir: &Path) -> String {
4172×
    let out = Command::new(exe)
4182×
        .args(["--plain", "identity", "show"])
4192×
        .current_dir(dir)
4202×
        .stdin(Stdio::null())
4212×
        .output();
4222×
    match out {
4232×
        Ok(o) if o.status.success() => {
4242×
            let text = String::from_utf8_lossy(&o.stdout);
4252×
            let who = text.lines().next().unwrap_or("").trim_start_matches(['#', '▍', ' ']).trim().to_string();
4266×
            let signing = text.lines().find(|l| l.trim_start().starts_with("signing")).map(|l| l.trim().to_string());
4272×
            match signing {
4281×
                Some(s) => format!("{who}; {s}"),
4291×
                None => who,
430
            }
431
        }
4320×
        _ => "no identity is configured: commits will be refused until one is (oxy identity create, or an agent home with OXY_HOME)".into(),
433
    }
4342×
}
435
4363×
pub fn serve(dir: Option<PathBuf>, allow_destructive: bool) -> Result<i32> {
4373×
    let dir = match dir {
4381×
        Some(d) => d,
4392×
        None => std::env::current_dir()?,
440
    };
4413×
    if !dir.join(oxy_core::repo::OXY_DIR).is_dir() {
4421×
        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);
4432×
    }
4442×
    let exe = std::env::current_exe()?;
4452×
    let signer = signer_line(&exe, &dir);
4462×
    let info = ServerInfo {
4472×
        name: "oxy".into(),
4482×
        version: env!("CARGO_PKG_VERSION").into(),
4492×
        instructions: format!(
450
            "Oxygen version control for the repository at {}. Changes are signed as: {signer}. Prefer these tools to running oxy in a shell. \
451
             commit and amend take explicit paths (or all: true). {}",
4522×
            dir.display(),
4532×
            if allow_destructive { "Destructive tools (undo, forced push) are enabled: use them only when asked." } else { "Destructive tools (undo, forced push) are off." }
454
        ),
455
    };
4562×
    let tools = OxyTools {
4572×
        exe,
4582×
        dir,
4592×
        allow_destructive,
4602×
    };
4612×
    let stdin = std::io::stdin();
4622×
    mcp::serve(&info, &tools, stdin.lock(), std::io::stdout().lock())?;
4632×
    Ok(0)
4643×
}
465
466
#[cfg(test)]
467
mod tests {
468
    use super::*;
469
4709×
    fn build(name: &str, args: Value, destructive: bool) -> Result<Vec<String>, String> {
471102×
        let spec = specs().into_iter().find(|s| s.name == name).unwrap();
4729×
        (spec.build)(&args, destructive)
4739×
    }
474
475
    #[test]
4761×
    fn values_never_read_as_flags() {
4771×
        assert_eq!(
4781×
            build(
4791×
                "commit",
4801×
                json!({"message": "-m oops", "paths": ["-rf"]}),
481
                false
482
            )
4831×
            .unwrap(),
484
            ["commit", "--message=-m oops", "--", "-rf"]
485
        );
4861×
        assert_eq!(
4871×
            build("cat", json!({"path": "--help", "change": "main"}), false).unwrap(),
488
            ["cat", "--change=main", "--", "--help"]
489
        );
4901×
        assert_eq!(
4911×
            build("log", json!({"limit": 5000}), false).unwrap(),
492
            ["log", "--json", "--limit=1000"],
493
            "limit is capped"
494
        );
4951×
    }
496
497
    #[test]
4981×
    fn commits_name_what_goes_in() {
4991×
        assert!(build("commit", json!({"message": "x"}), false)
5001×
            .unwrap_err()
5011×
            .contains("all: true"));
5021×
        assert!(build(
5031×
            "commit",
5041×
            json!({"message": "x", "paths": ["a"], "all": true}),
5051×
            false
5061×
        )
5071×
        .is_err());
5081×
        assert_eq!(
5091×
            build("commit", json!({"message": "x", "all": true}), false).unwrap(),
510
            ["commit", "--message=x"]
511
        );
5121×
        assert!(build("commit", json!({"message": " ", "all": true}), false)
5131×
            .unwrap_err()
5141×
            .contains("message"));
5151×
    }
516
517
    #[test]
5181×
    fn destructive_tools_need_the_flag() {
5191×
        let off = OxyTools {
5201×
            exe: "oxy".into(),
5211×
            dir: ".".into(),
5221×
            allow_destructive: false,
5231×
        };
5241×
        let on = OxyTools {
5251×
            exe: "oxy".into(),
5261×
            dir: ".".into(),
5271×
            allow_destructive: true,
5281×
        };
52918×
        assert!(!off.list().iter().any(|t| t.name == "undo"));
5301×
        assert!(
5311×
            off.call("undo", &json!({})).is_none(),
532
            "an unoffered tool is unknown"
533
        );
53419×
        assert!(on.list().iter().any(|t| t.name == "undo" && t.destructive));
5351×
        assert!(build("push", json!({"force": true}), false)
5361×
            .unwrap_err()
5371×
            .contains("--allow-destructive"));
5381×
        assert_eq!(
5391×
            build("push", json!({"force": true, "remote": "earth"}), true).unwrap(),
540
            ["push", "--json", "--force", "--", "earth"]
541
        );
5421×
    }
543
}