OXYGENHUB

oxy mcp: an MCP server for a repository, signing as its configured identity (closes #159) 91334b7fa574

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 8c19ca12e6cc
When
· 2026-10-31 22:00 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
91334b7fa5748e15146a231bb0caf79c2016d1306d7a015a93c7b355fdcec845
Depends on
01a47cd7f0c8 16aa9e03d85b 37f1914b62a8 50a046e439e9 648724d8e0de 8fd78eb52ad2 c6aa5bd9d1c8 d02e545dc265 d82e44097323 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
Depended on by
6f4226bfb87f
In bookmarks
main
In tags
v0.10.0 v0.11.0
Edits
13 atoms, 4 tree operations, 9 files

Browse the tree at this change

added crates/oxy-core/src/mcp.rs +353 −0
1 +
//! A Model Context Protocol server over stdio (oxygen#159): JSON-RPC 2.0,
2 +
//! one message per line, as MCP's stdio transport has it. This is only the
3 +
//! protocol: `initialize`, `ping`, `tools/list` and `tools/call`, with the
4 +
//! tools themselves supplied by the caller (`oxy mcp`, and `oh mcp` in the
5 +
//! hub, which reaches this module through its pinned oxygen).
6 +
//!
7 +
//! Nothing is written to stdout but protocol messages: a tool's own
8 +
//! output goes into its result.
9 +
10 +
use serde_json::{json, Value};
11 +
use std::io::{BufRead, Write};
12 +
13 +
/// The protocol versions this server speaks, newest first. A client asking
14 +
/// for one of them gets it back; any other gets the newest.
15 +
pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
16 +
17 +
/// One tool as `tools/list` describes it.
18 +
#[derive(Clone, Debug)]
19 +
pub struct Tool {
20 +
    pub name: String,
21 +
    pub description: String,
22 +
    /// A JSON Schema object for the arguments.
23 +
    pub input_schema: Value,
24 +
    /// Reads only: no change to the repository, the remote or the hub.
25 +
    pub read_only: bool,
26 +
    /// May lose work or history (undo, abandon, deletes).
27 +
    pub destructive: bool,
28 +
}
29 +
30 +
impl Tool {
31 +
    fn describe(&self) -> Value {
32 +
        json!({
33 +
            "name": self.name,
34 +
            "description": self.description,
35 +
            "inputSchema": self.input_schema,
36 +
            "annotations": {
37 +
                "readOnlyHint": self.read_only,
38 +
                "destructiveHint": self.destructive,
39 +
            },
40 +
        })
41 +
    }
42 +
}
43 +
44 +
/// What a tool call produced: text for the model to read, the same as JSON
45 +
/// when there is some, and whether it failed. A failed tool is a result, not
46 +
/// a protocol error, so the model sees why.
47 +
#[derive(Clone, Debug, Default, PartialEq)]
48 +
pub struct ToolOutput {
49 +
    pub text: String,
50 +
    pub structured: Option<Value>,
51 +
    pub is_error: bool,
52 +
}
53 +
54 +
impl ToolOutput {
55 +
    pub fn ok(text: impl Into<String>) -> ToolOutput {
56 +
        ToolOutput {
57 +
            text: text.into(),
58 +
            structured: None,
59 +
            is_error: false,
60 +
        }
61 +
    }
62 +
63 +
    pub fn error(text: impl Into<String>) -> ToolOutput {
64 +
        ToolOutput {
65 +
            text: text.into(),
66 +
            structured: None,
67 +
            is_error: true,
68 +
        }
69 +
    }
70 +
71 +
    fn to_json(&self) -> Value {
72 +
        let mut out = json!({
73 +
            "content": [{ "type": "text", "text": self.text }],
74 +
            "isError": self.is_error,
75 +
        });
76 +
        // Structured content must be an object; an array is wrapped.
77 +
        if let Some(s) = &self.structured {
78 +
            let obj = if s.is_object() {
79 +
                s.clone()
80 +
            } else {
81 +
                json!({ "result": s })
82 +
            };
83 +
            out["structuredContent"] = obj;
84 +
        }
85 +
        out
86 +
    }
87 +
}
88 +
89 +
/// The tools a server offers.
90 +
pub trait Tools {
91 +
    fn list(&self) -> Vec<Tool>;
92 +
    /// Run a tool. `None` when no tool has that name.
93 +
    fn call(&self, name: &str, args: &Value) -> Option<ToolOutput>;
94 +
}
95 +
96 +
/// Who the server is, for `initialize`.
97 +
#[derive(Clone, Debug)]
98 +
pub struct ServerInfo {
99 +
    pub name: String,
100 +
    pub version: String,
101 +
    /// Shown to the model: what the server is for and how to use it safely.
102 +
    pub instructions: String,
103 +
}
104 +
105 +
const PARSE_ERROR: i64 = -32700;
106 +
const INVALID_REQUEST: i64 = -32600;
107 +
const METHOD_NOT_FOUND: i64 = -32601;
108 +
const INVALID_PARAMS: i64 = -32602;
109 +
110 +
fn error(id: Value, code: i64, message: &str) -> Value {
111 +
    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
112 +
}
113 +
114 +
fn result(id: Value, result: Value) -> Value {
115 +
    json!({ "jsonrpc": "2.0", "id": id, "result": result })
116 +
}
117 +
118 +
/// Answer one message; `None` for a notification (no reply is due).
119 +
pub fn handle(info: &ServerInfo, tools: &dyn Tools, message: &Value) -> Option<Value> {
120 +
    let Some(obj) = message.as_object() else {
121 +
        return Some(error(
122 +
            Value::Null,
123 +
            INVALID_REQUEST,
124 +
            "a message is a JSON object",
125 +
        ));
126 +
    };
127 +
    let method = obj.get("method").and_then(Value::as_str);
128 +
    let id = obj.get("id").cloned();
129 +
    let Some(method) = method else {
130 +
        // A response to something we never sent, or nonsense: a request
131 +
        // without a method gets an error, anything else is ignored.
132 +
        return id.map(|id| error(id, INVALID_REQUEST, "a request needs a method"));
133 +
    };
134 +
    // Notifications (initialized, cancelled, …) carry no id and get no reply.
135 +
    let id = id?;
136 +
    let params = obj.get("params").cloned().unwrap_or(Value::Null);
137 +
    Some(match method {
138 +
        "initialize" => {
139 +
            let asked = params.get("protocolVersion").and_then(Value::as_str);
140 +
            let version = asked
141 +
                .filter(|v| PROTOCOL_VERSIONS.contains(v))
142 +
                .unwrap_or(PROTOCOL_VERSIONS[0]);
143 +
            result(
144 +
                id,
145 +
                json!({
146 +
                    "protocolVersion": version,
147 +
                    "capabilities": { "tools": { "listChanged": false } },
148 +
                    "serverInfo": { "name": info.name, "version": info.version },
149 +
                    "instructions": info.instructions,
150 +
                }),
151 +
            )
152 +
        }
153 +
        "ping" => result(id, json!({})),
154 +
        "tools/list" => result(
155 +
            id,
156 +
            json!({ "tools": tools.list().iter().map(Tool::describe).collect::<Vec<_>>() }),
157 +
        ),
158 +
        "tools/call" => {
159 +
            let Some(name) = params.get("name").and_then(Value::as_str) else {
160 +
                return Some(error(
161 +
                    id,
162 +
                    INVALID_PARAMS,
163 +
                    "tools/call needs the tool's name",
164 +
                ));
165 +
            };
166 +
            let args = params
167 +
                .get("arguments")
168 +
                .cloned()
169 +
                .unwrap_or_else(|| json!({}));
170 +
            if !args.is_object() {
171 +
                return Some(error(
172 +
                    id,
173 +
                    INVALID_PARAMS,
174 +
                    "a tool's arguments are a JSON object",
175 +
                ));
176 +
            }
177 +
            match tools.call(name, &args) {
178 +
                Some(out) => result(id, out.to_json()),
179 +
                None => error(id, INVALID_PARAMS, &format!("no tool named {name}")),
180 +
            }
181 +
        }
182 +
        other => error(id, METHOD_NOT_FOUND, &format!("{other} is not supported")),
183 +
    })
184 +
}
185 +
186 +
/// Serve until the input ends: read a message per line, write a reply per
187 +
/// request. A line that is not JSON gets a parse error; blank lines are
188 +
/// skipped.
189 +
pub fn serve(
190 +
    info: &ServerInfo,
191 +
    tools: &dyn Tools,
192 +
    input: impl BufRead,
193 +
    mut output: impl Write,
194 +
) -> std::io::Result<()> {
195 +
    for line in input.lines() {
196 +
        let line = line?;
197 +
        if line.trim().is_empty() {
198 +
            continue;
199 +
        }
200 +
        let reply = match serde_json::from_str::<Value>(&line) {
201 +
            Ok(Value::Array(batch)) => {
202 +
                let replies: Vec<Value> = batch
203 +
                    .iter()
204 +
                    .filter_map(|m| handle(info, tools, m))
205 +
                    .collect();
206 +
                (!replies.is_empty()).then_some(Value::Array(replies))
207 +
            }
208 +
            Ok(message) => handle(info, tools, &message),
209 +
            Err(e) => Some(error(Value::Null, PARSE_ERROR, &format!("not JSON: {e}"))),
210 +
        };
211 +
        if let Some(reply) = reply {
212 +
            writeln!(output, "{reply}")?;
213 +
            output.flush()?;
214 +
        }
215 +
    }
216 +
    Ok(())
217 +
}
218 +
219 +
/// A string argument, if given and a string.
220 +
pub fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
221 +
    args.get(key).and_then(Value::as_str)
222 +
}
223 +
224 +
/// A list of strings, if given; a single string counts as a list of one.
225 +
pub fn arg_strings(args: &Value, key: &str) -> Result<Vec<String>, String> {
226 +
    match args.get(key) {
227 +
        None | Some(Value::Null) => Ok(vec![]),
228 +
        Some(Value::String(s)) => Ok(vec![s.clone()]),
229 +
        Some(Value::Array(a)) => a
230 +
            .iter()
231 +
            .map(|v| {
232 +
                v.as_str()
233 +
                    .map(str::to_string)
234 +
                    .ok_or_else(|| format!("{key} must be a list of strings"))
235 +
            })
236 +
            .collect(),
237 +
        Some(_) => Err(format!("{key} must be a list of strings")),
238 +
    }
239 +
}
240 +
241 +
#[cfg(test)]
242 +
mod tests {
243 +
    use super::*;
244 +
245 +
    struct Echo;
246 +
247 +
    impl Tools for Echo {
248 +
        fn list(&self) -> Vec<Tool> {
249 +
            vec![Tool {
250 +
                name: "echo".into(),
251 +
                description: "says it back".into(),
252 +
                input_schema: json!({"type": "object"}),
253 +
                read_only: true,
254 +
                destructive: false,
255 +
            }]
256 +
        }
257 +
        fn call(&self, name: &str, args: &Value) -> Option<ToolOutput> {
258 +
            (name == "echo").then(|| ToolOutput {
259 +
                text: args.to_string(),
260 +
                structured: Some(json!([1, 2])),
261 +
                is_error: false,
262 +
            })
263 +
        }
264 +
    }
265 +
266 +
    fn info() -> ServerInfo {
267 +
        ServerInfo {
268 +
            name: "t".into(),
269 +
            version: "1".into(),
270 +
            instructions: "be careful".into(),
271 +
        }
272 +
    }
273 +
274 +
    fn talk(lines: &str) -> Vec<Value> {
275 +
        let mut out = Vec::new();
276 +
        serve(&info(), &Echo, lines.as_bytes(), &mut out).unwrap();
277 +
        String::from_utf8(out)
278 +
            .unwrap()
279 +
            .lines()
280 +
            .map(|l| serde_json::from_str(l).unwrap())
281 +
            .collect()
282 +
    }
283 +
284 +
    #[test]
285 +
    fn a_session_initializes_lists_and_calls() {
286 +
        let r = talk(concat!(
287 +
            r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}"#,
288 +
            "\n",
289 +
            r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
290 +
            "\n\n",
291 +
            r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
292 +
            "\n",
293 +
            r#"{"jsonrpc":"2.0","id":"three","method":"tools/call","params":{"name":"echo","arguments":{"a":1}}}"#,
294 +
            "\n",
295 +
        ));
296 +
        assert_eq!(r.len(), 3, "the notification gets no reply: {r:?}");
297 +
        assert_eq!(
298 +
            r[0]["result"]["protocolVersion"], "2025-03-26",
299 +
            "a version we speak is kept"
300 +
        );
301 +
        assert_eq!(r[0]["result"]["instructions"], "be careful");
302 +
        assert_eq!(r[1]["result"]["tools"][0]["name"], "echo");
303 +
        assert_eq!(
304 +
            r[1]["result"]["tools"][0]["annotations"]["readOnlyHint"],
305 +
            true
306 +
        );
307 +
        assert_eq!(r[2]["id"], "three");
308 +
        assert_eq!(r[2]["result"]["content"][0]["text"], r#"{"a":1}"#);
309 +
        assert_eq!(
310 +
            r[2]["result"]["structuredContent"],
311 +
            json!({"result": [1, 2]}),
312 +
            "an array is wrapped in an object"
313 +
        );
314 +
        assert_eq!(r[2]["result"]["isError"], false);
315 +
    }
316 +
317 +
    #[test]
318 +
    fn errors_are_protocol_errors_and_unknown_versions_get_the_newest() {
319 +
        let r = talk(concat!(
320 +
            "not json\n",
321 +
            r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1999-01-01"}}"#,
322 +
            "\n",
323 +
            r#"{"jsonrpc":"2.0","id":2,"method":"resources/list"}"#,
324 +
            "\n",
325 +
            r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"nope"}}"#,
326 +
            "\n",
327 +
            r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"echo","arguments":[1]}}"#,
