返回 CodeWhale
mcp_server_proxy.rs
根目录 / crates / cli / tests / mcp_server_proxy.rs
1 //! `codewhale mcp-server` must proxy to the user's configured servers.
2 //!
3 //! Regression coverage for #4727, where every configured server was wired to
4 //! an in-process stub: `command`/`args`/`env` were never executed, `health`
5 //! and `capabilities` answered `{"status": "ok"}` from a hardcoded literal,
6 //! and every real tool came back "not found". A client had no way to tell a
7 //! working integration from a fabricated one, which is why these tests assert
8 //! on the *origin* of the answer, not merely that an answer arrived.
9
10 #![cfg(unix)]
11
12 use std::fs;
13 use std::io::Write;
14 use std::path::PathBuf;
15 use std::process::{Command, Stdio};
16
17 use serde_json::{Value, json};
18 use tempfile::TempDir;
19
20 /// A minimal MCP server in POSIX sh, so the test depends on nothing beyond the
21 /// shell already present on every unix runner.
22 const FAKE_SERVER: &str = r#"#!/bin/sh
23 while IFS= read -r line; do
24 id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
25 method=$(printf '%s' "$line" | sed -n 's/.*"method":"\([^"]*\)".*/\1/p')
26 if [ -z "$id" ]; then
27 continue
28 fi
29 case "$method" in
30 initialize)
31 printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{},"resources":{}},"serverInfo":{"name":"fake-mcp","version":"0"}}}\n' "$id"
32 ;;
33 tools/list)
34 printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"whoami","description":"report the spawned process","inputSchema":{"type":"object","properties":{}}}]}}\n' "$id"
35 ;;
36 tools/call)
37 printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"spawned-child"}]}}\n' "$id"
38 ;;
39 resources/list)
40 printf '{"jsonrpc":"2.0","id":%s,"result":{"resources":[{"uri":"file:///fake/readme.txt","name":"Fake readme","description":"resource from the spawned process","mimeType":"text/plain","size":16,"annotations":{"audience":["assistant"],"priority":0.75}}]}}\n' "$id"
41 ;;
42 resources/read)
43 printf '{"jsonrpc":"2.0","id":%s,"result":{"contents":[{"uri":"file:///fake/readme.txt","mimeType":"text/plain","text":"spawned-resource"}]}}\n' "$id"
44 ;;
45 *)
46 printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"unsupported method"}}\n' "$id"
47 ;;
48 esac
49 done
50 "#;
51
52 struct Fixture {
53 _root: TempDir,
54 home: PathBuf,
55 }
56
57 impl Fixture {
58 /// Seal HOME before anything writes config. The suite has written to the
59 /// real `~/.codewhale/config.toml` before (#4831); this test must never be
60 /// the one that does it again.
61 fn new() -> Self {
62 let root = TempDir::new().expect("fixture root");
63 let home = root.path().join("sealed-home");
64 fs::create_dir_all(home.join(".codewhale")).expect("sealed config dir");
65 fs::write(home.join(".codewhale").join("config.toml"), "").expect("seed config");
66 Self { _root: root, home }
67 }
68
69 fn command(&self) -> Command {
70 let mut command = Command::new(codewhale_binary());
71 command
72 .env_clear()
73 .env("PATH", std::env::var("PATH").unwrap_or_default())
74 .env("HOME", &self.home)
75 .env("USERPROFILE", &self.home)
76 .env("CODEWHALE_HOME", self.home.join(".codewhale"))
77 .env("CODEWHALE_SECRET_BACKEND", "file");
78 command
79 }
80
81 fn write_fake_server(&self) -> PathBuf {
82 let script = self.home.join("fake-mcp-server.sh");
83 fs::write(&script, FAKE_SERVER).expect("write fake MCP server");
84 script
85 }
86
87 fn configure_servers(&self, definitions: Value) {
88 let output = self
89 .command()
90 .args(["config", "set", "mcp.server_definitions"])
91 .arg(definitions.to_string())
92 .output()
93 .expect("run config set");
94 assert!(
95 output.status.success(),
96 "config set failed\nstdout:\n{}\nstderr:\n{}",
97 String::from_utf8_lossy(&output.stdout),
98 String::from_utf8_lossy(&output.stderr)
99 );
100 }
101
102 /// Drive `codewhale mcp-server` over stdio with `requests`, returning the
103 /// parsed JSON-RPC responses plus stderr.
104 fn run_mcp_server(&self, requests: &[Value]) -> (Vec<Value>, String) {
105 let mut child = self
106 .command()
107 .arg("mcp-server")
108 .stdin(Stdio::piped())
109 .stdout(Stdio::piped())
110 .stderr(Stdio::piped())
111 .spawn()
112 .expect("spawn codewhale mcp-server");
113
114 {
115 let stdin = child.stdin.as_mut().expect("mcp-server stdin");
116 for request in requests {
117 writeln!(stdin, "{request}").expect("write request");
118 }
119 }
120
121 let output = child.wait_with_output().expect("mcp-server output");
122 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
123 let responses = String::from_utf8_lossy(&output.stdout)
124 .lines()
125 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
126 .collect();
127 (responses, stderr)
128 }
129 }
130
131 fn codewhale_binary() -> PathBuf {
132 if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale") {
133 return PathBuf::from(path);
134 }
135 if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale") {
136 return PathBuf::from(path);
137 }
138 let mut path = std::env::current_exe().expect("current test executable path");
139 path.pop();
140 if path.ends_with("deps") {
141 path.pop();
142 }
143 path.join("codewhale")
144 }
145
146 fn response_for(responses: &[Value], id: i64) -> &Value {
147 responses
148 .iter()
149 .find(|response| response["id"] == json!(id))
150 .unwrap_or_else(|| panic!("no response with id {id} in {responses:?}"))
151 }
152
153 #[test]
154 fn mcp_server_enforces_jsonrpc_identity_and_initialize_lifecycle() {
155 let fixture = Fixture::new();
156 let (responses, stderr) = fixture.run_mcp_server(&[
157 json!({"id": 1, "method": "ping"}),
158 json!({"jsonrpc": "2.0", "id": null, "method": "ping"}),
159 json!({"jsonrpc": "2", "id": 2, "method": "ping"}),
160 json!({"jsonrpc": "2.0", "id": 3, "method": "tools/list"}),
161 json!({
162 "jsonrpc": "2.0",
163 "id": 4,
164 "method": "initialize",
165 "params": {
166 "protocolVersion": "2024-11-05",
167 "clientInfo": {"name": "lifecycle-test", "version": "1"},
168 "capabilities": {}
169 }
170 }),
171 json!({"jsonrpc": "2.0", "id": 5, "method": "resources/list"}),
172 json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
173 json!({"jsonrpc": "2.0", "method": "ping"}),
174 json!({"jsonrpc": "2.0", "id": 6, "method": "tools/list"}),
175 json!({"jsonrpc": "2.0", "id": 7, "method": "shutdown"}),
176 ]);
177
178 let null_id_responses: Vec<&Value> = responses
179 .iter()
180 .filter(|response| response["id"].is_null())
181 .collect();
182 assert_eq!(
183 null_id_responses.len(),
184 2,
185 "missing id must be a notification while explicit null receives a response: {responses:?}"
186 );
187 assert_eq!(null_id_responses[0]["error"]["code"], -32600);
188 assert!(null_id_responses[1]["result"].is_object());
189
190 assert_eq!(response_for(&responses, 2)["error"]["code"], -32600);
191 assert_eq!(response_for(&responses, 3)["error"]["code"], -32600);
192 assert!(
193 response_for(&responses, 3)["error"]["message"]
194 .as_str()
195 .is_some_and(|message| message.contains("completed initialize"))
196 );
197 assert_eq!(
198 response_for(&responses, 4)["result"]["protocolVersion"],
199 "2024-11-05"
200 );
201 assert_eq!(response_for(&responses, 5)["error"]["code"], -32600);
202 assert_eq!(response_for(&responses, 6)["result"]["tools"], json!([]));
203 assert!(
204 stderr.contains("codewhale mcp-server: stdio server exited"),
205 "missing clean shutdown receipt:\n{stderr}"
206 );
207 }
208
209 #[test]
210 fn mcp_server_proxies_tools_and_resources_from_the_configured_child_process() {
211 let fixture = Fixture::new();
212 let script = fixture.write_fake_server();
213 fixture.configure_servers(json!([{
214 "config": {
215 "name": "fake",
216 "command": "/bin/sh",
217 "args": [script.to_str().expect("utf-8 script path")],
218 }
219 }]));
220
221 let (responses, stderr) = fixture.run_mcp_server(&[
222 json!({
223 "jsonrpc": "2.0",
224 "id": 0,
225 "method": "initialize",
226 "params": {
227 "protocolVersion": "2024-11-05",
228 "clientInfo": {"name": "proxy-test", "version": "1"},
229 "capabilities": {}
230 }
231 }),
232 json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
233 json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}),
234 json!({
235 "jsonrpc": "2.0",
236 "id": 2,
237 "method": "tools/call",
238 "params": {"name": "mcp__fake__whoami", "arguments": {}}
239 }),
240 json!({"jsonrpc": "2.0", "id": 3, "method": "resources/list"}),
241 json!({
242 "jsonrpc": "2.0",
243 "id": 4,
244 "method": "resources/read",
245 "params": {"uri": "file:///fake/readme.txt"}
246 }),
247 json!({"jsonrpc": "2.0", "id": 5, "method": "shutdown"}),
248 ]);
249
250 let initialize = response_for(&responses, 0);
251 assert_eq!(initialize["result"]["protocolVersion"], "2024-11-05");
252 assert_eq!(
253 initialize["result"]["serverInfo"]["name"],
254 "codewhale-mcp-server"
255 );
256 assert!(initialize["result"]["capabilities"]["tools"].is_object());
257 assert!(initialize["result"]["capabilities"]["resources"].is_object());
258
259 let tools = response_for(&responses, 1)["result"]["tools"]
260 .as_array()
261 .unwrap_or_else(|| panic!("tools/list returned no array; stderr:\n{stderr}"))
262 .clone();
263 let names: Vec<&str> = tools
264 .iter()
265 .filter_map(|tool| tool["name"].as_str())
266 .collect();
267 assert_eq!(
268 names,
269 vec!["mcp__fake__whoami"],
270 "only the child's real tools may be exposed; the stub's fabricated \
271 `health`/`capabilities` must be gone. stderr:\n{stderr}"
272 );
273 assert_eq!(tools[0]["tool_name"], "whoami");
274 assert!(tools[0]["inputSchema"].is_object());
275
276 let call = response_for(&responses, 2);
277 assert_eq!(
278 call["result"]["content"][0]["text"], "spawned-child",
279 "the standard MCP result must come from the spawned process: {call}"
280 );
281 assert_eq!(
282 call["result"]["result"]["content"][0]["text"], "spawned-child",
283 "the legacy nested result must remain available: {call}"
284 );
285
286 let resources = response_for(&responses, 3)["result"]["resources"]
287 .as_array()
288 .unwrap_or_else(|| panic!("resources/list returned no array; stderr:\n{stderr}"));
289 assert_eq!(resources.len(), 1);
290 assert_eq!(resources[0]["uri"], "file:///fake/readme.txt");
291 assert_eq!(resources[0]["name"], "Fake readme");
292 assert_eq!(resources[0]["mimeType"], "text/plain");
293 assert_eq!(resources[0]["size"], 16);
294 assert_eq!(
295 resources[0]["annotations"]["audience"],
296 json!(["assistant"])
297 );
298 assert_eq!(resources[0]["annotations"]["priority"], 0.75);
299 assert_eq!(resources[0]["server_name"], "fake");
300
301 let read = response_for(&responses, 4);
302 assert_eq!(read["result"]["contents"][0]["text"], "spawned-resource");
303 assert_eq!(
304 read["result"]["resource"]["contents"][0]["text"], "spawned-resource",
305 "the legacy nested resource must remain available: {read}"
306 );
307 assert!(
308 !stderr.contains("deepseek-mcp"),
309 "stale identity in stderr:\n{stderr}"
310 );
311 assert!(
312 stderr.contains("codewhale mcp-server: stdio server exited"),
313 "missing Codewhale shutdown identity in stderr:\n{stderr}"
314 );
315 }
316
317 #[test]
318 fn mcp_server_reports_a_server_it_could_not_spawn() {
319 let fixture = Fixture::new();
320 fixture.configure_servers(json!([{
321 "config": {
322 "name": "broken",
323 "command": "codewhale-nonexistent-mcp-server-binary",
324 }
325 }]));
326
327 let (responses, stderr) = fixture.run_mcp_server(&[
328 json!({"jsonrpc": "2.0", "id": 1, "method": "server/list"}),
329 json!({
330 "jsonrpc": "2.0",
331 "id": 10,
332 "method": "initialize",
333 "params": {
334 "protocolVersion": "2024-11-05",
335 "clientInfo": {"name": "failure-test", "version": "1"},
336 "capabilities": {}
337 }
338 }),
339 json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
340 json!({
341 "jsonrpc": "2.0",
342 "id": 2,
343 "method": "tools/call",
344 "params": {"name": "mcp__broken__health", "arguments": {}}
345 }),
346 json!({"jsonrpc": "2.0", "id": 3, "method": "shutdown"}),
347 ]);
348
349 let server = response_for(&responses, 1)["result"]["lifecycle"]["servers"][0].clone();
350 assert_eq!(
351 server["running"],
352 json!(false),
353 "an unspawnable server must not report as running: {server}"
354 );
355 assert!(
356 server["error"]
357 .as_str()
358 .is_some_and(|error| error.contains("failed to spawn command")),
359 "the lifecycle must carry the spawn failure: {server}"
360 );
361 assert!(
362 stderr.contains("is not available"),
363 "the failure must also be loud on stderr, got:\n{stderr}"
364 );
365
366 let call = response_for(&responses, 2);
367 assert!(
368 call["error"].is_object(),
369 "a dead server must return an error, never a fabricated success: {call}"
370 );
371 }
372
372 lines RUST