Skip to content

Commit dd2cff9

Browse files
committed
test(cli): lock down --json output shape
1 parent 2c382f8 commit dd2cff9

2 files changed

Lines changed: 252 additions & 50 deletions

File tree

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

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -85,21 +85,15 @@ async fn diagnose(
8585
};
8686

8787
if json {
88-
let mut out = BTreeMap::new();
89-
out.insert(
90-
"workspace".to_string(),
91-
Value::String(workspace.display().to_string()),
92-
);
93-
out.insert("session_id".to_string(), Value::String(session_id));
94-
out.insert("config".to_string(), sanitized_config);
95-
out.insert("servers".to_string(), servers_json);
96-
out.insert("tool_names".to_string(), serde_json::to_value(tool_names)?);
97-
if let Some(v) = resources_json {
98-
out.insert("resources".to_string(), v);
99-
}
100-
if let Some(v) = prompts_json {
101-
out.insert("prompts".to_string(), v);
102-
}
88+
let out = build_mcp_diagnose_json(
89+
&workspace,
90+
&session_id,
91+
sanitized_config,
92+
servers_json,
93+
tool_names,
94+
resources_json,
95+
prompts_json,
96+
)?;
10397
println!("{}", serde_json::to_string_pretty(&out)?);
10498
return Ok(());
10599
}
@@ -139,6 +133,36 @@ async fn diagnose(
139133
Ok(())
140134
}
141135

