OXYGENHUB
◆ v0.11.0 oxygen / src / cli / mcp.rs
20.7 KiB · Rust · last changed oxy mcp: an MCP server for a repository, signing as its configured identity (closes #159) 4 days ago source blame history raw
91334b7f Andy McCall ↶ 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
33
fn obj(props: Value, required: &[&str]) -> Value {
34
    json!({ "type": "object", "properties": props, "required": required, "additionalProperties": false })
35
}
36
37
fn none() -> Value {
38
    obj(json!({}), &[])
39
}
40
41
/// An option's value as `--flag=value`, so a value starting with `-` is
42
/// never read as another flag.
43
fn opt(flag: &str, value: &str) -> String {
44
    format!("{flag}={value}")
45
}
46
47
/// Positional values after `--`, for the same reason.
48
fn positional(out: &mut Vec<String>, values: Vec<String>) {
49
    if !values.is_empty() {
50
        out.push("--".into());
51
        out.extend(values);
52
    }
53
}
54
55
fn required<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
56
    arg_str(args, key)
57
        .filter(|s| !s.trim().is_empty())
58
        .ok_or_else(|| format!("{key} is required"))
59
}
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.
63
fn paths_or_all(args: &Value) -> Result<Vec<String>, String> {
64
    let paths = arg_strings(args, "paths")?;
65
    let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);
66
    match (paths.is_empty(), all) {
67
        (false, false) => Ok(paths),
68
        (true, true) => Ok(vec![]),
69
        (false, true) => Err("give paths or all: true, not both".into()),
70
        (true, false) => {
71
            Err("give the paths to include, or all: true for every changed path".into())
72
        }
73
    }
74
}
75
76
fn limit(args: &Value, default: u64) -> String {
77
    args.get("limit")
78
        .and_then(Value::as_u64)
79
        .unwrap_or(default)
80
        .clamp(1, 1000)
81
        .to_string()
82
}
83
84
fn specs() -> Vec<Spec> {
85
    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.",
89
            schema: none,
90
            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).",
98
            schema: || obj(json!({ "change": { "type": "string" } }), &[]),
99
            build: |a, _| {
100
                let mut v = vec!["diff".into(), "--json".into()];
101
                positional(&mut v, arg_str(a, "change").map(|c| vec![c.to_string()]).unwrap_or_default());
102
                Ok(v)
103
            },
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.",
111
            schema: || obj(json!({ "revset": { "type": "string" }, "path": { "type": "string" }, "limit": { "type": "integer", "minimum": 1 } }), &[]),
112
            build: |a, _| {
113
                let mut v = vec!["log".into(), "--json".into(), opt("--limit", &limit(a, 20))];
114
                if let Some(r) = arg_str(a, "revset") {
115
                    v.push(opt("--revset", r));
116
                }
117
                if let Some(p) = arg_str(a, "path") {
118
                    v.push(opt("--path", p));
119
                }
120
                Ok(v)
121
            },
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.",
129
            schema: || obj(json!({ "change": { "type": "string" }, "stat": { "type": "boolean" } }), &[]),
130
            build: |a, _| {
131
                let mut v = vec!["show".into(), "--json".into()];
132
                if a.get("stat").and_then(Value::as_bool).unwrap_or(false) {
133
                    v.push("--stat".into());
134
                }
135
                positional(&mut v, vec![arg_str(a, "change").unwrap_or("@").to_string()]);
136
                Ok(v)
137
            },
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.",
145
            schema: || obj(json!({ "path": { "type": "string" }, "change": { "type": "string" } }), &["path"]),
146
            build: |a, _| {
147
                let mut v = vec!["cat".into()];
148
                if let Some(c) = arg_str(a, "change") {
149
                    v.push(opt("--change", c));
150
                }
151
                positional(&mut v, vec![required(a, "path")?.to_string()]);
152
                Ok(v)
153
            },
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.",
161
            schema: || obj(json!({ "path": { "type": "string" } }), &["path"]),
162
            build: |a, _| {
163
                let mut v = vec!["blame".into(), "--json".into()];
164
                positional(&mut v, vec![required(a, "path")?.to_string()]);
165
                Ok(v)
166
            },
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.",
174
            schema: || obj(json!({ "all": { "type": "boolean" } }), &[]),
175
            build: |a, _| {
176
                let mut v = vec!["bookmark".into(), "list".into(), "--json".into()];
177
                if a.get("all").and_then(Value::as_bool).unwrap_or(false) {
178
                    v.push("--all".into());
179
                }
180
                Ok(v)
181
            },
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.",
189
            schema: none,
190
            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.",
198
            schema: none,
199
            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.",
207
            schema: || obj(json!({ "limit": { "type": "integer", "minimum": 1 } }), &[]),
208
            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).",
216
            schema: none,
217
            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.",
225
            schema: || obj(json!({ "message": { "type": "string" }, "paths": { "type": "array", "items": { "type": "string" } }, "all": { "type": "boolean" } }), &["message"]),