328 +
            "\n",
329 +
            r#"[{"jsonrpc":"2.0","id":5,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled"}]"#,
330 +
            "\n",
331 +
        ));
332 +
        assert_eq!(r[0]["error"]["code"], PARSE_ERROR);
333 +
        assert_eq!(r[1]["result"]["protocolVersion"], PROTOCOL_VERSIONS[0]);
334 +
        assert_eq!(r[2]["error"]["code"], METHOD_NOT_FOUND);
335 +
        assert_eq!(r[3]["error"]["code"], INVALID_PARAMS);
336 +
        assert_eq!(r[4]["error"]["code"], INVALID_PARAMS);
337 +
        assert_eq!(
338 +
            r[5],
339 +
            json!([{"jsonrpc": "2.0", "id": 5, "result": {}}]),
340 +
            "a batch is answered as a batch"
341 +
        );
342 +
    }
343 +
344 +
    #[test]
345 +
    fn string_arguments_and_lists() {
346 +
        let a = json!({"one": "x", "many": ["a", "b"], "bad": [1]});
347 +
        assert_eq!(arg_str(&a, "one"), Some("x"));
348 +
        assert_eq!(arg_strings(&a, "one").unwrap(), vec!["x"]);
349 +
        assert_eq!(arg_strings(&a, "many").unwrap(), vec!["a", "b"]);
350 +
        assert_eq!(arg_strings(&a, "missing").unwrap(), Vec::<String>::new());
351 +
        assert!(arg_strings(&a, "bad").is_err());
352 +
    }
