返回 CodeWhale
mcp_server_proxy.rs
根目录 / crates / cli / tests / mcp_server_proxy.rs
1 //! The retired aggregation proxy's CLI spelling delegates to the native server.
2 //! Exercise both real binary entrypoints with isolated config; never launch or
3 //! rewrite legacy child-server definitions. The active MCP client is separate.
4
5 use std::fs;
6 use std::io::Write;
7 use std::path::PathBuf;
8 use std::process::{Command, Stdio};
9
10 use serde_json::{Value, json};
11 use tempfile::TempDir;
12
13 struct Fixture {
14 _root: TempDir,
15 home: PathBuf,
16 }
17
18 impl Fixture {
19 /// Seal HOME before anything writes config. The suite has written to the
20 /// real `~/.codewhale/config.toml` before (#4831); this test must never be
21 /// the one that does it again.
22 fn new() -> Self {
23 let root = TempDir::new().expect("fixture root");
24 let home = root.path().join("sealed-home");
25 fs::create_dir_all(home.join(".codewhale")).expect("sealed config dir");
26 fs::write(home.join(".codewhale").join("config.toml"), "").expect("seed config");
27 Self { _root: root, home }
28 }
29
30 fn command(&self) -> Command {
31 let mut command = Command::new(codewhale_binary());
32 command
33 .current_dir(&self.home)
34 .env_clear()
35 .env("PATH", std::env::var("PATH").unwrap_or_default())
36 .env("HOME", &self.home)
37 .env("USERPROFILE", &self.home)
38 .env("CODEWHALE_HOME", self.home.join(".codewhale"))
39 .env("CODEWHALE_SECRET_BACKEND", "file");
40 command
41 }
42
43 fn configure_servers(&self, definitions: Value) {
44 let output = self
45 .command()
46 .args(["config", "set", "mcp.server_definitions"])
47 .arg(definitions.to_string())
48 .output()
49 .expect("run config set");
50 assert!(
51 output.status.success(),
52 "config set failed\nstdout:\n{}\nstderr:\n{}",
53 String::from_utf8_lossy(&output.stdout),
54 String::from_utf8_lossy(&output.stderr)
55 );
56 }
57
58 /// Drive `codewhale mcp-server` over stdio with `requests`, returning the
59 /// parsed JSON-RPC responses plus stderr.
60 fn run_mcp_server(&self, args: &[&str], requests: &[Value]) -> (Vec<Value>, String) {
61 let mut child = self
62 .command()
63 .args(args)
64 .stdin(Stdio::piped())
65 .stdout(Stdio::piped())
66 .stderr(Stdio::piped())
67 .spawn()
68 .expect("spawn codewhale mcp-server");
69
70 {
71 let stdin = child.stdin.as_mut().expect("mcp-server stdin");
72 for request in requests {
73 writeln!(stdin, "{request}").expect("write request");
74 }
75 }
76
77 let output = child.wait_with_output().expect("mcp-server output");
78 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
79 assert!(
80 output.status.success(),
81 "native MCP server failed: {stderr}"
82 );
83 let responses = String::from_utf8_lossy(&output.stdout)
84 .lines()
85 .map(|line| serde_json::from_str::<Value>(line).expect("stdout is only JSON-RPC"))
86 .collect();
87 (responses, stderr)
88 }
89 }
90
91 fn codewhale_binary() -> PathBuf {
92 if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale") {
93 return PathBuf::from(path);
94 }
95 if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale") {
96 return PathBuf::from(path);
97 }
98 let mut path = std::env::current_exe().expect("current test executable path");
99 path.pop();
100 if path.ends_with("deps") {
101 path.pop();
102 }
103 path.join("codewhale")
104 }
105
106 fn initialize() -> Value {
107 json!({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {
108 "protocolVersion": "2024-11-05",
109 "clientInfo": {"name": "native-alias-test", "version": "1"},
110 "capabilities": {}
111 }})
112 }
113
114 #[test]
115 fn both_cli_spellings_expose_the_same_native_read_only_tools() {
116 let fixture = Fixture::new();
117 let requests = [
118 initialize(),
119 json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
120 json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}),
121 json!({"jsonrpc": "2.0", "id": 2, "method": "ping"}),
122 json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {
123 "name": "file_write", "approved": true,
124 "arguments": {"path": "forbidden.txt", "content": "must not be written"}
125 }}),
126 ];
127 let (alias, _) = fixture.run_mcp_server(&["mcp-server"], &requests);
128 let (native, _) = fixture.run_mcp_server(&["serve", "--mcp"], &requests);
129 assert_eq!(alias, native);
130 assert_eq!(alias.len(), 4);
131 assert_eq!(
132 alias[0]["result"]["serverInfo"]["name"],
133 "codewhale-mcp-server"
134 );
135 assert_eq!(alias[0]["result"]["protocolVersion"], "2024-11-05");
136 let names: Vec<&str> = alias[1]["result"]["tools"]
137 .as_array()
138 .unwrap()
139 .iter()
140 .map(|tool| tool["name"].as_str().unwrap())
141 .collect();
142 assert_eq!(names, ["file_read", "search"]);
143 assert_eq!(alias[2]["result"], json!({}));
144 assert_eq!(alias[3]["error"]["code"], -32602);
145 assert!(!fixture.home.join("forbidden.txt").exists());
146 }
147
148 #[test]
149 fn alias_neither_launches_nor_rewrites_legacy_child_server_definitions() {
150 let fixture = Fixture::new();
151 let marker = fixture.home.join("legacy-child-was-launched");
152 // Even an invalid executable would produce a visible startup failure in
153 // the old proxy. The native server must never attempt this launch.
154 fixture.configure_servers(json!([{"config": {
155 "name": "retired-proxy", "command": marker,
156 "env": {"PRIVATE_TOKEN": "test-only-sentinel"}
157 }}]));
158 let config = fixture.home.join(".codewhale/config.toml");
159 let before = fs::read(&config).unwrap();
160 let (responses, stderr) = fixture.run_mcp_server(
161 &["mcp-server"],
162 &[
163 initialize(),
164 json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
165 json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}),
166 ],
167 );
168 assert_eq!(responses.len(), 2);
169 assert_eq!(responses[1]["result"]["tools"].as_array().unwrap().len(), 2);
170 assert_eq!(
171 fs::read(config).unwrap(),
172 before,
173 "saved legacy data is untouched"
174 );
175 assert!(!stderr.contains("retired-proxy"), "{stderr}");
176 assert!(!stderr.contains("PRIVATE_TOKEN"), "{stderr}");
177 assert!(!stderr.contains("test-only-sentinel"), "{stderr}");
178 assert!(!marker.exists());
179 }
180
180 lines RUST