Skip to main content

mcpmesh/
json.rs

1//! Every value the porcelain prints under `--json` — the machine face, as pure
2//! unit-tested builders. [`crate::render`] owns the human strings; this module owns
3//! the JSON mirror. Shapes serialize the mcpmesh-local/1 result types verbatim
4//! wherever one exists (additive discipline: an absent field means empty/none, same
5//! as the wire), plus small hand-built objects for verbs with no API result. One
6//! JSON value per invocation on stdout; a failure is one
7//! `{"error":{"code":…,"message":…}}` line on stderr.
8
9use mcpmesh_local_api::{Hello, InviteResult, PairResult, StatusResult};
10
11use crate::doctor::{Level, Verdict};
12use crate::{client, render};
13
14/// The `--json` error object: the SAME human message [`render::error_lines`]
15/// produces (joined, without the leading "Error: "), plus the control-API JSON-RPC
16/// `code` when the failure came from the daemon — the machine-branchable field the
17/// human path deliberately hides.
18pub 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
33/// `status --json`: the [`StatusResult`] verbatim, plus the [`Hello`] fields and the
34/// device fingerprint the human header carries. Hello's api/stack fields win over
35/// StatusResult's `stack_version` copy (they are the same value from a live daemon).
36pub 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
47/// `invite --json`: the [`InviteResult`] verbatim plus the requested services (what
48/// the operator asked to grant — the same provenance as the human line).
49pub 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
57/// `pair --json`: the [`PairResult`] verbatim plus the ready-to-use
58/// `<peer>/<service>` mount targets (the machine mirror of "You can now use: …").
59pub 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
72/// `pair --remove --json`.
73pub fn unpair_json(nickname: &str) -> serde_json::Value {
74    serde_json::json!({"removed": nickname})
75}
76
77/// `serve --json`.
78pub fn serve_json(name: &str) -> serde_json::Value {
79    serde_json::json!({"service": name, "serving": true})
80}
81
82/// `up --json`.
83pub fn up_json(socket: &std::path::Path) -> serde_json::Value {
84    serde_json::json!({"socket": socket.display().to_string()})
85}
86
87/// `use --json`: per service, the mount target, the exact Claude Code command, and
88/// the generic MCP stdio server entry (name/command/args) any client can consume —
89/// the machine mirror of [`crate::proxy::client_instruction_lines`].
90pub 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
109/// `doctor --json`: every finding, plus the warn/error tallies the human summary
110/// line carries and an overall `ok` (false iff any ERROR — mirrors the exit code).
111pub 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        // The wire's "{method} failed: " framing is stripped, same as the human path.
143        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        // Empty vecs with skip_serializing_if stay ABSENT (additive discipline —
182        // consumers read absent as empty, exactly like the wire).
183        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        // A clean report is ok:true.
258        let clean = doctor_json(&[("config", Verdict::ok("fine"))]);
259        assert_eq!(clean["ok"], true);
260        assert_eq!(clean["errors"], 0);
261    }
262}