353 +
}
added docs/mcp.md +78 −0
1 +
# oxy mcp: Oxygen for AI agents
2 +
3 +
`oxy mcp` serves one repository to an AI agent over the
4 +
[Model Context Protocol](https://modelcontextprotocol.io) (MCP), on stdio
5 +
(oxygen#159). The agent calls typed tools (`status`, `log`, `commit`, …)
6 +
and gets JSON back, instead of running `oxy` in a shell and reading its
7 +
output.
8 +
9 +
## Who changes are signed as
10 +
11 +
The server signs as whatever identity its environment configures, exactly
12 +
as `oxy` would: it runs each tool as this same `oxy` binary, in the
13 +
repository, with the server's environment. Start it from an agent home and
14 +
every change the agent makes is sealed **via the agent**, with no
15 +
`OXY_HOME` to remember on each command:
16 +
17 +
```sh
18 +
OXY_HOME=~/.oxy-agents/claude oxy mcp ~/development/myproject
19 +
```
20 +
21 +
The server says who it signs as in its `initialize` reply (the first line
22 +
of `oxy identity show`), and the `identity` tool shows the rest. Without an
23 +
identity, reading works and committing is refused with oxy's own message.
24 +
25 +
## Tools
26 +
27 +
Each tool returns the command's `--json` output as structured content (an
28 +
array comes wrapped as `{"result": […]}`) and as text. A failed command is a
29 +
tool result with `isError: true` and oxy's error, so the agent sees why.
30 +
31 +
| Tool | Runs | Arguments |
32 +
|---|---|---|
33 +
| `status` | `oxy status --json` | |
34 +
| `diff` | `oxy diff [CHANGE] --json` | `change` |
35 +
| `log` | `oxy log --json` | `revset`, `path`, `limit` (default 20, at most 1000) |
36 +
| `show` | `oxy show CHANGE --json` | `change` (default `@`), `stat` |
37 +
| `cat` | `oxy cat PATH [-r CHANGE]` | `path`, `change` |
38 +
| `blame` | `oxy blame PATH --json` | `path` |
39 +
| `bookmarks` | `oxy bookmark list --json` | `all` |
40 +
| `tags` | `oxy tag list --json` | |
41 +
| `conflicts` | `oxy resolve --json` (report only) | |
42 +
| `op_log` | `oxy op log --json` | `limit` |
43 +
| `identity` | `oxy identity show` | |
44 +
| `commit` | `oxy commit -m MESSAGE PATHS…` | `message`, and `paths` or `all: true` |
45 +
| `amend` | `oxy amend [-m] PATHS…` | `paths` or `all: true`, `message` |
46 +
| `describe` | `oxy describe [CHANGE] -m MESSAGE` | `message`, `change` |
47 +
| `fetch`, `pull` | `oxy fetch\|pull [REMOTE] --json` | `remote` |
48 +
| `push` | `oxy push [REMOTE] --json` | `remote`, `bookmark`, `force` |
49 +
| `tlog_verify` | `oxy tlog verify --json` | |
50 +
| `undo` | `oxy undo` | only with `--allow-destructive` |
51 +
52 +
## Safety
53 +
54 +
- **Commits are deliberate.** `commit` and `amend` take the paths to
55 +
  include, or `all: true`; never neither.
56 +
- **Nothing destructive by default.** `undo` is not offered, and `push`
57 +
  refuses `force`, unless the server is started with `--allow-destructive`.
58 +
  Tag withdrawal, bookmark deletion and history rewriting are not tools at
59 +
  all. Each tool carries MCP's `readOnlyHint` and `destructiveHint`.
60 +
- **Values are never flags.** Arguments are passed as `--flag=value` and
61 +
  after `--`, so a path or message starting with `-` stays a value.
62 +
- **Your policy still applies.** Pushes go through the remote's checks and
63 +
  the repository's trust policy like any other; an agent's delegation
64 +
  scope limits what its seals may do.
65 +
- **Output is bounded.** A tool's text stops at 256 KiB, saying to narrow
66 +
  the request.
67 +
68 +
## Adding it to an agent
69 +
70 +
Claude Code, for one project (run in the project's root):
71 +
72 +
```sh
73 +
claude mcp add oxy -- env OXY_HOME=$HOME/.oxy-agents/claude oxy mcp
74 +
```
75 +
76 +
Any MCP client that starts stdio servers takes the same command: `oxy mcp
77 +
[DIR] [--allow-destructive]`, with `OXY_HOME` set for an agent's identity.
78 +
`DIR` defaults to the current directory and must be a repository's root.
added src/cli/mcp.rs +543 −0
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 +
}
added tests/cli_mcp.rs +231 −0
1 +
//! `oxy mcp` (oxygen#159) end to end: an MCP client talks to the server over
2 +
//! stdio, lists its tools, reads the repository, commits by path as the
3 +
//! configured identity, and is refused what it may not do.
4 +
5 +
use serde_json::{json, Value};
6 +
use std::io::{BufRead, BufReader, Write};
7 +
use std::path::Path;
8 +
use std::process::{Command, Stdio};
9 +
10 +
fn oxy(dir: &Path, home: &Path, args: &[&str]) -> i32 {
11 +
    Command::new(env!("CARGO_BIN_EXE_oxy"))
12 +
        .arg("--plain")
13 +
        .args(args)
14 +
        .current_dir(dir)
15 +
        .env("OXY_HOME", home)
16 +
        .env("NO_COLOR", "1")
17 +
        .status()
18 +
        .unwrap()
19 +
        .code()
20 +
        .unwrap_or(-1)
21 +
}
22 +
23 +
/// Send each request in turn and collect the replies, one per request (the
24 +
/// notification gets none).
25 +
fn session(dir: &Path, home: &Path, extra: &[&str], requests: &[Value]) -> Vec<Value> {
26 +
    let mut child = Command::new(env!("CARGO_BIN_EXE_oxy"))
27 +
        .arg("mcp")
28 +
        .args(extra)
29 +
        .current_dir(dir)
30 +
        .env("OXY_HOME", home)
31 +
        .env("NO_COLOR", "1")
32 +
        .stdin(Stdio::piped())
33 +
        .stdout(Stdio::piped())
34 +
        .stderr(Stdio::null())
35 +
        .spawn()
36 +
        .unwrap();
37 +
    let mut stdin = child.stdin.take().unwrap();
38 +
    let mut stdout = BufReader::new(child.stdout.take().unwrap());
39 +
    let mut replies = vec![];
40 +
    for r in requests {
41 +
        writeln!(stdin, "{r}").unwrap();
42 +
        stdin.flush().unwrap();
43 +
        if r.get("id").is_some() {
44 +
            let mut line = String::new();
45 +
            stdout.read_line(&mut line).unwrap();
46 +
            replies.push(
47 +
                serde_json::from_str(&line).unwrap_or_else(|e| panic!("not JSON ({e}): {line}")),
48 +
            );
49 +
        }
50 +
    }
51 +
    drop(stdin);
52 +
    assert!(
53 +
        child.wait().unwrap().success(),
54 +
        "the server ends cleanly when its input does"
55 +
    );
56 +
    replies
57 +
}
58 +
59 +
fn call(id: u64, name: &str, args: Value) -> Value {
60 +
    json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": args}})
61 +
}
62 +
63 +
#[test]
64 +
fn an_agent_reads_and_commits_through_mcp() {
65 +
    let tmp = tempfile::tempdir().unwrap();
66 +
    let home = tmp.path().join("home");
67 +
    std::fs::create_dir_all(&home).unwrap();
68 +
    let repo = tmp.path().join("repo");
69 +
    assert_eq!(oxy(tmp.path(), &home, &["init", repo.to_str().unwrap()]), 0);
70 +
    assert_eq!(
71 +
        oxy(
72 +
            &repo,
73 +
            &home,
74 +
            &[
75 +
                "identity",
76 +
                "create",
77 +
                "--name",
78 +
                "Ada",
79 +
                "--email",
80 +
                "[email protected]"
81 +
            ]
82 +
        ),
83 +
        0
84 +
    );
85 +
    assert_eq!(
86 +
        oxy(&repo, &home, &["config", "--user", "core.sign", "change"]),
87 +
        0
88 +
    );
89 +
    std::fs::write(repo.join("a.txt"), "one\n").unwrap();
90 +
    std::fs::write(repo.join("b.txt"), "two\n").unwrap();
91 +
92 +
    let r = session(
93 +
        &repo,
94 +
        &home,
95 +
        &[],
96 +
        &[
97 +
            json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}}),
98 +
            json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
99 +
            json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
100 +
            call(3, "status", json!({})),
101 +
            call(4, "commit", json!({"message": "everything"})),
102 +
            call(5, "commit", json!({"message": "Add a", "paths": ["a.txt"]})),
103 +
            call(6, "log", json!({"limit": 5})),
104 +
            call(7, "status", json!({})),
105 +
            call(8, "undo", json!({})),
106 +
            call(9, "push", json!({"force": true})),
107 +
            call(10, "cat", json!({"path": "a.txt", "change": "@"})),
108 +
        ],
