use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hello {
pub api: String, pub api_version: String, pub stack_version: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackendKind {
Run,
Socket,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServiceInfo {
pub name: String,
pub allow: Vec<String>, pub backend: BackendKind, }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerInfo {
pub name: String,
pub services: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RosterStatus {
pub org_id: String,
pub serial: u64,
pub state: String, pub org_root_fingerprint: String, }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PresencePeer {
pub user_id: String,
pub device_label: String,
pub role: String, pub online: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecentPairing {
pub peer_petname: String,
pub sas_code: String,
pub paired_at_epoch: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusResult {
pub stack_version: String,
pub services: Vec<ServiceInfo>,
pub peers: Vec<PeerInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub roster: Option<RosterStatus>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub presence: Vec<PresencePeer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub self_user_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub recent_pairings: Vec<RecentPairing>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
pub enum Request {
RegisterService {
name: String,
backend: BackendSpec,
allow: Vec<String>,
},
Status,
Invite {
services: Vec<String>,
},
Pair {
invite_line: String,
},
PeerRemove {
petname: String,
},
PeerRename {
#[serde(default)]
user_id: Option<String>,
#[serde(default)]
petname: Option<String>,
to: String,
},
OpenSession {
peer: String,
service: String,
},
RosterInstall {
path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
org_root_pk: Option<String>,
},
OrgJoin {
org_id: String,
org_root_pk: String,
user_id: String,
user_key: String,
},
SetRosterUrl {
url: String,
},
BlobPublish {
scope: String,
path: String,
},
BlobGrant {
scope: String,
principal: String,
},
BlobList,
BlobFetch {
ticket: String,
dest_path: String,
},
AuditSummary,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OrgJoinResult {
pub org_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RosterInstallResult {
pub org_id: String,
pub serial: u64,
#[serde(default)]
pub severed: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlobPublishResult {
pub ticket: String,
pub hash: String, }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScopeInfo {
pub name: String,
pub hashes: Vec<String>,
pub grants: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlobScopeList {
pub scopes: Vec<ScopeInfo>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlobFetchResult {
pub hash: String,
pub bytes_len: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditSummaryResult {
pub per_peer: Vec<(String, u64)>,
pub per_service: Vec<(String, u64)>,
#[serde(default)]
pub total_sessions: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InviteResult {
pub invite_line: String,
pub expires_at_epoch: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairResult {
pub peer_petname: String,
pub sas_code: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub services: Vec<String>,
}
pub fn method_of(v: &serde_json::Value) -> Option<&str> {
v.get("method").and_then(serde_json::Value::as_str)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackendSpec {
Run { cmd: Vec<String> },
Socket { path: String },
}
pub const API_NAME: &str = "mcpmesh-local/1";
pub const API_VERSION: &str = "1.0";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hello_result_roundtrips() {
let h = Hello {
api: "mcpmesh-local/1".into(),
api_version: "1.0".into(),
stack_version: "0.1.0".into(),
};
let v = serde_json::to_value(&h).unwrap();
assert_eq!(v["api"], "mcpmesh-local/1");
let back: Hello = serde_json::from_value(v).unwrap();
assert_eq!(back, h);
}
#[test]
fn request_tagged_by_method() {
let r = Request::Status;
assert_eq!(serde_json::to_value(&r).unwrap()["method"], "status");
let r = Request::OpenSession {
peer: "alice".into(),
service: "notes".into(),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["method"], "open_session");
assert_eq!(v["params"]["peer"], "alice");
}
#[test]
fn parameterless_method_tolerates_params_forms() {
let omitted: Request =
serde_json::from_value(serde_json::json!({"method": "status"})).unwrap();
assert_eq!(omitted, Request::Status);
let null: Request =
serde_json::from_value(serde_json::json!({"method": "status", "params": null}))
.unwrap();
assert_eq!(null, Request::Status);
let empty = serde_json::json!({"method": "status", "params": {}});
assert!(serde_json::from_value::<Request>(empty.clone()).is_err());
match method_of(&empty) {
Some("status") => {} other => panic!("method_of failed to resolve status: {other:?}"),
}
}
#[test]
fn backend_spec_roundtrips() {
let run = BackendSpec::Run {
cmd: vec!["notes-mcp".into(), "--stdio".into()],
};
let v = serde_json::to_value(&run).unwrap();
assert_eq!(v["run"]["cmd"][0], "notes-mcp");
assert_eq!(serde_json::from_value::<BackendSpec>(v).unwrap(), run);
let sock = BackendSpec::Socket {
path: "/run/notes.sock".into(),
};
let v = serde_json::to_value(&sock).unwrap();
assert_eq!(v["socket"]["path"], "/run/notes.sock");
assert_eq!(serde_json::from_value::<BackendSpec>(v).unwrap(), sock);
}
#[test]
fn register_service_wire_shape() {
let r = Request::RegisterService {
name: "notes".into(),
backend: BackendSpec::Run {
cmd: vec!["notes-mcp".into()],
},
allow: vec!["alice".into()],
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(
v,
serde_json::json!({
"method": "register_service",
"params": {
"name": "notes",
"backend": {"run": {"cmd": ["notes-mcp"]}},
"allow": ["alice"],
}
})
);
assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
}
#[test]
fn invite_request_and_result_roundtrip() {
let r = Request::Invite {
services: vec!["notes".into(), "kb".into()],
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["method"], "invite");
assert_eq!(v["params"]["services"][0], "notes");
assert_eq!(v["params"]["services"][1], "kb");
assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
assert_eq!(
method_of(&serde_json::json!({"method": "invite", "params": {"services": []}})),
Some("invite")
);
let res = InviteResult {
invite_line: "mcpmesh-invite:ABCDEF".into(),
expires_at_epoch: 1_800_000_000,
};
let v = serde_json::to_value(&res).unwrap();
assert_eq!(v["invite_line"], "mcpmesh-invite:ABCDEF");
assert_eq!(v["expires_at_epoch"], 1_800_000_000u64);
assert_eq!(serde_json::from_value::<InviteResult>(v).unwrap(), res);
}
#[test]
fn pair_request_and_result_roundtrip() {
let r = Request::Pair {
invite_line: "mcpmesh-invite:ABCDEF".into(),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["method"], "pair");
assert_eq!(v["params"]["invite_line"], "mcpmesh-invite:ABCDEF");
assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
assert_eq!(
method_of(&serde_json::json!({"method": "pair", "params": {"invite_line": "x"}})),
Some("pair")
);
let res = PairResult {
peer_petname: "alice".into(),
sas_code: "tango-fig-cabbage".into(),
services: vec!["notes".into(), "kb".into()],
};
let v = serde_json::to_value(&res).unwrap();
assert_eq!(v["peer_petname"], "alice");
assert_eq!(v["sas_code"], "tango-fig-cabbage");
assert_eq!(v["services"][0], "notes");
assert_eq!(v["services"][1], "kb");
assert_eq!(serde_json::from_value::<PairResult>(v).unwrap(), res);
let old_shape = serde_json::json!({
"peer_petname": "alice",
"sas_code": "tango-fig-cabbage",
});
let back: PairResult = serde_json::from_value(old_shape).unwrap();
assert_eq!(back.peer_petname, "alice");
assert!(back.services.is_empty());
}
#[test]
fn roster_install_request_and_result_roundtrip() {
let r = Request::RosterInstall {
path: "/tmp/roster.json".into(),
org_root_pk: Some("b64u:AAAA".into()),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["method"], "roster_install");
assert_eq!(v["params"]["path"], "/tmp/roster.json");
assert_eq!(v["params"]["org_root_pk"], "b64u:AAAA");
assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
assert_eq!(
method_of(&serde_json::json!({"method": "roster_install", "params": {"path": "/x"}})),
Some("roster_install")
);
let omit = Request::RosterInstall {
path: "/tmp/roster.json".into(),
org_root_pk: None,
};
let v = serde_json::to_value(&omit).unwrap();
assert!(
v["params"].get("org_root_pk").is_none(),
"an omitted org_root_pk must not appear on the wire: {v}"
);
assert_eq!(serde_json::from_value::<Request>(v).unwrap(), omit);
let res = RosterInstallResult {
org_id: "acme".into(),
serial: 42,
severed: 1,
};
let v = serde_json::to_value(&res).unwrap();
assert_eq!(v["org_id"], "acme");
assert_eq!(v["serial"], 42u64);
assert_eq!(v["severed"], 1u32);
assert_eq!(
serde_json::from_value::<RosterInstallResult>(v).unwrap(),
res
);
let old_shape = serde_json::json!({ "org_id": "acme", "serial": 7 });
let back: RosterInstallResult = serde_json::from_value(old_shape).unwrap();
assert_eq!(back.serial, 7);
assert_eq!(back.severed, 0);
}
#[test]
fn org_join_request_and_result_roundtrip() {
let r = Request::OrgJoin {
org_id: "acme".into(),
org_root_pk: "b64u:AAAA".into(),
user_id: "alice".into(),
user_key: "/home/alice/.config/mcpmesh/user.key".into(),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["method"], "org_join");
assert_eq!(v["params"]["org_id"], "acme");
assert_eq!(v["params"]["org_root_pk"], "b64u:AAAA");
assert_eq!(v["params"]["user_id"], "alice");
assert_eq!(
v["params"]["user_key"],
"/home/alice/.config/mcpmesh/user.key"
);
assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
assert_eq!(
method_of(&serde_json::json!({"method": "org_join", "params": {"org_id": "x"}})),
Some("org_join")
);
let res = OrgJoinResult {
org_id: "acme".into(),
};
let v = serde_json::to_value(&res).unwrap();
assert_eq!(v["org_id"], "acme");
assert_eq!(serde_json::from_value::<OrgJoinResult>(v).unwrap(), res);
}
#[test]
fn set_roster_url_request_roundtrip() {
let r = Request::SetRosterUrl {
url: "https://intranet.acme.com/roster.json".into(),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["method"], "set_roster_url");
assert_eq!(v["params"]["url"], "https://intranet.acme.com/roster.json");
assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
assert_eq!(
method_of(&serde_json::json!({"method": "set_roster_url", "params": {"url": "x"}})),
Some("set_roster_url")
);
}
#[test]
fn peer_remove_request_roundtrip() {
let r = Request::PeerRemove {
petname: "bob".into(),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["method"], "peer_remove");
assert_eq!(v["params"]["petname"], "bob");
assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
assert_eq!(
method_of(&serde_json::json!({"method": "peer_remove", "params": {"petname": "bob"}})),
Some("peer_remove")
);
}
#[test]
fn peer_rename_request_roundtrip() {
let r = Request::PeerRename {
user_id: Some("b64u:BOB".into()),
petname: None,
to: "Bobby".into(),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["method"], "peer_rename");
assert_eq!(v["params"]["user_id"], "b64u:BOB");
assert_eq!(v["params"]["to"], "Bobby");
assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
assert_eq!(
method_of(
&serde_json::json!({"method": "peer_rename", "params": {"petname": "carol", "to": "Carol"}})
),
Some("peer_rename")
);
}
#[test]
fn status_result_roundtrips() {
let s = StatusResult {
stack_version: "0.1.0".into(),
services: vec![ServiceInfo {
name: "notes".into(),
allow: vec!["alice".into()],
backend: BackendKind::Run,
}],
peers: vec![PeerInfo {
name: "alice".into(),
services: vec!["notes".into()],
user_id: Some("b64u:alicepk".into()),
}],
roster: None,
presence: vec![],
self_user_id: Some("b64u:selfpk".into()),
recent_pairings: vec![],
};
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["services"][0]["backend"], "run");
assert_eq!(v["peers"][0]["user_id"], "b64u:alicepk");
assert_eq!(v["self_user_id"], "b64u:selfpk");
assert!(
v.get("roster").is_none(),
"an absent roster must not appear on the wire: {v}"
);
assert!(
v.get("presence").is_none(),
"an empty presence must not appear on the wire: {v}"
);
assert!(
v.get("recent_pairings").is_none(),
"an empty recent_pairings must not appear on the wire: {v}"
);
assert_eq!(serde_json::from_value::<StatusResult>(v).unwrap(), s);
let old_shape = serde_json::json!({
"stack_version": "0.1.0",
"services": [],
"peers": [{ "name": "bob", "services": [] }],
});
let back: StatusResult = serde_json::from_value(old_shape).unwrap();
assert!(back.roster.is_none());
assert!(back.presence.is_empty());
assert!(back.self_user_id.is_none());
assert!(back.peers[0].user_id.is_none());
assert!(back.recent_pairings.is_empty());
let s = StatusResult {
stack_version: "0.1.0".into(),
services: vec![],
peers: vec![],
roster: Some(RosterStatus {
org_id: "acme".into(),
serial: 42,
state: "approved".into(),
org_root_fingerprint: "tango-fig-cabbage-anchor".into(),
}),
presence: vec![
PresencePeer {
user_id: "alice".into(),
device_label: "laptop".into(),
role: "primary".into(),
online: true,
},
PresencePeer {
user_id: "alice".into(),
device_label: "desktop".into(),
role: "mirror".into(),
online: false,
},
],
self_user_id: None,
recent_pairings: vec![],
};
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["roster"]["org_id"], "acme");
assert_eq!(v["roster"]["serial"], 42u64);
assert_eq!(v["roster"]["state"], "approved");
assert_eq!(
v["roster"]["org_root_fingerprint"],
"tango-fig-cabbage-anchor"
);
assert_eq!(v["presence"][0]["user_id"], "alice");
assert_eq!(v["presence"][0]["device_label"], "laptop");
assert_eq!(v["presence"][0]["role"], "primary");
assert_eq!(v["presence"][0]["online"], true);
assert_eq!(v["presence"][1]["online"], false);
assert_eq!(serde_json::from_value::<StatusResult>(v).unwrap(), s);
}
#[test]
fn recent_pairings_are_additive_on_status() {
let s = StatusResult {
stack_version: "0.1.0".into(),
services: vec![],
peers: vec![],
roster: None,
presence: vec![],
self_user_id: None,
recent_pairings: vec![RecentPairing {
peer_petname: "bob".into(),
sas_code: "tango-fig-cabbage".into(),
paired_at_epoch: 1_800_000_000,
}],
};
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["recent_pairings"][0]["peer_petname"], "bob");
assert_eq!(v["recent_pairings"][0]["sas_code"], "tango-fig-cabbage");
assert_eq!(v["recent_pairings"][0]["paired_at_epoch"], 1_800_000_000u64);
assert_eq!(serde_json::from_value::<StatusResult>(v).unwrap(), s);
let old_shape = serde_json::json!({
"stack_version": "0.1.0",
"services": [],
"peers": [],
});
let back: StatusResult = serde_json::from_value(old_shape).unwrap();
assert!(back.recent_pairings.is_empty());
}
#[test]
fn blob_requests_and_results_roundtrip() {
let r = Request::BlobPublish {
scope: "docs".into(),
path: "/tmp/a.bin".into(),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["method"], "blob_publish");
assert_eq!(v["params"]["scope"], "docs");
assert_eq!(v["params"]["path"], "/tmp/a.bin");
assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
let r = Request::BlobGrant {
scope: "docs".into(),
principal: "alice".into(),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["method"], "blob_grant");
assert_eq!(v["params"]["principal"], "alice");
assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
assert_eq!(
method_of(&serde_json::json!({"method": "blob_list"})),
Some("blob_list")
);
let r = Request::BlobFetch {
ticket: "blobAAA".into(),
dest_path: "/tmp/out.bin".into(),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["method"], "blob_fetch");
assert_eq!(v["params"]["ticket"], "blobAAA");
assert_eq!(v["params"]["dest_path"], "/tmp/out.bin");
assert_eq!(serde_json::from_value::<Request>(v).unwrap(), r);
let res = BlobPublishResult {
ticket: "blobAAA".into(),
hash: "ab".repeat(32),
};
let v = serde_json::to_value(&res).unwrap();
assert_eq!(v["ticket"], "blobAAA");
assert_eq!(serde_json::from_value::<BlobPublishResult>(v).unwrap(), res);
let res = BlobScopeList {
scopes: vec![ScopeInfo {
name: "docs".into(),
hashes: vec!["ab".repeat(32)],
grants: vec!["alice".into()],
}],
};
let v = serde_json::to_value(&res).unwrap();
assert_eq!(v["scopes"][0]["name"], "docs");
assert_eq!(v["scopes"][0]["grants"][0], "alice");
assert_eq!(serde_json::from_value::<BlobScopeList>(v).unwrap(), res);
let res = BlobFetchResult {
hash: "ab".repeat(32),
bytes_len: 4194304,
};
let v = serde_json::to_value(&res).unwrap();
assert_eq!(v["bytes_len"], 4194304u64);
assert_eq!(serde_json::from_value::<BlobFetchResult>(v).unwrap(), res);
}
#[test]
fn audit_summary_request_and_result_roundtrip() {
let r = Request::AuditSummary;
assert_eq!(serde_json::to_value(&r).unwrap()["method"], "audit_summary");
assert_eq!(
method_of(&serde_json::json!({"method": "audit_summary"})),
Some("audit_summary")
);
let res = AuditSummaryResult {
per_peer: vec![("alice".into(), 1), ("bob".into(), 2)],
per_service: vec![("kb".into(), 1), ("notes".into(), 3)],
total_sessions: 4,
};
let v = serde_json::to_value(&res).unwrap();
assert_eq!(v["per_peer"][1][0], "bob");
assert_eq!(v["per_peer"][1][1], 2u64);
assert_eq!(v["per_service"][1][0], "notes");
assert_eq!(v["total_sessions"], 4u64);
assert_eq!(
serde_json::from_value::<AuditSummaryResult>(v).unwrap(),
res
);
let old_shape = serde_json::json!({ "per_peer": [], "per_service": [] });
let back: AuditSummaryResult = serde_json::from_value(old_shape).unwrap();
assert_eq!(back.total_sessions, 0);
assert!(back.per_peer.is_empty());
}
}