226
            build: |a, _| {
227
                let mut v = vec!["commit".into(), opt("--message", required(a, "message")?)];
228
                positional(&mut v, paths_or_all(a)?);
229
                Ok(v)
230
            },
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.",
238
            schema: || obj(json!({ "message": { "type": "string" }, "paths": { "type": "array", "items": { "type": "string" } }, "all": { "type": "boolean" } }), &[]),
239
            build: |a, _| {
240
                let mut v = vec!["amend".into()];
241
                if let Some(m) = arg_str(a, "message") {
242
                    v.push(opt("--message", m));
243
                }
244
                positional(&mut v, paths_or_all(a)?);
245
                Ok(v)
246
            },
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).",
254
            schema: || obj(json!({ "message": { "type": "string" }, "change": { "type": "string" } }), &["message"]),
255
            build: |a, _| {
256
                let mut v = vec!["describe".into(), "--json".into(), opt("--message", required(a, "message")?)];
257
                positional(&mut v, arg_str(a, "change").map(|c| vec![c.to_string()]).unwrap_or_default());
258
                Ok(v)
259
            },
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.",
267
            schema: || obj(json!({ "remote": { "type": "string" } }), &[]),
268
            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.",
276
            schema: || obj(json!({ "remote": { "type": "string" } }), &[]),
277
            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.",
285
            schema: || obj(json!({ "remote": { "type": "string" }, "bookmark": { "type": "string" }, "force": { "type": "boolean" } }), &[]),
286
            build: |a, destructive| {
287
                let mut v = vec!["push".to_string(), "--json".into()];
288
                if let Some(b) = arg_str(a, "bookmark") {
289
                    v.push(opt("--bookmark", b));
290
                }
291
                if a.get("force").and_then(Value::as_bool).unwrap_or(false) {
292
                    if !destructive {
293
                        return Err("a forced push can drop the remote's heads; start oxy mcp with --allow-destructive to allow it".into());
294
                    }
295
                    v.push("--force".into());
296
                }
297
                positional(&mut v, arg_str(a, "remote").map(|r| vec![r.to_string()]).unwrap_or_default());
298
                Ok(v)
299
            },
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.",
307
            schema: none,
308
            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.",
316
            schema: none,
317
            build: |_, _| Ok(vec!["undo".into()]),
318
            json: false,
319
            read_only: false,
320
            destructive: true,
321
        },
322
    ]
323
}
324
325
fn remote_cmd(command: &str, a: &Value) -> Result<Vec<String>, String> {
326
    let mut v = vec![command.to_string(), "--json".into()];
327
    positional(
328
        &mut v,
329
        arg_str(a, "remote")
330
            .map(|r| vec![r.to_string()])
331
            .unwrap_or_default(),
332
    );
333
    Ok(v)
334
}
335
336
struct OxyTools {
337
    exe: PathBuf,
338
    dir: PathBuf,
339
    allow_destructive: bool,
340
}
341
342
impl OxyTools {
343
    fn offered(&self) -> impl Iterator<Item = Spec> + '_ {
344
        specs()
345
            .into_iter()
346
            .filter(|s| self.allow_destructive || !s.destructive)
347
    }
348
349
    fn run(&self, spec: &Spec, argv: Vec<String>) -> ToolOutput {
350
        let out = Command::new(&self.exe)
351
            .arg("--plain")
352
            .args(&argv)
353
            .current_dir(&self.dir)
354
            .stdin(Stdio::null())
355
            .output();
356
        let out = match out {
357
            Ok(o) => o,
358
            Err(e) => return ToolOutput::error(format!("could not run oxy: {e}")),
359
        };
360
        let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
361
        let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
362
        if !out.status.success() {
363
            let why = if stderr.trim().is_empty() {
364
                stdout
365
            } else {
366
                stderr
367
            };
368
            return ToolOutput::error(format!("oxy {} failed: {}", argv.join(" "), why.trim()));
369
        }
370
        let mut result = ToolOutput::ok(stdout.clone());
371
        if spec.json {
372
            result.structured = serde_json::from_str(&stdout).ok();
373
        } else if !stderr.trim().is_empty() && stdout.trim().is_empty() {
374
            // Commands that report on stderr (commit's summary) still say what they did.
375
            result.text = stderr;
376
        }
377
        if result.text.len() > MAX_TEXT {
378
            let mut cut = MAX_TEXT;
379
            while !result.text.is_char_boundary(cut) {
380
                cut -= 1;
381
            }
382
            result.text.truncate(cut);
383
            result.text.push_str(
384
                "\n… (cut at 256 KiB: narrow the request, e.g. a smaller limit or one path)",
385
            );
386
            result.structured = None;
387
        }
388
        result
389
    }