109 +
    );
110 +
111 +
    let init = &r[0]["result"];
112 +
    assert_eq!(init["serverInfo"]["name"], "oxy");
113 +
    let said = init["instructions"].as_str().unwrap();
114 +
    assert!(
115 +
        said.contains("Ada"),
116 +
        "initialize says who changes are signed as: {said}"
117 +
    );
118 +
    assert!(
119 +
        said.contains("Destructive tools (undo, forced push) are off"),
120 +
        "{said}"
121 +
    );
122 +
123 +
    let names: Vec<&str> = r[1]["result"]["tools"]
124 +
        .as_array()
125 +
        .unwrap()
126 +
        .iter()
127 +
        .map(|t| t["name"].as_str().unwrap())
128 +
        .collect();
129 +
    for t in [
130 +
        "status",
131 +
        "diff",
132 +
        "log",
133 +
        "show",
134 +
        "cat",
135 +
        "blame",
136 +
        "bookmarks",
137 +
        "commit",
138 +
        "amend",
139 +
        "push",
140 +
        "pull",
141 +
        "tlog_verify",
142 +
    ] {
143 +
        assert!(names.contains(&t), "{t} is offered: {names:?}");
144 +
    }
145 +
    assert!(!names.contains(&"undo"), "undo needs --allow-destructive");
