OXYGENHUB
◆ main oxygen / tests / cli_mcp.rs
7.0 KiB · Rust · last changed oxy mcp: an MCP server for a repository, signing as its configured identity (closes #159) 4 days ago source blame history raw
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
}