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 |