146 +
147 +
    let before = &r[2]["result"];
148 +
    assert_eq!(before["isError"], false, "{before}");
149 +
    assert!(
150 +
        before["structuredContent"].is_object(),
151 +
        "status comes back as JSON: {before}"
152 +
    );
153 +
    assert!(before["content"][0]["text"]
154 +
        .as_str()
155 +
        .unwrap()
156 +
        .contains("b.txt"));
157 +
158 +
    assert_eq!(
159 +
        r[3]["result"]["isError"], true,
160 +
        "a commit names its paths or all: {}",
161 +
        r[3]
162 +
    );
163 +
    assert!(r[3]["result"]["content"][0]["text"]
164 +
        .as_str()
165 +
        .unwrap()
166 +
        .contains("all: true"));
167 +
168 +
    assert_eq!(r[4]["result"]["isError"], false, "commit by path: {}", r[4]);
169 +
170 +
    let log = r[5]["result"]["content"][0]["text"].as_str().unwrap();
171 +
    assert!(log.contains("Add a"), "{log}");
172 +
    assert!(
173 +
        r[5]["result"]["structuredContent"].is_object(),
174 +
        "a JSON array comes wrapped: {}",
175 +
        r[5]
176 +
    );
177 +
178 +
    let after = r[6]["result"]["content"][0]["text"].as_str().unwrap();
