返回 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":{}},"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"}]}}\n' "$id"
35 ;;
36 tools/call)
37 printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"spawned-child"}]}}\n' "$id"
38 ;;
39 *)
40 printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"unsupported method"}}\n' "$id"
41 ;;
42 esac
43 done
44 "#;
45
46 struct Fixture {
47 _root: TempDir,
48 home: PathBuf,
49 }
50
51 impl Fixture {
52 /// Seal HOME before anything writes config. The suite has written to the
53 /// real `~/.codewhale/config.toml` before (#4831); this test must never be
54 /// the one that does it again.
55 fn new() -> Self {
56 let root = TempDir::new().expect("fixture root");
57 let home = root.path().join("sealed-home");
58 fs::create_dir_all(home.join(".codewhale")).expect("sealed config dir");
59 fs::write(home.join(".codewhale").join("config.toml"), "").expect("seed config");
60 Self { _root: root, home }
61 }
62
63 fn command(&self) -> Command {
64 let mut command = Command::new(codewhale_binary());
65 command
66 .env_clear()
67 .env("PATH", std::env::var("PATH").unwrap_or_default())
68 .env("HOME", &self.home)
69 .env("USERPROFILE", &self.home)
70 .env("CODEWHALE_HOME", self.home.join(".codewhale"))
71 .env("CODEWHALE_SECRET_BACKEND", "file");
72 command
73 }
74
75 fn write_fake_server(&self) -> PathBuf {
76 let script = self.home.join("fake-mcp-server.sh");
77 fs::write(&script, FAKE_SERVER).expect("write fake MCP server");
78 script
79 }
80
81 fn configure_servers(&self, definitions: Value) {
82 let output = self
83 .command()
84 .args(["config", "set", "mcp.server_definitions"])
85 .arg(definitions.to_string())
86 .output()
87 .expect("run config set");
88 assert!(
89 output.status.success(),
90 "config set failed\nstdout:\n{}\nstderr:\n{}",
91 String::from_utf8_lossy(&output.stdout),
92 String::from_utf8_lossy(&output.stderr)
93 );
94 }
95
96 /// Drive `codewhale mcp-server` over stdio with `requests`, returning the
97 /// parsed JSON-RPC responses plus stderr.
98 fn run_mcp_server(&self, requests: &[Value]) -> (Vec<Value>, String) {
99 let mut child = self
100 .command()
101 .arg("mcp-server")
102 .stdin(Stdio::piped())
103 .stdout(Stdio::piped())
104 .stderr(Stdio::piped())
105 .spawn()
106 .expect("spawn codewhale mcp-server");
107
108 {
109 let stdin = child.stdin.as_mut().expect("mcp-server stdin");
110 for request in requests {
111 writeln!(stdin, "{request}").expect("write request");
112 }
113 }
114
115 let output = child.wait_with_output().expect("mcp-server output");
116 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
117 let responses = String::from_utf8_lossy(&output.stdout)
118 .lines()
119 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
120 .collect();
121 (responses, stderr)
122 }
123 }
124
125 fn codewhale_binary() -> PathBuf {
126 if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale") {
127 return PathBuf::from(path);
128 }
129 if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale") {
130 return PathBuf::from(path);
131 }
132 let mut path = std::env::current_exe().expect("current test executable path");
133 path.pop();
134 if path.ends_with("deps") {
135 path.pop();
136 }
137 path.join("codewhale")
138 }
139
140 fn response_for(responses: &[Value], id: i64) -> &Value {
141 responses
142 .iter()
143 .find(|response| response["id"] == json!(id))
144 .unwrap_or_else(|| panic!("no response with id {id} in {responses:?}"))
145 }
146
147 #[test]
148 fn mcp_server_proxies_tools_from_the_configured_child_process() {
149 let fixture = Fixture::new();
150 let script = fixture.write_fake_server();
151 fixture.configure_servers(json!([{
152 "config": {
153 "name": "fake",
154 "command": "/bin/sh",
155 "args": [script.to_str().expect("utf-8 script path")],
156 }
157 }]));
158
159 let (responses, stderr) = fixture.run_mcp_server(&[
160 json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}),
161 json!({
162 "jsonrpc": "2.0",
163 "id": 2,
164 "method": "tools/call",
165 "params": {"name": "mcp__fake__whoami", "arguments": {}}
166 }),
167 json!({"jsonrpc": "2.0", "id": 3, "method": "shutdown"}),
168 ]);
169
170 let tools = response_for(&responses, 1)["result"]["tools"]
171 .as_array()
172 .unwrap_or_else(|| panic!("tools/list returned no array; stderr:\n{stderr}"))
173 .clone();
174 let names: Vec<&str> = tools
175 .iter()
176 .filter_map(|tool| tool["tool_name"].as_str())
177 .collect();
178 assert_eq!(
179 names,
180 vec!["whoami"],
181 "only the child's real tools may be exposed; the stub's fabricated \
182 `health`/`capabilities` must be gone. stderr:\n{stderr}"
183 );
184
185 let call = response_for(&responses, 2);
186 assert_eq!(
187 call["result"]["result"]["content"][0]["text"], "spawned-child",
188 "the tool result must come from the spawned process: {call}"
189 );
190 }
191
192 #[test]
193 fn mcp_server_reports_a_server_it_could_not_spawn() {
194 let fixture = Fixture::new();
195 fixture.configure_servers(json!([{
196 "config": {
197 "name": "broken",
198 "command": "codewhale-nonexistent-mcp-server-binary",
199 }
200 }]));
201
202 let (responses, stderr) = fixture.run_mcp_server(&[
203 json!({"jsonrpc": "2.0", "id": 1, "method": "server/list"}),
204 json!({
205 "jsonrpc": "2.0",
206 "id": 2,
207 "method": "tools/call",
208 "params": {"name": "mcp__broken__health", "arguments": {}}
209 }),
210 json!({"jsonrpc": "2.0", "id": 3, "method": "shutdown"}),
211 ]);
212
213 let server = response_for(&responses, 1)["result"]["lifecycle"]["servers"][0].clone();
214 assert_eq!(
215 server["running"],
216 json!(false),
217 "an unspawnable server must not report as running: {server}"
218 );
219 assert!(
220 server["error"]
221 .as_str()
222 .is_some_and(|error| error.contains("failed to spawn command")),
223 "the lifecycle must carry the spawn failure: {server}"
224 );
225 assert!(
226 stderr.contains("is not available"),
227 "the failure must also be loud on stderr, got:\n{stderr}"
228 );
229
230 let call = response_for(&responses, 2);
231 assert!(
232 call["error"].is_object(),
233 "a dead server must return an error, never a fabricated success: {call}"
234 );
235 }
236
236 lines RUST