136+
fn build_mcp_diagnose_json(
137+
workspace: &PathBuf,
138+
session_id: &str,
139+
sanitized_config: Value,
140+
servers_json: Value,
141+
tool_names: Vec<String>,
142+
resources_json: Option<Value>,
143+
prompts_json: Option<Value>,
144+
) -> anyhow::Result<Value> {
145+
let mut out = BTreeMap::new();
146+
out.insert(
147+
"workspace".to_string(),
148+
Value::String(workspace.display().to_string()),
149+
);
150+
out.insert(
151+
"session_id".to_string(),
152+
Value::String(session_id.to_string()),
153+
);
154+
out.insert("config".to_string(), sanitized_config);
155+
out.insert("servers".to_string(), servers_json);
156+
out.insert("tool_names".to_string(), serde_json::to_value(tool_names)?);
157+
if let Some(v) = resources_json {
158+
out.insert("resources".to_string(), v);
159+
}
160+
if let Some(v) = prompts_json {
161+
out.insert("prompts".to_string(), v);
162+
}
163+
Ok(Value::Object(out.into_iter().collect()))
164+
}
165+
142166
fn normalize_json_string(raw: &str) -> anyhow::Result<String> {
143167
let json: Value = serde_json::from_str(raw).context("parse JSON")?;
144168
Ok(serde_json::to_string(&json)?)
@@ -195,6 +219,55 @@ fn sanitize_mcp_config(value: &Value) -> Value {
195219
mod tests {
196220
use super::*;
197221

222+
#[test]
223+
fn build_mcp_diagnose_json_includes_expected_keys_and_redaction() {
224+
let tmp = tempfile::tempdir().unwrap();
225+
let workspace = tmp.path().join("workspace");
226+
227+
let config = serde_json::json!({
228+
"servers": {
229+
"s1": {
230+
"command": "python",
231+
"env": { "API_KEY": "secret" },
232+
}
233+
}
234+
});
235+
let sanitized = sanitize_mcp_config(&config);
236+
237+
let out = build_mcp_diagnose_json(
238+
&workspace,
239+
"s-test",
240+
sanitized,
241+
serde_json::json!(["s1"]),
242+
vec!["mcp_s1__echo".to_string()],
243+
None,
244+
None,
245+
)
246+
.unwrap();
247+
248+
assert_eq!(
249+
out["workspace"].as_str(),
250+
Some(workspace.display().to_string().as_str())
251+
);
252+
assert_eq!(out["session_id"].as_str(), Some("s-test"));
253+
assert!(out.get("servers").is_some());
254+
assert!(out.get("tool_names").is_some());
255+
assert_eq!(
256+
out["config"]["servers"]["s1"]["env"]["API_KEY"].as_str(),
257+
Some("[redacted]")
258+
);
259+
260+
let tool_names: Vec<String> = out["tool_names"]
261+
.as_array()
262+
.unwrap()
263+
.iter()
264+
.filter_map(|v| v.as_str().map(|s| s.to_string()))
265+
.collect();
266+
assert_eq!(tool_names, vec!["mcp_s1__echo".to_string()]);
267+
assert!(out.get("resources").is_none());
268+
assert!(out.get("prompts").is_none());
269+
}
270+
198271
#[test]
199272
fn normalize_json_string_round_trips() {
200273
let out = normalize_json_string(" {\"servers\":{}} ").unwrap();

crates/loopforge-cli/src/dispatch/session.rs

Lines changed: 164 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,45 +24,22 @@ pub(super) async fn run(command: SessionCommand) -> anyhow::Result<()> {
2424
.load_session_policy_snapshot(&session_id)
2525
.with_context(|| format!("load session policy snapshot: {session_id}"))?;
2626

27-
let (mcp_servers, mcp_config_sanitized, mcp_parse_error) =
28-
summarize_mcp_config(snapshot.mcp_config_json.as_deref(), show_mcp_config);
29-
3027
if json {
31-
let mut out = BTreeMap::new();
32-
out.insert("session_id".to_string(), Value::String(session_id));
33-
out.insert(
34-
"allowed_tools".to_string(),
35-
serde_json::to_value(&snapshot.allowed_tools)?,
36-
);
37-
out.insert(
38-
"allowed_skills".to_string(),
39-
serde_json::to_value(&snapshot.allowed_skills)?,
40-
);
41-
out.insert(
42-
"skill_policy".to_string(),
43-
serde_json::to_value(&snapshot.skill_policy)?,
44-
);
45-
46-
let mut mcp = BTreeMap::new();
47-
mcp.insert(
48-
"present".to_string(),
49-
Value::Bool(snapshot.mcp_config_json.is_some()),
50-
);
51-
if let Some(servers) = mcp_servers {
52-
mcp.insert("servers".to_string(), serde_json::to_value(servers)?);
53-
}
54-
if let Some(err) = mcp_parse_error {
55-
mcp.insert("parse_error".to_string(), Value::String(err));
56-
}
57-
if let Some(cfg) = mcp_config_sanitized {
58-
mcp.insert("config".to_string(), cfg);
59-
}
60-
out.insert("mcp".to_string(), Value::Object(mcp.into_iter().collect()));
61-
28+
let out = build_session_policy_json(
29+
session_id,
30+
&snapshot.allowed_tools,
31+
&snapshot.allowed_skills,
32+
&snapshot.skill_policy,
33+
snapshot.mcp_config_json.as_deref(),
34+
show_mcp_config,
35+
)?;
6236
println!("{}", serde_json::to_string_pretty(&out)?);
6337
return Ok(());
6438
}
6539

40+
let (mcp_servers, mcp_config_sanitized, mcp_parse_error) =
41+
summarize_mcp_config(snapshot.mcp_config_json.as_deref(), show_mcp_config);
42+
6643
println!("Session policy");
6744
println!();
6845
println!("session_id: {session_id}");
@@ -112,6 +89,51 @@ pub(super) async fn run(command: SessionCommand) -> anyhow::Result<()> {
11289
}
11390
}
11491

92+
fn build_session_policy_json(
93+
session_id: String,
94+
allowed_tools: &Option<Vec<String>>,
95+
allowed_skills: &Option<Vec<String>>,
96+
skill_policy: &rexos::agent::SessionSkillPolicy,
97+
mcp_config_json: Option<&str>,
98+
show_mcp_config: bool,
99+
) -> anyhow::Result<Value> {
100+
let (mcp_servers, mcp_config_sanitized, mcp_parse_error) =
101+
summarize_mcp_config(mcp_config_json, show_mcp_config);
102+
103+
let mut out = BTreeMap::new();
104+
out.insert("session_id".to_string(), Value::String(session_id));
105+
out.insert(
106+
"allowed_tools".to_string(),
107+
serde_json::to_value(allowed_tools)?,
108+
);
109+
out.insert(
110+
"allowed_skills".to_string(),
111+
serde_json::to_value(allowed_skills)?,
112+
);
113+
out.insert(
114+
"skill_policy".to_string(),
115+
serde_json::to_value(skill_policy)?,
116+
);
117+
118+
let mut mcp = BTreeMap::new();
119+
mcp.insert(
120+
"present".to_string(),
121+
Value::Bool(mcp_config_json.is_some()),
122+
);
123+
if let Some(servers) = mcp_servers {
124+
mcp.insert("servers".to_string(), serde_json::to_value(servers)?);
125+
}
126+
if let Some(err) = mcp_parse_error {
127+
mcp.insert("parse_error".to_string(), Value::String(err));
128+
}
129+
if let Some(cfg) = mcp_config_sanitized {
130+
mcp.insert("config".to_string(), cfg);
131+
}
132+
out.insert("mcp".to_string(), Value::Object(mcp.into_iter().collect()));
133+
134+
Ok(Value::Object(out.into_iter().collect()))
135+
}
136+
115137
fn summarize_mcp_config(
116138
raw_json: Option<&str>,
117139
include_sanitized: bool,
@@ -130,7 +152,11 @@ fn summarize_mcp_config(
130152
let servers = parsed
131153
.get("servers")
132154
.and_then(|v| v.as_object())
133-
.map(|obj| obj.keys().cloned().collect::<Vec<_>>());
155+
.map(|obj| {
156+
let mut servers = obj.keys().cloned().collect::<Vec<_>>();
157+
servers.sort();
158+
servers
159+
});
134160

135161
let sanitized = if include_sanitized {
136162
Some(sanitize_mcp_config(&parsed))
@@ -175,6 +201,109 @@ fn sanitize_mcp_config(value: &Value) -> Value {
175201
mod tests {
176202
use super::*;
177203

204+
#[test]
205+
fn build_session_policy_json_includes_expected_keys() {
206+
let mut skill_policy = rexos::agent::SessionSkillPolicy::default();
207+
skill_policy.allowlist = vec!["alpha".to_string()];
208+
skill_policy.require_approval = true;
209+
skill_policy.auto_approve_readonly = false;
210+
211+
let mcp_config_json =
212+
r#"{"servers":{"s1":{"command":"python","env":{"API_KEY":"secret"}}}}"#;
213+
let out = build_session_policy_json(
214+
"s-test".to_string(),
215+
&Some(vec!["fs_read".to_string(), "fs_write".to_string()]),
216+
&None,
217+
&skill_policy,
218+
Some(mcp_config_json),
219+
true,
220+
)
221+
.unwrap();
222+
223+
assert_eq!(out["session_id"].as_str(), Some("s-test"));
224+
assert!(out.get("allowed_tools").is_some());
225+
assert!(out.get("allowed_skills").is_some());
226+
assert!(out.get("skill_policy").is_some());
227+
assert!(out.get("mcp").is_some());
228+
229+
let tools: Vec<String> = out["allowed_tools"]
230+
.as_array()
231+
.unwrap()
232+
.iter()
233+
.filter_map(|v| v.as_str().map(|s| s.to_string()))
234+
.collect();
235+
assert!(tools.contains(&"fs_read".to_string()));
236+
assert!(tools.contains(&"fs_write".to_string()));
237+
238+
assert_eq!(
239+
out["skill_policy"]["require_approval"].as_bool(),
240+
Some(true)
241+
);
242+
assert_eq!(
243+
out["skill_policy"]["auto_approve_readonly"].as_bool(),
244+
Some(false)
245+
);
246+
assert_eq!(out["skill_policy"]["allowlist"][0].as_str(), Some("alpha"));
247+
248+
assert_eq!(out["mcp"]["present"].as_bool(), Some(true));
249+
let servers: Vec<String> = out["mcp"]["servers"]
250+
.as_array()
251+
.unwrap()
252+
.iter()
253+
.filter_map(|v| v.as_str().map(|s| s.to_string()))
254+
.collect();
255+
assert_eq!(servers, vec!["s1".to_string()]);
256+
assert_eq!(
257+
out["mcp"]["config"]["servers"]["s1"]["env"]["API_KEY"].as_str(),
258+
Some("[redacted]")
259+
);
260+
assert_eq!(
261+
out["mcp"]["config"]["servers"]["s1"]["command"].as_str(),
262+
Some("python")
263+
);
264+
}
265+
266+
#[test]
267+
fn build_session_policy_json_omits_sanitized_config_when_not_requested() {
268+
let out = build_session_policy_json(
269+
"s-test".to_string(),
270+
&None,
271+
&None,
272+
&rexos::agent::SessionSkillPolicy::default(),
273+
Some(r#"{"servers":{"s1":{"command":"python"}}}"#),
274+
false,
275+
)
276+
.unwrap();
277+
278+
let mcp = out["mcp"].as_object().unwrap();
279+
assert_eq!(mcp.get("present").and_then(|v| v.as_bool()), Some(true));
280+
assert!(mcp.contains_key("servers"));
281+
assert!(!mcp.contains_key("config"));
282+
}
283+
284+
#[test]
285+
fn build_session_policy_json_reports_invalid_mcp_json() {
286+
let out = build_session_policy_json(
287+
"s-test".to_string(),
288+
&None,
289+
&None,
290+
&rexos::agent::SessionSkillPolicy::default(),
291+
Some("not-json"),
292+
true,
293+
)
294+
.unwrap();
295+
296+
let mcp = out["mcp"].as_object().unwrap();
297+
assert_eq!(mcp.get("present").and_then(|v| v.as_bool()), Some(true));
298+
assert!(mcp.get("servers").is_none());
299+
assert!(mcp.get("config").is_none());
300+
assert!(mcp
301+
.get("parse_error")
302+
.and_then(|v| v.as_str())
303+
.unwrap_or("")
304+
.starts_with("invalid JSON:"),);
305+
}
306+
178307
#[test]
179308
fn sanitize_mcp_config_redacts_env_values() {
180309
let input = serde_json::json!({

0 commit comments

Comments
 (0)