1use mcpmesh_local_api::{Hello, InviteResult, PairResult, StatusResult};
10
11use crate::doctor::{Level, Verdict};
12use crate::{client, render};
13
14pub fn error_json(err: &anyhow::Error) -> serde_json::Value {
19 let code = err
20 .chain()
21 .find_map(|cause| match cause.downcast_ref::<client::ClientError>() {
22 Some(client::ClientError::Api(v)) => v.get("code").and_then(|c| c.as_i64()),
23 _ => None,
24 });
25 let joined = render::error_lines(err).join("\n");
26 let message = joined
27 .strip_prefix("Error: ")
28 .unwrap_or(&joined)
29 .to_string();
30 serde_json::json!({"error": {"code": code, "message": message}})
31}
32
33pub fn status_json(fingerprint: &str, hello: &Hello, status: &StatusResult) -> serde_json::Value {
37 let mut v = serde_json::to_value(status).expect("StatusResult serializes");
38 let o = v.as_object_mut().expect("StatusResult is an object");
39 o.insert("api".into(), hello.api.clone().into());
40 o.insert("api_version".into(), hello.api_version.clone().into());
41 o.insert("api_minor".into(), hello.api_minor.into());
42 o.insert("stack_version".into(), hello.stack_version.clone().into());
43 o.insert("device_fingerprint".into(), fingerprint.into());
44 v
45}
46
47pub fn invite_json(invite: &InviteResult, services: &[String]) -> serde_json::Value {
50 let mut v = serde_json::to_value(invite).expect("InviteResult serializes");
51 v.as_object_mut()
52 .expect("InviteResult is an object")
53 .insert("services".into(), serde_json::json!(services));
54 v
55}
56
57pub fn pair_json(result: &PairResult) -> serde_json::Value {
60 let mounts: Vec<String> = result
61 .services
62 .iter()
63 .map(|s| format!("{}/{s}", result.peer_nickname))
64 .collect();
65 let mut v = serde_json::to_value(result).expect("PairResult serializes");
66 v.as_object_mut()
67 .expect("PairResult is an object")
68 .insert("mounts".into(), serde_json::json!(mounts));
69 v
70}
71
72pub fn unpair_json(nickname: &str) -> serde_json::Value {
74 serde_json::json!({"removed": nickname})
75}
76
77pub fn serve_json(name: &str) -> serde_json::Value {
79 serde_json::json!({"service": name, "serving": true})
80}
81
82pub fn up_json(socket: &std::path::Path) -> serde_json::Value {
84 serde_json::json!({"socket": socket.display().to_string()})
85}
86
87pub fn use_json(peer: &str, services: &[String]) -> serde_json::Value {
91 let mounts: Vec<serde_json::Value> = services
92 .iter()
93 .map(|s| {
94 serde_json::json!({
95 "target": format!("{peer}/{s}"),
96 "claude_code_command":
97 format!("claude mcp add {peer}-{s} -- mcpmesh connect {peer}/{s}"),
98 "mcp_server": {
99 "name": format!("{peer}-{s}"),
100 "command": "mcpmesh",
101 "args": ["connect", format!("{peer}/{s}")],
102 },
103 })
104 })
105 .collect();
106 serde_json::json!({"peer": peer, "mounts": mounts})
107}
108
109pub fn doctor_json(findings: &[(&str, Verdict)]) -> serde_json::Value {
112 let list: Vec<serde_json::Value> = findings
113 .iter()
114 .map(|(check, v)| {
115 serde_json::json!({"check": check, "level": v.level.as_str(), "message": v.message})
116 })
117 .collect();
118 let count = |l: Level| findings.iter().filter(|(_, v)| v.level == l).count();
119 serde_json::json!({
120 "findings": list,
121 "warnings": count(Level::Warn),
122 "errors": count(Level::Error),
123 "ok": count(Level::Error) == 0,
124 })
125}
126
127#[cfg(test)]
128mod tests {
129 use serde_json::json;
130
131 use super::*;
132
133 fn api_error(value: serde_json::Value) -> anyhow::Error {
134 anyhow::Error::from(client::ClientError::Api(value))
135 }
136
137 #[test]
138 fn error_json_carries_the_control_api_code_and_clean_message() {
139 let err = api_error(json!({"code": -32055, "message": "invite failed: invite expired"}));
140 let v = error_json(&err);
141 assert_eq!(v["error"]["code"], json!(-32055));
142 assert_eq!(v["error"]["message"], json!("invite expired"));
144 }
145
146 #[test]
147 fn error_json_on_a_plain_error_has_null_code_and_the_chain() {
148 let err = anyhow::Error::from(std::io::Error::other("disk full")).context("write roster");
149 let v = error_json(&err);
150 assert_eq!(v["error"]["code"], serde_json::Value::Null);
151 let msg = v["error"]["message"].as_str().unwrap();
152 assert!(
153 msg.contains("write roster") && msg.contains("disk full"),
154 "{msg}"
155 );
156 }
157
158 #[test]
159 fn status_json_merges_hello_and_fingerprint_over_the_status_result() {
160 let hello = Hello {
161 api: "mcpmesh-local/1".into(),
162 api_version: "1.1".into(),
163 api_minor: 1,
164 stack_version: "0.6.1".into(),
165 };
166 let status = StatusResult {
167 stack_version: "0.6.1".into(),
168 services: vec![],
169 peers: vec![],
170 roster: None,
171 presence: vec![],
172 self_user_id: None,
173 recent_pairings: vec![],
174 reachability: vec![],
175 self_nickname: String::new(),
176 };
177 let v = status_json("fp-words", &hello, &status);
178 assert_eq!(v["api"], "mcpmesh-local/1");
179 assert_eq!(v["api_minor"], 1);
180 assert_eq!(v["device_fingerprint"], "fp-words");
181 assert!(v.get("recent_pairings").is_none());
184 assert!(v.get("roster").is_none());
185 }
186
187 #[test]
188 fn invite_json_carries_the_line_expiry_and_requested_services() {
189 let invite = InviteResult {
190 invite_line: "mcpmesh-invite:MFRGGZDF".into(),
191 expires_at_epoch: 1_086_400,
192 };
193 let v = invite_json(&invite, &["notes".to_string(), "kb".to_string()]);
194 assert_eq!(v["invite_line"], "mcpmesh-invite:MFRGGZDF");
195 assert_eq!(v["expires_at_epoch"], 1_086_400);
196 assert_eq!(v["services"], json!(["notes", "kb"]));
197 }
198
199 #[test]
200 fn pair_json_serializes_the_result_and_mount_targets() {
201 let result = PairResult {
202 peer_nickname: "alice".into(),
203 sas_code: "tango-fig-42".into(),
204 services: vec!["notes".into(), "kb".into()],
205 app_label: None,
206 peer_user_id: None,
207 };
208 let v = pair_json(&result);
209 assert_eq!(v["peer_nickname"], "alice");
210 assert_eq!(v["sas_code"], "tango-fig-42");
211 assert_eq!(v["mounts"], json!(["alice/notes", "alice/kb"]));
212 }
213
214 #[test]
215 fn use_json_emits_the_exact_client_commands() {
216 let v = use_json("alice", &["notes".to_string()]);
217 assert_eq!(v["peer"], "alice");
218 let m = &v["mounts"][0];
219 assert_eq!(m["target"], "alice/notes");
220 assert_eq!(
221 m["claude_code_command"],
222 "claude mcp add alice-notes -- mcpmesh connect alice/notes"
223 );
224 assert_eq!(m["mcp_server"]["command"], "mcpmesh");
225 assert_eq!(m["mcp_server"]["args"], json!(["connect", "alice/notes"]));
226 }
227
228 #[test]
229 fn small_ack_objects_have_their_documented_shapes() {
230 assert_eq!(unpair_json("bob"), json!({"removed": "bob"}));
231 assert_eq!(
232 serve_json("notes"),
233 json!({"service": "notes", "serving": true})
234 );
235 assert_eq!(
236 up_json(std::path::Path::new("/run/mcpmesh/mcpmesh.sock")),
237 json!({"socket": "/run/mcpmesh/mcpmesh.sock"})
238 );
239 }
240
241 #[test]
242 fn doctor_json_tallies_and_flags_errors() {
243 let findings = vec![
244 ("config", Verdict::ok("config parses")),
245 (
246 "device.key",
247 Verdict::error("group/world-writable (mode 0666)"),
248 ),
249 ("daemon", Verdict::warn("daemon not running")),
250 ];
251 let v = doctor_json(&findings);
252 assert_eq!(v["findings"][1]["check"], "device.key");
253 assert_eq!(v["findings"][1]["level"], "error");
254 assert_eq!(v["warnings"], 1);
255 assert_eq!(v["errors"], 1);
256 assert_eq!(v["ok"], false);
257 let clean = doctor_json(&[("config", Verdict::ok("fine"))]);
259 assert_eq!(clean["ok"], true);
260 assert_eq!(clean["errors"], 0);
261 }
262}