179 +
    assert!(
180 +
        after.contains("b.txt") && !after.contains("a.txt"),
181 +
        "only a.txt went in: {after}"
182 +
    );
183 +
184 +
    assert!(
185 +
        r[7]["error"]["message"]
186 +
            .as_str()
187 +
            .unwrap()
188 +
            .contains("no tool named undo"),
189 +
        "{}",
190 +
        r[7]
191 +
    );
192 +
    assert_eq!(r[8]["result"]["isError"], true);
193 +
    assert!(r[8]["result"]["content"][0]["text"]
194 +
        .as_str()
195 +
        .unwrap()
196 +
        .contains("--allow-destructive"));
197 +
    assert_eq!(r[9]["result"]["content"][0]["text"], "one\n");
198 +
}
199 +
200 +
#[test]
201 +
fn destructive_tools_come_with_the_flag_and_a_bad_directory_is_refused() {
202 +
    let tmp = tempfile::tempdir().unwrap();
203 +
    let home = tmp.path().join("home");
204 +
    std::fs::create_dir_all(&home).unwrap();
205 +
    let repo = tmp.path().join("repo");
206 +
    assert_eq!(oxy(tmp.path(), &home, &["init", repo.to_str().unwrap()]), 0);
207 +
    let r = session(
208 +
        &repo,
209 +
        &home,
210 +
        &["--allow-destructive"],
211 +
        &[json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})],