390
}
391
392
impl Tools for OxyTools {
393
    fn list(&self) -> Vec<Tool> {
394
        self.offered()
395
            .map(|s| Tool {
396
                name: s.name.into(),
397
                description: s.description.into(),
398
                input_schema: (s.schema)(),
399
                read_only: s.read_only,
400
                destructive: s.destructive,
401
            })
402
            .collect()
403
    }
404
405
    fn call(&self, name: &str, args: &Value) -> Option<ToolOutput> {
406
        let spec = self.offered().find(|s| s.name == name)?;
407
        Some(match (spec.build)(args, self.allow_destructive) {
408
            Ok(argv) => self.run(&spec, argv),
409
            Err(why) => ToolOutput::error(why),
410
        })
411
    }
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.
416
fn signer_line(exe: &Path, dir: &Path) -> String {
417
    let out = Command::new(exe)
418
        .args(["--plain", "identity", "show"])
419
        .current_dir(dir)
420
        .stdin(Stdio::null())
421
        .output();
422
    match out {
423
        Ok(o) if o.status.success() => {
424
            let text = String::from_utf8_lossy(&o.stdout);
425
            let who = text.lines().next().unwrap_or("").trim_start_matches(['#', '▍', ' ']).trim().to_string();
426
            let signing = text.lines().find(|l| l.trim_start().starts_with("signing")).map(|l| l.trim().to_string());
427
            match signing {
428
                Some(s) => format!("{who}; {s}"),
429
                None => who,
430
            }
431
        }
432
        _ => "no identity is configured: commits will be refused until one is (oxy identity create, or an agent home with OXY_HOME)".into(),
433
    }
434
}
435
436
pub fn serve(dir: Option<PathBuf>, allow_destructive: bool) -> Result<i32> {
437
    let dir = match dir {
438
        Some(d) => d,
439
        None => std::env::current_dir()?,
440
    };
441
    if !dir.join(oxy_core::repo::OXY_DIR).is_dir() {
442
        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);
443
    }
444
    let exe = std::env::current_exe()?;
445
    let signer = signer_line(&exe, &dir);
446
    let info = ServerInfo {
447
        name: "oxy".into(),
448
        version: env!("CARGO_PKG_VERSION").into(),
449
        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). {}",
452
            dir.display(),
453
            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
    };
456
    let tools = OxyTools {
457
        exe,
458
        dir,
459
        allow_destructive,
460
    };
461
    let stdin = std::io::stdin();
462
    mcp::serve(&info, &tools, stdin.lock(), std::io::stdout().lock())?;
463
    Ok(0)
464
}
465
466
#[cfg(test)]
467
mod tests {
468
    use super::*;
469
470
    fn build(name: &str, args: Value, destructive: bool) -> Result<Vec<String>, String> {
471
        let spec = specs().into_iter().find(|s| s.name == name).unwrap();
472
        (spec.build)(&args, destructive)
473
    }
474
475
    #[test]
476
    fn values_never_read_as_flags() {
477
        assert_eq!(
478
            build(
479
                "commit",
480
                json!({"message": "-m oops", "paths": ["-rf"]}),
481
                false
482
            )
483
            .unwrap(),
484
            ["commit", "--message=-m oops", "--", "-rf"]
485
        );
486
        assert_eq!(
487
            build("cat", json!({"path": "--help", "change": "main"}), false).unwrap(),
488
            ["cat", "--change=main", "--", "--help"]
489
        );
490
        assert_eq!(
491
            build("log", json!({"limit": 5000}), false).unwrap(),
492
            ["log", "--json", "--limit=1000"],
493
            "limit is capped"
494
        );
495
    }
496
497
    #[test]
498
    fn commits_name_what_goes_in() {
499
        assert!(build("commit", json!({"message": "x"}), false)
500
            .unwrap_err()
501
            .contains("all: true"));
502
        assert!(build(
503
            "commit",
504
            json!({"message": "x", "paths": ["a"], "all": true}),
505
            false
506
        )
507
        .is_err());
508
        assert_eq!(
509
            build("commit", json!({"message": "x", "all": true}), false).unwrap(),
510
            ["commit", "--message=x"]
511
        );
512
        assert!(build("commit", json!({"message": " ", "all": true}), false)
513
            .unwrap_err()
514
            .contains("message"));
515
    }
516
517
    #[test]
518
    fn destructive_tools_need_the_flag() {
519
        let off = OxyTools {
520
            exe: "oxy".into(),
521
            dir: ".".into(),
522
            allow_destructive: false,
523
        };
524
        let on = OxyTools {
525
            exe: "oxy".into(),
526
            dir: ".".into(),
527
            allow_destructive: true,
528
        };
529
        assert!(!off.list().iter().any(|t| t.name == "undo"));
530
        assert!(
531
            off.call("undo", &json!({})).is_none(),
532
            "an unoffered tool is unknown"
533
        );
534
        assert!(on.list().iter().any(|t| t.name == "undo" && t.destructive));
535
        assert!(build("push", json!({"force": true}), false)
536
            .unwrap_err()
537
            .contains("--allow-destructive"));
538
        assert_eq!(
539
            build("push", json!({"force": true, "remote": "earth"}), true).unwrap(),
540
            ["push", "--json", "--force", "--", "earth"]
541
        );
542
    }
543
}