Skip to content

Commit 0609812

Browse files
committed
test(cli): add fixture-driven mcp diagnose coverage
1 parent b69b6ec commit 0609812

2 files changed

Lines changed: 240 additions & 40 deletions

File tree

crates/loopforge-cli/src/dispatch/mcp/diagnose.rs

Lines changed: 74 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ use super::json::{build_mcp_diagnose_json, normalize_json_string};
88
use super::tools::list_remote_tool_names;
99
use crate::runtime_env;
1010

11+
pub(super) struct McpDiagnoseData {
12+
pub(super) sanitized_config: Value,
13+
pub(super) servers_raw: String,
14+
pub(super) servers_json: Value,
15+
pub(super) tool_names: Vec<String>,
16+
pub(super) resources_json: Option<Value>,
17+
pub(super) prompts_json: Option<Value>,
18+
}
19+
1120
pub(super) async fn run_diagnose(
1221
workspace: PathBuf,
1322
session: Option<String>,
@@ -43,45 +52,24 @@ pub(super) async fn run_diagnose(
4352
}
4453
};
4554

46-
let parsed_config: Value =
47-
serde_json::from_str(&raw_config).context("parse mcp config JSON")?;
48-
let sanitized_config = sanitize_mcp_config(&parsed_config);
49-
50-
let mut tools =
51-
rexos::tools::Toolset::new_with_security_config(workspace.clone(), cfg.security.clone())?;
52-
tools
53-
.enable_mcp_from_json(&raw_config)
54-
.await
55-
.context("connect mcp servers")?;
56-
57-
let servers_raw = tools.call("mcp_servers_list", r#"{}"#).await?;
58-
let servers_json: Value =
59-
serde_json::from_str(&servers_raw).context("decode mcp_servers_list output")?;
60-
let tool_names = list_remote_tool_names(&tools);
61-
62-
let resources_json = if resources {
63-
let out = tools.call("mcp_resources_list", r#"{}"#).await?;
64-
Some(serde_json::from_str(&out).context("decode mcp_resources_list output")?)
65-
} else {
66-
None
67-
};
68-
69-
let prompts_json = if prompts {
70-
let out = tools.call("mcp_prompts_list", r#"{}"#).await?;
71-
Some(serde_json::from_str(&out).context("decode mcp_prompts_list output")?)
72-
} else {
73-
None
74-
};
55+
let data = collect_mcp_diagnose_data(
56+
workspace.clone(),
57+
&raw_config,
58+
cfg.security.clone(),
59+
resources,
60+
prompts,
61+
)
62+
.await?;
7563

7664
if json {
7765
let out = build_mcp_diagnose_json(
7866
&workspace,
7967
&session_id,
80-
sanitized_config,
81-
servers_json,
82-
tool_names,
83-
resources_json,
84-
prompts_json,
68+
data.sanitized_config,
69+
data.servers_json,
70+
data.tool_names,
71+
data.resources_json,
72+
data.prompts_json,
8573
)?;
8674
println!("{}", serde_json::to_string_pretty(&out)?);
8775
return Ok(());
@@ -96,7 +84,8 @@ pub(super) async fn run_diagnose(
9684
} else {
9785
println!("config_source: session");
9886
}
99-
if let Some(servers) = sanitized_config
87+
if let Some(servers) = data
88+
.sanitized_config
10089
.get("servers")
10190
.and_then(|v| v.as_object())
10291
.map(|obj| obj.keys().cloned().collect::<Vec<_>>())
@@ -106,18 +95,63 @@ pub(super) async fn run_diagnose(
10695
}
10796
}
10897
println!();
109-
println!("servers: {}", servers_raw.trim());
110-
println!("remote_tools: {} tool(s)", tool_names.len());
111-
for name in tool_names {
98+
println!("servers: {}", data.servers_raw.trim());
99+
println!("remote_tools: {} tool(s)", data.tool_names.len());
100+
for name in data.tool_names {
112101
println!("- {name}");
113102
}
114-
if let Some(v) = resources_json {
103+
if let Some(v) = data.resources_json {
115104
println!();
116105
println!("resources_list: {}", serde_json::to_string_pretty(&v)?);
117106
}
118-
if let Some(v) = prompts_json {
107+
if let Some(v) = data.prompts_json {
119108
println!();
120109
println!("prompts_list: {}", serde_json::to_string_pretty(&v)?);
121110
}
122111
Ok(())
123112
}
113+
114+
pub(super) async fn collect_mcp_diagnose_data(
115+
workspace: PathBuf,
116+
raw_config: &str,
117+
security: rexos::security::SecurityConfig,
118+
resources: bool,
119+
prompts: bool,
120+
) -> anyhow::Result<McpDiagnoseData> {
121+
let parsed_config: Value = serde_json::from_str(raw_config).context("parse mcp config JSON")?;
122+
let sanitized_config = sanitize_mcp_config(&parsed_config);
123+
124+
let mut tools = rexos::tools::Toolset::new_with_security_config(workspace, security)?;
125+
tools
126+
.enable_mcp_from_json(raw_config)
127+
.await
128+
.context("connect mcp servers")?;
129+
130+
let servers_raw = tools.call("mcp_servers_list", r#"{}"#).await?;
131+
let servers_json: Value =
132+
serde_json::from_str(&servers_raw).context("decode mcp_servers_list output")?;
133+
let tool_names = list_remote_tool_names(&tools);
134+
135+
let resources_json = if resources {
136+
let out = tools.call("mcp_resources_list", r#"{}"#).await?;
137+
Some(serde_json::from_str(&out).context("decode mcp_resources_list output")?)
138+
} else {
139+
None
140+
};
141+
142+
let prompts_json = if prompts {
143+
let out = tools.call("mcp_prompts_list", r#"{}"#).await?;
144+
Some(serde_json::from_str(&out).context("decode mcp_prompts_list output")?)
145+
} else {
146+
None
147+
};
148+
149+
Ok(McpDiagnoseData {
150+
sanitized_config,
151+
servers_raw,
152+
servers_json,
153+
tool_names,
154+
resources_json,
155+
prompts_json,
156+
})
157+
}

crates/loopforge-cli/src/dispatch/mcp/tests.rs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,108 @@
11
use super::super::mcp_sanitize::sanitize_mcp_config;
2+
use super::diagnose::collect_mcp_diagnose_data;
23
use super::json::{build_mcp_diagnose_json, normalize_json_string};
4+
use std::path::Path;
5+
6+
const MCP_STUB_PY: &str = r#"
7+
import json
8+
import sys
9+
10+
def send(obj):
11+
sys.stdout.write(json.dumps(obj) + "\n")
12+
sys.stdout.flush()
13+
14+
for line in sys.stdin:
15+
line = line.strip()
16+
if not line:
17+
continue
18+
try:
19+
msg = json.loads(line)
20+
except Exception:
21+
continue
22+
23+
method = msg.get("method")
24+
if not method:
25+
continue
26+
27+
# Notifications have no id; ignore them.
28+
if "id" not in msg:
29+
continue
30+
31+
msg_id = msg.get("id")
32+
params = msg.get("params") or {}
33+
34+
if method == "initialize":
35+
send({"jsonrpc": "2.0", "id": msg_id, "result": {}})
36+
elif method == "tools/list":
37+
send({
38+
"jsonrpc": "2.0",
39+
"id": msg_id,
40+
"result": {
41+
"tools": [
42+
{
43+
"name": "echo",
44+
"description": "Echo input text",
45+
"inputSchema": {
46+
"type": "object",
47+
"properties": {"text": {"type": "string"}},
48+
"required": ["text"],
49+
"additionalProperties": False
50+
}
51+
}
52+
]
53+
}
54+
})
55+
elif method == "resources/list":
56+
send({
57+
"jsonrpc": "2.0",
58+
"id": msg_id,
59+
"result": {
60+
"resources": [{"uri": "mem://hello", "name": "hello", "mimeType": "text/plain"}],
61+
"nextCursor": None,
62+
}
63+
})
64+
elif method == "prompts/list":
65+
send({
66+
"jsonrpc": "2.0",
67+
"id": msg_id,
68+
"result": {
69+
"prompts": [{"name": "greet", "description": "greet prompt"}],
70+
"nextCursor": None,
71+
}
72+
})
73+
else:
74+
send({"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32601, "message": "unknown method"}})
75+
"#;
76+
77+
fn python_exe() -> &'static str {
78+
if cfg!(windows) {
79+
"python"
80+
} else {
81+
"python3"
82+
}
83+
}
84+
85+
fn write_mcp_stub(root: &Path) -> std::path::PathBuf {
86+
let path = root.join("mcp_diagnose_stub.py");
87+
std::fs::write(&path, MCP_STUB_PY).expect("write mcp diagnose stub script");
88+
path
89+
}
90+
91+
fn mcp_config_json(script: &Path) -> String {
92+
serde_json::json!({
93+
"servers": {
94+
"stub": {
95+
"command": python_exe(),
96+
"args": ["-u", script.to_string_lossy()],
97+
"cwd": ".",
98+
"env": {
99+
"API_KEY": "secret",
100+
}
101+
}
102+
}
103+
})
104+
.to_string()
105+
}
3106

4107
#[test]
5108
fn build_mcp_diagnose_json_includes_expected_keys_and_redaction() {
@@ -76,3 +179,66 @@ fn sanitize_mcp_config_redacts_env_values() {
76179
Some("python")
77180
);
78181
}
182+
183+
#[tokio::test]
184+
async fn collect_mcp_diagnose_data_includes_tools_resources_prompts_and_redaction() {
185+
let tmp = tempfile::tempdir().unwrap();
186+
let workspace = tmp.path().join("workspace");
187+
std::fs::create_dir_all(&workspace).unwrap();
188+
let stub = write_mcp_stub(tmp.path());
189+
let config_json = mcp_config_json(&stub);
190+
191+
let out = collect_mcp_diagnose_data(
192+
workspace.clone(),
193+
&config_json,
194+
rexos::security::SecurityConfig::default(),
195+
true,
196+
true,
197+
)
198+
.await
199+
.unwrap();
200+
201+
assert_eq!(out.servers_json, serde_json::json!(["stub"]));
202+
assert!(out.tool_names.contains(&"mcp_stub__echo".to_string()));
203+
assert_eq!(
204+
out.sanitized_config["servers"]["stub"]["env"]["API_KEY"].as_str(),
205+
Some("[redacted]")
206+
);
207+
let resources = out
208+
.resources_json
209+
.as_ref()
210+
.expect("resources should be present");
211+
assert_eq!(resources[0]["server"].as_str(), Some("stub"));
212+
assert_eq!(
213+
resources[0]["resources"][0]["uri"].as_str(),
214+
Some("mem://hello")
215+
);
216+
let prompts = out
217+
.prompts_json
218+
.as_ref()
219+
.expect("prompts should be present");
220+
assert_eq!(prompts[0]["server"].as_str(), Some("stub"));
221+
assert_eq!(prompts[0]["prompts"][0]["name"].as_str(), Some("greet"));
222+
}
223+
224+
#[tokio::test]
225+
async fn collect_mcp_diagnose_data_skips_optional_lists_when_disabled() {
226+
let tmp = tempfile::tempdir().unwrap();
227+
let workspace = tmp.path().join("workspace");
228+
std::fs::create_dir_all(&workspace).unwrap();
229+
let stub = write_mcp_stub(tmp.path());
230+
let config_json = mcp_config_json(&stub);
231+
232+
let out = collect_mcp_diagnose_data(
233+
workspace,
234+
&config_json,
235+
rexos::security::SecurityConfig::default(),
236+
false,
237+
false,
238+
)
239+
.await
240+
.unwrap();
241+
242+
assert!(out.resources_json.is_none());
243+
assert!(out.prompts_json.is_none());
244+
}

0 commit comments

Comments
 (0)