212 +
    );
213 +
    let undo = r[0]["result"]["tools"]
214 +
        .as_array()
215 +
        .unwrap()
216 +
        .iter()
217 +
        .find(|t| t["name"] == "undo")
218 +
        .cloned();
219 +
    assert_eq!(
220 +
        undo.expect("undo is offered")["annotations"]["destructiveHint"],
221 +
        true
222 +
    );
223 +
224 +
    let out = Command::new(env!("CARGO_BIN_EXE_oxy"))
225 +
        .args(["mcp", tmp.path().to_str().unwrap()])
226 +
        .env("OXY_HOME", &home)
227 +
        .output()
228 +
        .unwrap();
229 +
    assert!(!out.status.success());
230 +
    assert!(String::from_utf8_lossy(&out.stderr).contains("not the root of an Oxygen repository"));
231 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Added: `oxy mcp`, Oxygen for AI agents (#159).** An MCP server over stdio for a repository: an agent calls typed tools (status, diff, log, show, cat, blame, bookmarks, tags, conflicts, op log, identity, commit, amend, describe, fetch, pull, push, tlog verify) and gets each command's JSON back instead of reading a shell. It signs as the identity its environment configures, so started with an agent home every change is sealed via the agent with nothing to forget per command, and says who in its `initialize` reply. Commits name their paths (or `all: true`); undo and forced pushes are off unless started with `--allow-destructive`. The protocol itself is oxy-core's `mcp` module, for `oh mcp` to reuse. See docs/mcp.md.
5 6
- **Icons for HaikuDepot (#157).** `packaging/haikuports/icons/` has oxy's and oh's icons as HVIF (Haiku's vector format) with 16, 32 and 64 px PNGs, written by `make-icons.py`: the Oxygen mark on a pale tile for oxy, on OxygenHub's dark tile for oh, the same family as the VS Code extensions.
6 7
7 8
## 0.9.0 (2026-10-29)
…
306 unchanged lines
modified README.md +3 −0
…
13 unchanged lines
14 14
15 15
Every change the agent makes is attributed to you and signed by the agent. `oxy log`, `oxy verify` and the hub therefore show who did the work, such as "Andy McCall · via Claude Code", and a policy can require that a person has approved it before it lands.
16 16
17 +
An agent can also work through `oxy mcp`, an MCP server for the repository: typed tools instead of a shell, signing as the identity it was started with, and nothing destructive unless you allow it (see [docs/mcp.md](docs/mcp.md)).
18 +
17 19
See [docs/ai_native_agent_workflows.md](docs/ai_native_agent_workflows.md) for the design and [docs/guide.md](docs/guide.md) §9 for the setup.
18 20
19 21
## What is different
…
133 unchanged lines
153 155
| `docs/strata.md` | Keeping a repository on your own storage: S3, GCS, Azure, a directory |
154 156
| `docs/protocol.md` | Wire protocol for other implementers |
155 157
| `docs/prompt.md` | Shell prompt integration |
158 +
| `docs/mcp.md` | `oxy mcp`: the repository as tools for AI agents |
156 159
157 160
## Licence
158 161
…
1 unchanged lines
modified crates/oxy-core/src/lib.rs +1 −0
…
23 unchanged lines
24 24
pub mod ids;
25 25
pub mod lanes;
26 26
pub mod lines;
27 +
pub mod mcp;
27 28
pub mod objects;
28 29
pub mod oplog;
29 30
pub mod policy;
…
13 unchanged lines
modified docs/spec.md +3 −0
…
1042 unchanged lines
1043 1043
oxy doctor [--offline] [--json]     check identity, config and remotes (§21.2)
1044 1044
oxy completions SHELL               shell completion script     oxy man [--dir DIR]   man pages
1045 1045
oxy hunks                           oxy discard PATH.. | --hunks N,M
1046 +
oxy mcp [DIR] [--allow-destructive] serve the repository to an AI agent (MCP, stdio)
1046 1047
```
1047 1048
1048 1049
**Machine interface.** `status`, `log` (with `--path`), `show`, `diff`, `hunks`, `blame`, `resolve`, `op log`, `bookmark list`, `remote list`, `fetch`, `pull`, `push`, `submodule status`, `bisect status`, `verify`, `policy show`, `root` and `version` take `--json` and then print exactly one JSON document on stdout, with ids as hex strings and times in milliseconds since the epoch; errors go to stderr with a non-zero status. `commit --hunks N,M` and `discard --hunks N,M` take hunk numbers as `hunks` prints them. Editors and other tools MUST use these rather than reading `.oxy/`.
1049 1050
1051 +
**Agents.** `oxy mcp` serves the repository to an AI agent over the Model Context Protocol on stdio (docs/mcp.md): each tool runs one of the commands above with `--json`, in the repository and with the server's environment, so it signs as the identity `OXY_HOME` configures (an agent home seals every change via the agent). `commit` and `amend` take explicit paths or `all: true`; `undo` and a forced push are offered only with `--allow-destructive`.
1052 +
1050 1053
Change arguments accept a ChangeId prefix, a ChangeHash prefix (with `#`), a bookmark or tag name, and `@` for the working-copy change. Ambiguous prefixes are an error.
1051 1054
1052 1055
Reserved for a later version, with these names: `oxy new`, `oxy edit`, `oxy redo`, revsets (`log -r`), and `oxy remote trust` (with the Noise transport, §15).
…
140 unchanged lines
modified src/cli/mod.rs +13 −0
…
5 unchanged lines
6 6
mod format;
7 7
mod identity;
8 8
mod json;
9 +
mod mcp;
9 10
mod proposal;
10 11
mod receipt;
11 12
mod sync;
…
194 unchanged lines
206 207
        #[arg(long)]
207 208
        json: bool,
208 209
    },
210 +
    /// Serve this repository to an AI agent over the Model Context Protocol (stdio), signing as this checkout's identity
211 +
    Mcp {
212 +
        /// The repository's root (default: the current directory)
213 +
        dir: Option<PathBuf>,
214 +
        /// Also offer tools that can lose work: undo, and a forced push
215 +
        #[arg(long)]
216 +
        allow_destructive: bool,
217 +
    },
209 218
    /// Print the version of oxy, its repository format, and protocol
210 219
    Version {
211 220
        /// Machine-readable output: one JSON document on stdout
…
1062 unchanged lines
1274 1283
        }
1275 1284
        Cmd::Cat { path, change } => workspace::cat(&path, change.as_deref()),
1276 1285
        Cmd::Root { json } => workspace::root(json),
1286 +
        Cmd::Mcp {
1287 +
            dir,
1288 +
            allow_destructive,
1289 +
        } => mcp::serve(dir, allow_destructive),
1277 1290
        Cmd::Version { json } => workspace::version(json),
1278 1291
        Cmd::Discard { paths, hunks } => workspace::discard(&paths, &hunks),
1279 1292
        Cmd::Hunks { json } => workspace::hunks(json),
…
384 unchanged lines