Skip to main content

wire/
mcp.rs

1//! MCP (Model Context Protocol) server over stdio.
2//!
3//! Spec: https://modelcontextprotocol.io/specification/2025-06-18
4//!
5//! Wire protocol: JSON-RPC 2.0, one message per line on stdin and stdout.
6//! stderr is reserved for logs (clients display them as server-side diagnostics).
7//!
8//! Tools exposed:
9//!
10//! **Identity / messaging (always agent-safe)**
11//!   - `wire_whoami`         — read self DID + fingerprint + capabilities
12//!   - `wire_peers`          — list pinned peers + tiers
13//!   - `wire_send`           — sign + queue an event to a peer
14//!   - `wire_tail`           — read recent signed events from inbox
15//!   - `wire_verify`         — verify a signed event JSON
16//!
17//! **Pairing (agent drives; operator gates via bilateral accept)**
18//!   - `wire_init`           — idempotent identity creation; same handle = no-op,
19//!     different handle = error (cannot re-key silently)
20//!   - `wire_dial`           — initiate a pair by handle (`<handle>@<relay>`);
21//!     the canonical pairing path
22//!   - `wire_pending` / `wire_accept` / `wire_reject` — inbound bilateral gate
23//!   - `wire_invite_mint` / `wire_invite_accept` — single-paste invite-URL pair
24//!
25//! The SAS / code-phrase / SPAKE2 ceremony (`wire_pair_initiate` / `_join` /
26//! `_confirm` and their detached variants) was removed in the RFC-005
27//! follow-on — `wire_dial` is the sole canonical pairing path.
28
29use anyhow::Result;
30use serde_json::{Value, json};
31use std::collections::HashSet;
32use std::io::{BufRead, BufReader, Write};
33use std::sync::{Arc, Mutex};
34
35/// Shared MCP-session state. Today: subscribed resource URIs + a writer
36/// channel for unsolicited notifications (push). Future per-session cursors,
37/// etc. go here.
38#[derive(Clone, Default)]
39pub struct McpState {
40    /// Resource URIs the client has subscribed to. Wildcard support is
41    /// intentionally NOT done — clients subscribe to specific URIs and
42    /// receive `notifications/resources/updated` only for those URIs.
43    pub subscribed: Arc<Mutex<HashSet<String>>>,
44    /// Writer-channel sender for emitting unsolicited notifications
45    /// (notifications/resources/list_changed, etc.). Populated by `run()`
46    /// before tools are dispatched; None in unit tests.
47    pub notif_tx: Arc<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
48}
49
50const PROTOCOL_VERSION: &str = "2025-06-18";
51const SERVER_NAME: &str = "wire";
52const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
53
54/// Run the MCP server until stdin closes.
55///
56/// Threading model (Goal 2.1):
57///
58/// - **Main thread**: reads stdin line-by-line, parses JSON-RPC, calls
59///   `handle_request` to compute a response, hands it to the writer via the
60///   mpsc channel.
61/// - **Writer thread**: single owner of stdout. Drains responses + push
62///   notifications from the channel, writes each as one line + flush. Single
63///   writer = no interleaving between responses and notifications.
64/// - **Watcher thread**: holds an `InboxWatcher::from_head` (starts at EOF —
65///   each MCP session only sees fresh events). Polls every 2s. For each new
66///   inbox event, checks the shared subscription set; if any matching
67///   `wire://inbox/<peer>` or `wire://inbox/all` URI is subscribed, pushes
68///   a `notifications/resources/updated` message into the channel.
69///
70/// v0.6.7: `detect_session_wire_home` moved to
71/// `session::detect_session_wire_home` (shared with the CLI auto-detect at
72/// `cli::run` entry). The mcp-only wrapper was removed; the regression test
73/// now calls the session-module version directly.
74pub fn run() -> Result<()> {
75    use std::sync::atomic::{AtomicBool, Ordering};
76    use std::sync::mpsc;
77    use std::time::{Duration, Instant};
78
79    // v0.6.1: auto-detect WIRE_HOME from cwd. If the operator already
80    // set it (explicit override via `.mcp.json env.WIRE_HOME`), respect
81    // that. Else: if the cwd maps to a `wire session` entry in the
82    // registry, adopt that session's WIRE_HOME for this MCP process so
83    // every subsequent tool call routes to the right inbox / outbox /
84    // identity.
85    //
86    // v0.6.7: identical helper now also runs at CLI entry (cli::run),
87    // so `wire whoami` / `wire monitor` from a session cwd resolve to
88    // the same identity the MCP server uses. Before v0.6.7 the CLI
89    // silently fell back to the default WIRE_HOME, leaving operators
90    // unable to tell which identity their monitor was tailing.
91    crate::session::maybe_adopt_session_wire_home("mcp");
92
93    // v0.7.0-alpha.2: if auto-detect found no session for this cwd
94    // (including via parent-walk), create one inline so every Claude
95    // tab in a fresh project gets its own wire identity rather than
96    // silently sharing the machine-wide default. Opt out via
97    // `WIRE_AUTO_INIT=0`.
98    crate::cli::maybe_auto_init_cwd_session("mcp");
99
100    // v0.13: a session-keyed WIRE_HOME (sessions/by-key/<hash>) starts empty.
101    // Bootstrap its identity on first MCP start — one-name init + federation
102    // slot + phonebook claim — so each Claude session is its own reachable,
103    // claimed identity. One-time per home (gated on is_initialized);
104    // best-effort (offline → init-only, no claim). Skipped under
105    // WIRE_MCP_SKIP_AUTO_UP (tests + manual-identity operators).
106    ensure_session_bootstrapped();
107
108    // v0.6.10: surface multi-agent identity collisions explicitly.
109    // Two Claudes (or any MCP-host pair) launched in the same cwd
110    // auto-detect into the same wire session and silently share an
111    // inbox cursor. v0.6.7 made this invisible by design ("just adopt
112    // the cwd's session"); operators hit it as "they look identical"
113    // and burn hours debugging. The warning gives them a clear
114    // remediation path the first time they see it.
115    crate::session::warn_on_identity_collision(std::process::id(), "mcp");
116
117    let state = McpState::default();
118    let shutdown = Arc::new(AtomicBool::new(false));
119
120    let (tx, rx) = mpsc::channel::<String>();
121
122    // Expose the tx clone via state so tool handlers can push unsolicited
123    // notifications (notifications/resources/list_changed after a pair pin).
124    if let Ok(mut g) = state.notif_tx.lock() {
125        *g = Some(tx.clone());
126    }
127
128    // Writer thread — single owner of stdout. Exits when all senders drop.
129    let writer_handle = std::thread::spawn(move || {
130        let stdout = std::io::stdout();
131        let mut w = stdout.lock();
132        while let Ok(line) = rx.recv() {
133            if writeln!(w, "{line}").is_err() {
134                break;
135            }
136            if w.flush().is_err() {
137                break;
138            }
139        }
140    });
141
142    // Watcher thread — polls inbox every 2s and emits
143    // notifications/resources/updated on grow. Observes `shutdown` so we
144    // can exit cleanly on stdin EOF (otherwise its tx_w clone keeps the
145    // writer thread blocked on rx.recv forever).
146    let subs_w = state.subscribed.clone();
147    let tx_w = tx.clone();
148    let shutdown_w = shutdown.clone();
149    let watcher_handle = std::thread::spawn(move || {
150        let mut watcher = match crate::inbox_watch::InboxWatcher::from_head() {
151            Ok(w) => w,
152            Err(_) => return,
153        };
154        let poll_interval = Duration::from_secs(2);
155        let mut next_poll = Instant::now() + poll_interval;
156        loop {
157            if shutdown_w.load(Ordering::SeqCst) {
158                return;
159            }
160            std::thread::sleep(Duration::from_millis(100));
161            if Instant::now() < next_poll {
162                continue;
163            }
164            next_poll = Instant::now() + poll_interval;
165            let subs_snapshot = match subs_w.lock() {
166                Ok(g) => g.clone(),
167                Err(_) => return,
168            };
169
170            let mut affected: HashSet<String> = HashSet::new();
171
172            // ---- inbox events ----
173            if !subs_snapshot.is_empty()
174                && let Ok(events) = watcher.poll()
175            {
176                for ev in &events {
177                    if subs_snapshot.contains("wire://inbox/all") {
178                        affected.insert("wire://inbox/all".to_string());
179                    }
180                    let peer_uri = format!("wire://inbox/{}", ev.peer);
181                    if subs_snapshot.contains(&peer_uri) {
182                        affected.insert(peer_uri);
183                    }
184                }
185            }
186
187            for uri in affected {
188                let notif = json!({
189                    "jsonrpc": "2.0",
190                    "method": "notifications/resources/updated",
191                    "params": {"uri": uri}
192                });
193                if tx_w.send(notif.to_string()).is_err() {
194                    return;
195                }
196            }
197        }
198    });
199
200    let stdin = std::io::stdin();
201    let mut reader = BufReader::new(stdin.lock());
202    let mut line = String::new();
203    loop {
204        line.clear();
205        let n = reader.read_line(&mut line)?;
206        if n == 0 {
207            // EOF — signal watcher to exit; clear the notif_tx Sender clone
208            // that state holds (otherwise writer's rx.recv() never sees
209            // all-senders-dropped); drop main tx; wait for worker threads.
210            shutdown.store(true, Ordering::SeqCst);
211            if let Ok(mut g) = state.notif_tx.lock() {
212                *g = None;
213            }
214            drop(tx);
215            let _ = watcher_handle.join();
216            let _ = writer_handle.join();
217            return Ok(());
218        }
219        let trimmed = line.trim();
220        if trimmed.is_empty() {
221            continue;
222        }
223        let request: Value = match serde_json::from_str(trimmed) {
224            Ok(v) => v,
225            Err(e) => {
226                let err = error_response(&Value::Null, -32700, &format!("parse error: {e}"));
227                let _ = tx.send(err.to_string());
228                continue;
229            }
230        };
231        let response = handle_request(&request, &state);
232        // Notifications (no `id`) get no response.
233        if response.get("id").is_some() || response.get("error").is_some() {
234            let _ = tx.send(response.to_string());
235        }
236    }
237}
238
239fn handle_request(req: &Value, state: &McpState) -> Value {
240    let id = req.get("id").cloned().unwrap_or(Value::Null);
241    let method = match req.get("method").and_then(Value::as_str) {
242        Some(m) => m,
243        None => return error_response(&id, -32600, "missing method"),
244    };
245    match method {
246        "initialize" => handle_initialize(&id),
247        "notifications/initialized" => Value::Null, // notification — no reply
248        "tools/list" => handle_tools_list(&id),
249        "tools/call" => handle_tools_call(&id, req.get("params").unwrap_or(&Value::Null), state),
250        "resources/list" => handle_resources_list(&id),
251        "resources/read" => handle_resources_read(&id, req.get("params").unwrap_or(&Value::Null)),
252        "resources/subscribe" => {
253            handle_resources_subscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
254        }
255        "resources/unsubscribe" => {
256            handle_resources_unsubscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
257        }
258        "ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}),
259        other => error_response(&id, -32601, &format!("method not found: {other}")),
260    }
261}
262
263// ---------- resources (Goal 2) ----------
264//
265// MCP resources expose semi-static state for agents that want a "read this
266// when relevant" surface instead of polling tools. v0.2 ships read-only;
267// subscribe (push-notify on inbox grow) is v0.2.1 — requires a background
268// watcher thread + async stdout writer.
269//
270// Resource URI scheme:
271//   wire://inbox/<peer>    last 50 verified events for that pinned peer
272//   wire://inbox/all       last 50 events across all peers, newest first
273
274fn handle_resources_list(id: &Value) -> Value {
275    let mut resources = vec![json!({
276        "uri": "wire://inbox/all",
277        "name": "wire inbox (all peers)",
278        "description": "Most recent verified events from all pinned peers, JSONL.",
279        "mimeType": "application/x-ndjson"
280    })];
281
282    if let Ok(trust) = crate::config::read_trust() {
283        let agents = trust
284            .get("agents")
285            .and_then(Value::as_object)
286            .cloned()
287            .unwrap_or_default();
288        let self_did = crate::config::read_agent_card()
289            .ok()
290            .and_then(|c| c.get("did").and_then(Value::as_str).map(str::to_string));
291        for (handle, agent) in agents.iter() {
292            let did = agent
293                .get("did")
294                .and_then(Value::as_str)
295                .unwrap_or("")
296                .to_string();
297            if Some(did.as_str()) == self_did.as_deref() {
298                continue;
299            }
300            resources.push(json!({
301                "uri": format!("wire://inbox/{handle}"),
302                "name": format!("inbox from {handle}"),
303                "description": format!("Recent verified events from did:wire:{handle}."),
304                "mimeType": "application/x-ndjson"
305            }));
306        }
307    }
308
309    json!({
310        "jsonrpc": "2.0",
311        "id": id,
312        "result": {
313            "resources": resources
314        }
315    })
316}
317
318fn handle_resources_subscribe(id: &Value, params: &Value, state: &McpState) -> Value {
319    let uri = match params.get("uri").and_then(Value::as_str) {
320        Some(u) => u.to_string(),
321        None => return error_response(id, -32602, "missing 'uri'"),
322    };
323    // Validate the URI shape. Accept wire://inbox/<peer>, wire://inbox/all.
324    // Anything else is rejected so we don't pile up dead subscriptions.
325    let inbox_peer = parse_inbox_uri(&uri);
326    if let Some(ref p) = inbox_peer
327        && p.starts_with("__invalid__")
328    {
329        return error_response(
330            id,
331            -32602,
332            "subscribe URI must be wire://inbox/<peer> or wire://inbox/all",
333        );
334    }
335    if let Ok(mut g) = state.subscribed.lock() {
336        g.insert(uri);
337    }
338    json!({"jsonrpc": "2.0", "id": id, "result": {}})
339}
340
341fn handle_resources_unsubscribe(id: &Value, params: &Value, state: &McpState) -> Value {
342    let uri = match params.get("uri").and_then(Value::as_str) {
343        Some(u) => u.to_string(),
344        None => return error_response(id, -32602, "missing 'uri'"),
345    };
346    if let Ok(mut g) = state.subscribed.lock() {
347        g.remove(&uri);
348    }
349    json!({"jsonrpc": "2.0", "id": id, "result": {}})
350}
351
352fn handle_resources_read(id: &Value, params: &Value) -> Value {
353    let uri = match params.get("uri").and_then(Value::as_str) {
354        Some(u) => u,
355        None => return error_response(id, -32602, "missing 'uri'"),
356    };
357    let peer_opt = parse_inbox_uri(uri);
358    match read_inbox_resource(peer_opt) {
359        Ok(payload) => json!({
360            "jsonrpc": "2.0",
361            "id": id,
362            "result": {
363                "contents": [{
364                    "uri": uri,
365                    "mimeType": "application/x-ndjson",
366                    "text": payload,
367                }]
368            }
369        }),
370        Err(e) => error_response(id, -32603, &e.to_string()),
371    }
372}
373
374/// Parse `wire://inbox/<peer>` → Some(peer). `wire://inbox/all` → None.
375/// Anything else → returns a marker that triggers "unknown URI" on read.
376fn parse_inbox_uri(uri: &str) -> Option<String> {
377    if let Some(rest) = uri.strip_prefix("wire://inbox/") {
378        if rest == "all" {
379            return None;
380        }
381        if !rest.is_empty() {
382            return Some(rest.to_string());
383        }
384    }
385    Some(format!("__invalid__{uri}"))
386}
387
388fn read_inbox_resource(peer_opt: Option<String>) -> Result<String, String> {
389    const LIMIT: usize = 50;
390    // Validate URI shape FIRST — an invalid URI is an error regardless of
391    // whether the inbox dir exists yet.
392    if let Some(ref p) = peer_opt
393        && p.starts_with("__invalid__")
394    {
395        return Err(
396            "unknown resource URI (must be wire://inbox/<peer> or wire://inbox/all)".into(),
397        );
398    }
399    let inbox = crate::config::inbox_dir().map_err(|e| e.to_string())?;
400    if !inbox.exists() {
401        return Ok(String::new());
402    }
403    let trust = crate::config::read_trust().map_err(|e| e.to_string())?;
404
405    let paths: Vec<std::path::PathBuf> = match peer_opt {
406        Some(p) => {
407            let path = inbox.join(format!("{p}.jsonl"));
408            if !path.exists() {
409                return Ok(String::new());
410            }
411            vec![path]
412        }
413        None => std::fs::read_dir(&inbox)
414            .map_err(|e| e.to_string())?
415            .flatten()
416            .map(|e| e.path())
417            .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
418            .collect(),
419    };
420
421    let mut events: Vec<(String, bool, Value)> = Vec::new();
422    for path in paths {
423        let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
424        let peer = path
425            .file_stem()
426            .and_then(|s| s.to_str())
427            .unwrap_or("")
428            .to_string();
429        for line in body.lines() {
430            let event: Value = match serde_json::from_str(line) {
431                Ok(v) => v,
432                Err(_) => continue,
433            };
434            let verified = crate::signing::verify_message_v31(&event, &trust).is_ok();
435            events.push((peer.clone(), verified, event));
436        }
437    }
438    // Newest last (JSONL append order is chronological); take tail LIMIT.
439    let take_from = events.len().saturating_sub(LIMIT);
440    let tail = &events[take_from..];
441
442    let mut out = String::new();
443    for (_peer, verified, mut event) in tail.iter().cloned() {
444        if let Some(obj) = event.as_object_mut() {
445            obj.insert("verified".into(), json!(verified));
446        }
447        out.push_str(&serde_json::to_string(&event).map_err(|e| e.to_string())?);
448        out.push('\n');
449    }
450    Ok(out)
451}
452
453fn handle_initialize(id: &Value) -> Value {
454    json!({
455        "jsonrpc": "2.0",
456        "id": id,
457        "result": {
458            "protocolVersion": PROTOCOL_VERSION,
459            "capabilities": {
460                "tools": {"listChanged": false},
461                "resources": {
462                    "listChanged": false,
463                    // Goal 2.1 (v0.2.1): subscribe shipped. A background watcher
464                    // thread polls the inbox every 2s and pushes
465                    // notifications/resources/updated via a writer-thread channel
466                    // for any subscribed URI.
467                    "subscribe": true
468                }
469            },
470            "serverInfo": {
471                "name": SERVER_NAME,
472                "version": SERVER_VERSION,
473            },
474            "instructions": "wire — agent-to-agent comms. Canonical verbs (v0.9+): wire_dial (initiate pair), wire_send (talk; auto-pairs on miss), wire_pending (see inbound requests), wire_accept (consent to a pending pair), wire_reject (refuse), wire_whois (inspect identity), wire_status (daemon + sync health). For cross-machine pairing pass `<handle>@<relay>` to wire_dial. Bilateral: the peer must also accept (wire_accept or wire_dial back) before capability flows in both directions. INBOUND pair requests from strangers land in pending-inbound: call wire_pending to enumerate, surface to operator, then wire_accept or wire_reject. NEVER auto-accept inbound pair requests without operator consent — accepting grants the peer authenticated write access to this agent's inbox. Resources: 'wire://inbox/<peer>' exposes each pinned peer's verified inbox (JSONL). ON SESSION START, DO THIS IMMEDIATELY (every wire session): (1) call wire_status to confirm the `wire daemon` sync loop is running — `daemon_running:true` + `last_sync_age_seconds < 60`; if not, the session won't push outbound or pull inbound and the operator must start a daemon (`wire daemon --interval 5` in a background shell, or systemd/launchd). (2) arm a PERSISTENT stream-watcher on `wire monitor --json` — in Claude Code, the Monitor tool with persistent:true — so peer messages surface live mid-session. The monitor does NOT sync the relay; it only tails the inbox the daemon writes. Both are required. WHEN A PEER MESSAGE ARRIVES, reply to it in your own live context WITHOUT waiting for the operator to prompt you; this is how paired agents collaborate immediately. (Default filter strips pair_drop/pair_drop_ack/heartbeat noise.) v0.14.2: wire_send POSTs synchronously by default — response `status` is the actual relay verdict: `delivered` (event landed on peer's slot), `duplicate` (same event_id already on slot; peer can still pull), `peer_unknown` (peer not pinned — run wire_dial first), `slot_stale` (peer's slot rotated — run wire_dial to re-pair), or `transport_error` (TLS/DNS/relay-5xx; check `reason` field). Pass `queue:true` to opt back into the legacy outbox→daemon-push path for offline-buffer / pre-pair queueing. wire_pull is the symmetric receive primitive — call it to trigger an immediate relay GET instead of waiting for the daemon's 5s pull cycle; returns written[]/rejected[]/total_seen the same way `wire pull --json` does. See docs/AGENT_INTEGRATION.md for the full monitor recipe and THREAT_MODEL.md (T10/T14)."
475        }
476    })
477}
478
479fn handle_tools_list(id: &Value) -> Value {
480    json!({
481        "jsonrpc": "2.0",
482        "id": id,
483        "result": {
484            "tools": tool_defs(),
485        }
486    })
487}
488
489fn tool_defs() -> Vec<Value> {
490    vec![
491        json!({
492            "name": "wire_whoami",
493            "description": "Return this agent's DID, fingerprint, key_id, public key, and capabilities. Read-only.",
494            "inputSchema": {"type": "object", "properties": {}, "required": []}
495        }),
496        json!({
497            "name": "wire_peers",
498            "description": "List pinned peers with their tier (UNTRUSTED/VERIFIED/ATTESTED) and advertised capabilities. Read-only.",
499            "inputSchema": {"type": "object", "properties": {}, "required": []}
500        }),
501        json!({
502            "name": "wire_status",
503            "description": "v0.14.2 — daemon + sync-loop health check. Returns: daemon_running (pidfile pid alive), all_running_pids (pgrep for `wire daemon`), last_sync_age_seconds (age of the most recent successful daemon cycle; null if no cycle ever recorded), outbox_count, inbox_count, peer count. **Call this BEFORE assuming wire_send actually delivered** — `wire_send` returns `status:\"queued\"` even if no daemon is running to push the queued event. A nonzero `outbox_count` with no recent `last_sync` means events are queued into the void. Read-only.",
504            "inputSchema": {"type": "object", "properties": {}, "required": []}
505        }),
506        json!({
507            "name": "wire_send",
508            "description": "Sign and queue an event to a peer. Returns event_id (SHA-256 of canonical body — content-addressed, so identical bodies produce identical event_ids and the daemon dedupes). Body may be plain text or a JSON-encoded structured value. Concurrent sends to multiple peers are safe (per-peer outbox files); concurrent sends to the same peer are serialized via a per-path lock.",
509            "inputSchema": {
510                "type": "object",
511                "properties": {
512                    "peer": {"type": "string", "description": "Peer handle (without did:wire: prefix). Must be a pinned peer; check wire_peers first."},
513                    "kind": {"type": "string", "description": "Event kind: a name (decision, claim, ack, agent_card, trust_add_key, trust_revoke_key, wire_open, wire_close) or a numeric kind id."},
514                    "body": {"type": "string", "description": "Event body. Plain text becomes a JSON string; valid JSON is parsed and embedded structurally."},
515                    "time_sensitive_until": {"type": "string", "description": "Optional advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp."}
516                },
517                "required": ["peer", "kind", "body"]
518            }
519        }),
520        json!({
521            "name": "wire_pull",
522            "description": "v0.14.2: trigger an immediate, synchronous pull from this agent's relay slot(s). Returns the same shape as `wire pull --json`: written[] (events landed in inbox), rejected[] (failed signature / cursor verify / dedupe), total_seen, cursor_blocked, endpoints_pulled. **Use this when you want events NOW** instead of waiting for the daemon's 5s pull cycle. Symmetric to wire_send's sync POST. Read-only — only consults the relay's GET, no mutations beyond writing inbox.jsonl + advancing per-slot cursors. Idempotent: re-pulling with the same cursor returns nothing new.",
523            "inputSchema": {"type": "object", "properties": {}, "required": []}
524        }),
525        json!({
526            "name": "wire_tail",
527            "description": "Read recent signed events from this agent's inbox. Each event has a 'verified' field (bool) — the Ed25519 signature was checked against the trust state before the daemon wrote the inbox. **Orientation (wire #79):** defaults to NEWEST-N (last `limit` events across all matched peers, sorted chronologically by timestamp). Pass `oldest: true` for FIFO behaviour (first-N, for inbox replay from the start).",
528            "inputSchema": {
529                "type": "object",
530                "properties": {
531                    "peer": {"type": "string", "description": "Optional peer handle to filter inbox by."},
532                    "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Max events to return."},
533                    "oldest": {"type": "boolean", "default": false, "description": "Return the FIRST `limit` events (oldest-N) instead of the default last-N (newest-N)."}
534                },
535                "required": []
536            }
537        }),
538        json!({
539            "name": "wire_verify",
540            "description": "Verify a signed event JSON against the local trust state. Returns {verified: bool, reason?: string}. Use this to validate events received out-of-band (not via the daemon).",
541            "inputSchema": {
542                "type": "object",
543                "properties": {
544                    "event": {"type": "string", "description": "JSON-encoded signed event."}
545                },
546                "required": ["event"]
547            }
548        }),
549        json!({
550            "name": "wire_init",
551            "description": "Idempotent identity creation. If already initialized with the same handle: returns the existing identity (no-op). If initialized with a different handle: errors — operator must explicitly delete config to re-key. If --relay is passed and not yet bound, also allocates a relay slot in one step.",
552            "inputSchema": {
553                "type": "object",
554                "properties": {
555                    "handle": {"type": "string", "description": "Short handle (becomes did:wire:<handle>). ASCII alphanumeric / '-' / '_' only."},
556                    "name": {"type": "string", "description": "Optional display name (defaults to capitalized handle)."},
557                    "relay_url": {"type": "string", "description": "Optional relay URL — if set, also binds a relay slot."}
558                },
559                "required": ["handle"]
560            }
561        }),
562        json!({
563            "name": "wire_invite_mint",
564            "description": "Mint a single-paste invite URL (v0.4.0). Auto-inits this agent + auto-allocates a relay slot if needed. Hand the URL string to ONE peer (Discord/SMS/voice); when they call wire_invite_accept on it, the daemon completes the pair end-to-end with no SAS digits. Single-use by default; --uses N for multi-accept. TTL 24h by default. Returns {invite_url, ttl_secs, uses}.",
565            "inputSchema": {
566                "type": "object",
567                "properties": {
568                    "relay_url": {"type": "string", "description": "Override relay for first-time auto-allocate."},
569                    "ttl_secs": {"type": "integer", "description": "Invite lifetime in seconds (default 86400)."},
570                    "uses": {"type": "integer", "description": "Number of distinct peers that can accept before consumption (default 1)."}
571                }
572            }
573        }),
574        json!({
575            "name": "wire_invite_accept",
576            "description": "Accept a wire invite URL (v0.4.0). Auto-inits this agent + auto-allocates a relay slot if needed (zero prior setup OK). Pins issuer from URL contents, sends our signed agent-card to issuer's slot. Issuer's daemon completes the bilateral pin on next pull. Returns {paired_with, peer_handle, event_id, status}.",
577            "inputSchema": {
578                "type": "object",
579                "properties": {
580                    "url": {"type": "string", "description": "Full wire://pair?v=1&inv=... URL."}
581                },
582                "required": ["url"]
583            }
584        }),
585        // v0.5 — agentic hotline.
586        json!({
587            "name": "wire_add",
588            "description": "Bilateral pair (v0.5.14). Resolve a peer handle (`nick@domain`) via the domain's `.well-known/wire/agent`, pin them locally, and deliver a signed pair-intro to their slot. THE PEER MUST ALSO RUN `wire add` (or `wire accept`) ON THEIR SIDE — bilateral-required as of v0.5.14, no auto-pin on receiver. Once both sides have gestured consent, capability flows in both directions. Use this for outgoing pair requests; for incoming pair_drops in the operator's pending-inbound queue, use `wire_accept` or `wire_reject` instead.",
589            "inputSchema": {
590                "type": "object",
591                "properties": {
592                    "handle": {"type": "string", "description": "Peer handle like `nick@domain`."},
593                    "relay_url": {"type": "string", "description": "Override resolver URL (default: `https://<domain>`)."}
594                },
595                "required": ["handle"]
596            }
597        }),
598        // v0.10.1: canonical MCP names mirroring the operator-facing
599        // verbs (wire dial / accept / reject / pending). Deprecated aliases
600        // wire_pair_accept / wire_pair_reject / wire_pair_list_inbound were
601        // removed from the catalog in RFC-005 Phase 2; calls to those names
602        // now return a helpful redirect error (see dispatch).
603        json!({
604            "name": "wire_dial",
605            "description": "v0.8 — go talk to this name. Accepts a character nickname (`noble-slate`), session name, card handle, or DID — or a federation handle (`<handle>@<relay>`). Resolves through the local addressing layer (pinned peers, local sister sessions) or routes federation via `.well-known/wire/agent`. Drives the right pair flow (already-pinned: no-op, local sister: disk-read --local-sister, federation: pair_drop). After this completes the peer is in `wire_peers` and `wire_send` to them works.",
606            "inputSchema": {
607                "type": "object",
608                "properties": {
609                    "name": {"type": "string", "description": "Peer name — character nickname / session / handle / DID / `<handle>@<relay>`."}
610                },
611                "required": ["name"]
612            }
613        }),
614        json!({
615            "name": "wire_accept",
616            "description": "v0.9 — accept a pending-inbound pair request by character nickname or handle. Replaces deprecated wire_pair_accept. Pins the peer VERIFIED, ships our slot_token via pair_drop_ack, and deletes the pending record. Requires explicit operator consent — surface the request to the user before calling.",
617            "inputSchema": {
618                "type": "object",
619                "properties": {
620                    "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle, from wire_pending)."}
621                },
622                "required": ["peer"]
623            }
624        }),
625        json!({
626            "name": "wire_reject",
627            "description": "v0.9 — refuse a pending-inbound pair request without pairing. Replaces deprecated wire_pair_reject. Idempotent: succeeds with `rejected: false` if no record existed for that peer.",
628            "inputSchema": {
629                "type": "object",
630                "properties": {
631                    "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle)."}
632                },
633                "required": ["peer"]
634            }
635        }),
636        json!({
637            "name": "wire_pending",
638            "description": "v0.9 — list pending-inbound pair requests waiting for operator consent. Returns the same flat array as legacy wire_pair_list_inbound. Use on session start (or in response to a `wire — pair request from X` OS toast) to surface inbound requests for accept/reject decisions.",
639            "inputSchema": {"type": "object", "properties": {}}
640        }),
641        json!({
642            "name": "wire_claim",
643            "description": "Publish this agent in a relay's handle directory so others can reach it by `<persona>@<relay-domain>`. ONE-NAME RULE: the claimed handle is ALWAYS your DID-derived persona — you do not choose it. The `nick` arg is optional + advisory; a value that differs from your persona is ignored (response sets typed_nick_ignored=true). Auto-inits + auto-allocates a relay slot if needed. FCFS — same-DID re-claims allowed (used for profile/slot updates).",
644            "inputSchema": {
645                "type": "object",
646                "properties": {
647                    "nick": {"type": "string", "description": "Optional + advisory. Ignored if it differs from your DID-derived persona (one-name rule)."},
648                    "relay_url": {"type": "string", "description": "Relay to claim on. Default = our relay."},
649                    "public_url": {"type": "string", "description": "Public URL the relay should advertise to resolvers."}
650                }
651            }
652        }),
653        json!({
654            "name": "wire_whois",
655            "description": "Look up an agent profile. With no handle, returns the local agent's profile. With a `nick@domain` handle, resolves via that domain's `.well-known/wire/agent` and verifies the returned signed card.",
656            "inputSchema": {
657                "type": "object",
658                "properties": {
659                    "handle": {"type": "string", "description": "Optional `nick@domain`. Omit for self."},
660                    "relay_url": {"type": "string", "description": "Override resolver URL."}
661                }
662            }
663        }),
664        json!({
665            "name": "wire_profile_set",
666            "description": "Edit a profile field on the local agent's signed agent-card. Field names: display_name, emoji, motto, vibe (array of strings), pronouns, avatar_url, handle (`nick@domain`), now (object). The card is re-signed atomically; the new profile is visible to anyone who resolves us via wire_whois. Use this to let the agent EXPRESS PERSONALITY — choose a motto, an emoji, a vibe.",
667            "inputSchema": {
668                "type": "object",
669                "properties": {
670                    "field": {"type": "string", "description": "One of: display_name, emoji, motto, vibe, pronouns, avatar_url, handle, now."},
671                    "value": {"description": "String for most fields; array for vibe; object for now. Pass JSON null to clear a field."}
672                },
673                "required": ["field", "value"]
674            }
675        }),
676        json!({
677            "name": "wire_profile_get",
678            "description": "Return the local agent's full profile (DID + handle + emoji + motto + vibe + pronouns + now). Cheap; no network. Use this to surface 'who am I' to the operator or to compose self-introductions to new peers.",
679            "inputSchema": {"type": "object", "properties": {}}
680        }),
681        // ---- group chat (v0.13.4): a group is a shared relay-room slot; the
682        // creator-signed roster carries member keys so members verify each
683        // other without pairing. GroupTier (creator/member/introduced) is a
684        // SEPARATE axis from bilateral peer trust. ----
685        json!({
686            "name": "wire_group_create",
687            "description": "Create a group chat room (you become the creator). Allocates a shared relay slot whose token is the room key, signs the initial roster, and persists it locally. Returns {id, name, members, relay_url}. Use the returned id with the other wire_group_* tools.",
688            "inputSchema": {
689                "type": "object",
690                "properties": {"name": {"type": "string", "description": "Human label for the group."}},
691                "required": ["name"]
692            }
693        }),
694        json!({
695            "name": "wire_group_add",
696            "description": "Add a bilaterally-VERIFIED pinned peer to a group you created, as a Member. The peer must already be paired + VERIFIED (check wire_peers). Re-signs the roster and queues a signed group_invite to every member (run a normal push/let the daemon deliver). Creator-only.",
697            "inputSchema": {
698                "type": "object",
699                "properties": {
700                    "group": {"type": "string", "description": "Group id or name."},
701                    "peer": {"type": "string", "description": "Handle of a VERIFIED pinned peer."}
702                },
703                "required": ["group", "peer"]
704            }
705        }),
706        json!({
707            "name": "wire_group_send",
708            "description": "Post a message to a group room (one signed event to the shared slot; every member reads it). You must have the group locally (created it, were added, or joined by code).",
709            "inputSchema": {
710                "type": "object",
711                "properties": {
712                    "group": {"type": "string", "description": "Group id or name."},
713                    "message": {"type": "string", "description": "Message text."}
714                },
715                "required": ["group", "message"]
716            }
717        }),
718        json!({
719            "name": "wire_group_tail",
720            "description": "Read recent messages from a group room. Each message has a 'verified' bool (signature checked against the roster + room-announced joiner keys). Also surfaces join notices. Pulls the shared room slot.",
721            "inputSchema": {
722                "type": "object",
723                "properties": {
724                    "group": {"type": "string", "description": "Group id or name."},
725                    "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 20, "description": "Max timeline entries to return."}
726                },
727                "required": ["group"]
728            }
729        }),
730        json!({
731            "name": "wire_group_list",
732            "description": "List the groups this agent is in, with each group's members and their GroupTiers (creator/member/introduced). Read-only, local.",
733            "inputSchema": {"type": "object", "properties": {}, "required": []}
734        }),
735        json!({
736            "name": "wire_group_invite",
737            "description": "Mint a shareable join code for a group — a self-contained token (room coords + signed roster). Anyone you give it to can wire_group_join to enter at Introduced tier. The code IS the room key; share only with people you want in the room.",
738            "inputSchema": {
739                "type": "object",
740                "properties": {"group": {"type": "string", "description": "Group id or name."}},
741                "required": ["group"]
742            }
743        }),
744        json!({
745            "name": "wire_group_join",
746            "description": "Join a group from a code minted by wire_group_invite. Materializes the room locally, pins existing members on the creator's vouch, and announces you to the room so members verify your messages. No prior pairing needed.",
747            "inputSchema": {
748                "type": "object",
749                "properties": {"code": {"type": "string", "description": "The `wire-group:` join code."}},
750                "required": ["code"]
751            }
752        }),
753    ]
754}
755
756fn handle_tools_call(id: &Value, params: &Value, _state: &McpState) -> Value {
757    let name = match params.get("name").and_then(Value::as_str) {
758        Some(n) => n,
759        None => return error_response(id, -32602, "missing tool name"),
760    };
761    let args = params
762        .get("arguments")
763        .cloned()
764        .unwrap_or_else(|| json!({}));
765
766    let result = match name {
767        "wire_whoami" => tool_whoami(),
768        "wire_status" => tool_status(),
769        "wire_peers" => tool_peers(),
770        "wire_send" => tool_send(&args),
771        "wire_pull" => tool_pull(),
772        "wire_tail" => tool_tail(&args),
773        "wire_verify" => tool_verify(&args),
774        "wire_init" => tool_init(&args),
775        "wire_invite_mint" => tool_invite_mint(&args),
776        "wire_invite_accept" => tool_invite_accept(&args),
777        // v0.5 — agentic hotline (handle + profile + zero-paste discovery).
778        "wire_add" => tool_add(&args),
779        // v0.5.14 — bilateral-required pair: inbound queue management.
780        // v0.10.1: canonical names introduced; v0.14.x (RFC-005 Phase 2):
781        // deprecated wire_pair_* alias surface removed from tools/list.
782        // Calls to the old names return a helpful redirect error.
783        "wire_accept" => tool_pair_accept(&args),
784        "wire_reject" => tool_pair_reject(&args),
785        "wire_pending" => tool_pair_list_inbound(),
786        "wire_pair_accept" => Err("wire_pair_accept was renamed to wire_accept (v0.9+). \
787             Use wire_accept instead."
788            .into()),
789        "wire_pair_reject" => Err("wire_pair_reject was renamed to wire_reject (v0.9+). \
790             Use wire_reject instead."
791            .into()),
792        "wire_pair_list_inbound" => Err(
793            "wire_pair_list_inbound was renamed to wire_pending (v0.9+). \
794             Use wire_pending instead."
795                .into(),
796        ),
797        "wire_dial" => tool_dial(&args),
798        "wire_claim" => tool_claim_handle(&args),
799        "wire_whois" => tool_whois(&args),
800        "wire_profile_set" => tool_profile_set(&args),
801        "wire_profile_get" => tool_profile_get(),
802        // v0.13.4 — group chat (shared-room slot + introduce-on-vouch).
803        "wire_group_create" => tool_group_create(&args),
804        "wire_group_add" => tool_group_add(&args),
805        "wire_group_send" => tool_group_send(&args),
806        "wire_group_tail" => tool_group_tail(&args),
807        "wire_group_list" => tool_group_list(),
808        "wire_group_invite" => tool_group_invite(&args),
809        "wire_group_join" => tool_group_join(&args),
810        // Legacy alias kept for older agent prompts that reference `wire_join`.
811        // The SAS code-phrase pair flow it pointed at is gone — redirect to the
812        // canonical handle-dial path.
813        "wire_join" => Err("wire_join (SAS code-phrase pairing) was removed. \
814             Use wire_dial(\"<handle>@<relay>\") to pair by handle. \
815             See docs/AGENT_INTEGRATION.md."
816            .into()),
817        other => Err(format!("unknown tool: {other}")),
818    };
819
820    match result {
821        Ok(value) => json!({
822            "jsonrpc": "2.0",
823            "id": id,
824            "result": {
825                "content": [{
826                    "type": "text",
827                    "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
828                }],
829                "isError": false
830            }
831        }),
832        Err(message) => json!({
833            "jsonrpc": "2.0",
834            "id": id,
835            "result": {
836                "content": [{"type": "text", "text": message}],
837                "isError": true
838            }
839        }),
840    }
841}
842
843// ---------- tool implementations ----------
844
845fn tool_whoami() -> Result<Value, String> {
846    use crate::config;
847    use crate::signing::{b64decode, fingerprint, make_key_id};
848
849    if !config::is_initialized().map_err(|e| e.to_string())? {
850        return Err("not initialized — operator must run `wire init <handle>` first".into());
851    }
852    let card = config::read_agent_card().map_err(|e| e.to_string())?;
853    let did = card
854        .get("did")
855        .and_then(Value::as_str)
856        .unwrap_or("")
857        .to_string();
858    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
859    let pk_b64 = card
860        .get("verify_keys")
861        .and_then(Value::as_object)
862        .and_then(|m| m.values().next())
863        .and_then(|v| v.get("key"))
864        .and_then(Value::as_str)
865        .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
866    let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
867    let fp = fingerprint(&pk_bytes);
868    let key_id = make_key_id(&handle, &pk_bytes);
869    let capabilities = card
870        .get("capabilities")
871        .cloned()
872        .unwrap_or_else(|| json!(["wire/v3.2"]));
873    // v0.12: surface the DID-derived persona (nickname + emoji + palette)
874    // that the CLI `wire whoami`/`here` already emit, so agents and toasts
875    // see the persona, not just the raw handle.
876    let persona =
877        serde_json::to_value(crate::character::Character::from_card(&card)).unwrap_or(Value::Null);
878    // v0.14: surface the RFC-001 op claims (op_did / op_pubkey / op_cert /
879    // org_memberships / schema_version) when enrolled, mirroring the CLI
880    // `wire whoami --json` shape. Same `op_claims_from_card` helper as
881    // CLI ⇒ MCP + CLI stay in lock-step as the inline set grows. Older
882    // cards / unenrolled ⇒ no extra keys (no JSON null-spam).
883    let mut payload = serde_json::Map::new();
884    payload.insert("did".into(), json!(did));
885    payload.insert("handle".into(), json!(handle));
886    payload.insert("persona".into(), persona);
887    payload.insert("fingerprint".into(), json!(fp));
888    payload.insert("key_id".into(), json!(key_id));
889    payload.insert("public_key_b64".into(), json!(pk_b64));
890    payload.insert("capabilities".into(), capabilities);
891    // RFC-008 §A: same `session_source` the CLI `wire whoami --json` emits —
892    // which signal won session/home resolution — so an agent diagnosing a
893    // wrong/shared identity over MCP sees the cause without shelling out.
894    payload.insert(
895        "session_source".into(),
896        json!(crate::session::session_source()),
897    );
898    for (k, v) in crate::cli::op_claims_from_card(&card) {
899        payload.insert(k, v);
900    }
901    Ok(Value::Object(payload))
902}
903
904fn tool_peers() -> Result<Value, String> {
905    use crate::config;
906
907    let trust = config::read_trust().map_err(|e| e.to_string())?;
908    let agents = trust
909        .get("agents")
910        .and_then(Value::as_object)
911        .cloned()
912        .unwrap_or_default();
913    // v0.14.3 (coral dogfood 2026-06-01): use effective tier so the
914    // MCP surface matches the CLI ones (wire status / wire peers /
915    // wire here all switched to effective_tier in #199 + #201).
916    // Pre-fix, agents calling wire_peers via MCP got raw
917    // trust-promoted VERIFIED even when the bilateral handshake
918    // never delivered the slot credentials → daemon can't push but
919    // agent thought it could.
920    let relay_state =
921        config::read_relay_state().unwrap_or_else(|_| json!({"self": null, "peers": {}}));
922    let mut self_did: Option<String> = None;
923    if let Ok(card) = config::read_agent_card() {
924        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
925    }
926    let mut peers = Vec::new();
927    for (handle, agent) in agents.iter() {
928        let did = agent
929            .get("did")
930            .and_then(Value::as_str)
931            .unwrap_or("")
932            .to_string();
933        if Some(did.as_str()) == self_did.as_deref() {
934            continue;
935        }
936        // v0.12: include the persona (respecting the peer's advertised
937        // override when their card carries one, else DID-derived) so MCP
938        // callers render the nickname/emoji instead of the raw handle.
939        let persona = match agent.get("card") {
940            Some(c) => crate::character::Character::from_card(c),
941            None => crate::character::Character::from_did(&did),
942        };
943        // v0.14: surface peer's inline op claims (when their pinned card
944        // carries them) so paired agents see ORG_VERIFIED-source membership
945        // without reading trust.json directly. Identical shape to the CLI
946        // `wire peers --json` row; older peers ⇒ no extra keys.
947        let peer_op_claims = agent
948            .get("card")
949            .map(crate::cli::op_claims_from_card)
950            .unwrap_or_default();
951        let mut row = serde_json::Map::new();
952        row.insert("handle".into(), json!(handle));
953        row.insert(
954            "persona".into(),
955            serde_json::to_value(&persona).unwrap_or(Value::Null),
956        );
957        row.insert("did".into(), json!(did));
958        row.insert(
959            "tier".into(),
960            json!(crate::trust::effective_tier(&trust, &relay_state, handle)),
961        );
962        row.insert(
963            "capabilities".into(),
964            agent
965                .get("card")
966                .and_then(|c| c.get("capabilities"))
967                .cloned()
968                .unwrap_or_else(|| json!([])),
969        );
970        for (k, v) in peer_op_claims {
971            row.insert(k, v);
972        }
973        peers.push(Value::Object(row));
974    }
975    Ok(json!(peers))
976}
977
978/// Run `wire group <args> --json` by spawning this same binary, inheriting the
979/// MCP session's WIRE_* env so it resolves the same identity/home. Group ops are
980/// infrequent, so this reuses the exact, tested CLI logic — including the
981/// verification-sensitive invite/join paths — rather than duplicating it here.
982fn group_cli_json(args: &[&str]) -> Result<Value, String> {
983    let exe = std::env::current_exe().map_err(|e| format!("locating wire binary: {e}"))?;
984    let out = std::process::Command::new(exe)
985        .arg("group")
986        .args(args)
987        .arg("--json")
988        .env("WIRE_QUIET_AUTOSESSION", "1") // suppress the adopt-session stderr line
989        .output()
990        .map_err(|e| format!("spawning `wire group`: {e}"))?;
991    if !out.status.success() {
992        let err = String::from_utf8_lossy(&out.stderr);
993        return Err(err.trim().to_string());
994    }
995    let s = String::from_utf8_lossy(&out.stdout);
996    // Last JSON object line is the result (any adopt chatter went to stderr).
997    let line = s
998        .lines()
999        .rev()
1000        .find(|l| l.trim_start().starts_with('{'))
1001        .unwrap_or("{}");
1002    serde_json::from_str(line).map_err(|e| format!("parsing `wire group` output: {e}"))
1003}
1004
1005fn tool_group_create(args: &Value) -> Result<Value, String> {
1006    let name = args
1007        .get("name")
1008        .and_then(Value::as_str)
1009        .ok_or("missing 'name'")?;
1010    group_cli_json(&["create", name])
1011}
1012
1013fn tool_group_add(args: &Value) -> Result<Value, String> {
1014    let group = args
1015        .get("group")
1016        .and_then(Value::as_str)
1017        .ok_or("missing 'group'")?;
1018    let peer = args
1019        .get("peer")
1020        .and_then(Value::as_str)
1021        .ok_or("missing 'peer'")?;
1022    group_cli_json(&["add", group, peer])
1023}
1024
1025fn tool_group_send(args: &Value) -> Result<Value, String> {
1026    let group = args
1027        .get("group")
1028        .and_then(Value::as_str)
1029        .ok_or("missing 'group'")?;
1030    let message = args
1031        .get("message")
1032        .and_then(Value::as_str)
1033        .ok_or("missing 'message'")?;
1034    group_cli_json(&["send", group, message])
1035}
1036
1037fn tool_group_tail(args: &Value) -> Result<Value, String> {
1038    let group = args
1039        .get("group")
1040        .and_then(Value::as_str)
1041        .ok_or("missing 'group'")?;
1042    if let Some(n) = args.get("limit").and_then(Value::as_u64) {
1043        group_cli_json(&["tail", group, "--limit", &n.to_string()])
1044    } else {
1045        group_cli_json(&["tail", group])
1046    }
1047}
1048
1049fn tool_group_list() -> Result<Value, String> {
1050    group_cli_json(&["list"])
1051}
1052
1053fn tool_group_invite(args: &Value) -> Result<Value, String> {
1054    let group = args
1055        .get("group")
1056        .and_then(Value::as_str)
1057        .ok_or("missing 'group'")?;
1058    group_cli_json(&["invite", group])
1059}
1060
1061fn tool_group_join(args: &Value) -> Result<Value, String> {
1062    let code = args
1063        .get("code")
1064        .and_then(Value::as_str)
1065        .ok_or("missing 'code'")?;
1066    group_cli_json(&["join", code])
1067}
1068
1069/// v0.14.2 (#162): daemon + sync-loop health check, MCP-side mirror of
1070/// `wire status`. Specifically engineered to answer the silent-send
1071/// question — "if I call wire_send right now, will the daemon actually
1072/// push it?". Returns the daemon-liveness section + last-sync metadata +
1073/// outbox/inbox depth so callers can branch on a stale or absent sync.
1074///
1075/// Read-only. No initialization gate — runs against an empty home
1076/// (returns `initialized:false` shape mirroring wire_whoami's
1077/// degraded-uninit path from #152).
1078fn tool_status() -> Result<Value, String> {
1079    use crate::config;
1080
1081    let initialized = config::is_initialized().unwrap_or(false);
1082    if !initialized {
1083        return Ok(json!({
1084            "initialized": false,
1085            "daemon_running": false,
1086            "last_sync_age_seconds": Value::Null,
1087        }));
1088    }
1089
1090    let snap = crate::ensure_up::daemon_liveness();
1091    let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1092    let last_sync_record = crate::ensure_up::read_last_sync_record();
1093
1094    let mut daemon = json!({
1095        "running": snap.pidfile_alive,
1096        "pid": snap.pidfile_pid,
1097        "all_running_pids": snap.pgrep_pids,
1098        "orphans": snap.orphan_pids,
1099    });
1100    if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
1101        daemon["version"] = json!(d.version);
1102        daemon["bin_path"] = json!(d.bin_path);
1103        daemon["did"] = json!(d.did);
1104        daemon["relay_url"] = json!(d.relay_url);
1105        daemon["started_at"] = json!(d.started_at);
1106    }
1107
1108    let (last_sync_at, last_sync_push_n, last_sync_pull_n, last_sync_rejected_n) =
1109        match last_sync_record {
1110            Some(rec) => (
1111                Some(rec.ts),
1112                Some(rec.push_n),
1113                Some(rec.pull_n),
1114                Some(rec.rejected_n),
1115            ),
1116            None => (None, None, None, None),
1117        };
1118
1119    let outbox_count = config::outbox_dir()
1120        .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1121        .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1122        .unwrap_or(0);
1123    let inbox_count = config::inbox_dir()
1124        .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1125        .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1126        .unwrap_or(0);
1127
1128    // v0.14.2 (#162 fix #2): total events queued but not yet pushed.
1129    // `pending_push_count > 0` + `stale_sync == true` = the
1130    // silent-send class — events queued, daemon not pushing.
1131    // v0.14.3 (coral dogfood 2026-06-01): also surface a per-peer
1132    // breakdown so MCP-side agents (and the CLI both share the
1133    // same derivation) can see which peer is wedged + at what
1134    // trust tier without re-walking the outbox.
1135    let pending_push_breakdown = config::compute_pending_push_breakdown();
1136    let pending_push_count: u64 = pending_push_breakdown.iter().map(|p| p.count).sum();
1137
1138    // v0.14.2 (#162 fix #7): SSE stream-subscriber state so callers
1139    // can distinguish "stream alive (live monitor will fire on
1140    // inbound)" from "polling-only (daemon up, monitor will wait
1141    // until next poll cycle)". Best-effort read; missing file is
1142    // Value::Null (unknown).
1143    let stream_state = config::read_stream_state();
1144
1145    Ok(json!({
1146        "initialized": true,
1147        "daemon": daemon,
1148        "daemon_running": snap.pidfile_alive,
1149        "last_sync_at": last_sync_at,
1150        "last_sync_age_seconds": last_sync_age,
1151        "last_sync_push_n": last_sync_push_n,
1152        "last_sync_pull_n": last_sync_pull_n,
1153        "last_sync_rejected_n": last_sync_rejected_n,
1154        "stale_sync": config::stale_sync(last_sync_age),
1155        "outbox_count": outbox_count,
1156        "inbox_count": inbox_count,
1157        "pending_push_count": pending_push_count,
1158        "pending_push_breakdown": pending_push_breakdown,
1159        "stream_state": stream_state,
1160    }))
1161}
1162
1163fn tool_send(args: &Value) -> Result<Value, String> {
1164    use crate::config;
1165    use crate::signing::{b64decode, sign_message_v31};
1166
1167    let peer = args
1168        .get("peer")
1169        .and_then(Value::as_str)
1170        .ok_or("missing 'peer'")?;
1171    let peer = crate::agent_card::bare_handle(peer);
1172    let kind = args
1173        .get("kind")
1174        .and_then(Value::as_str)
1175        .ok_or("missing 'kind'")?;
1176    let body = args
1177        .get("body")
1178        .and_then(Value::as_str)
1179        .ok_or("missing 'body'")?;
1180    let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
1181    // v0.14.2 (paul, 2026-06-01): opt back into the legacy outbox →
1182    // daemon-push pipeline. Default is synchronous POST so callers get
1183    // a real `delivered` / `duplicate` / `failed` verdict instead of
1184    // a `queued` lie. `queue: true` writes to outbox like pre-v0.14.2.
1185    let queue = args.get("queue").and_then(Value::as_bool).unwrap_or(false);
1186
1187    if !config::is_initialized().map_err(|e| e.to_string())? {
1188        return Err("not initialized — operator must run `wire init <handle>` first".into());
1189    }
1190    let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
1191    let card = config::read_agent_card().map_err(|e| e.to_string())?;
1192    let did = card
1193        .get("did")
1194        .and_then(Value::as_str)
1195        .unwrap_or("")
1196        .to_string();
1197    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1198    let pk_b64 = card
1199        .get("verify_keys")
1200        .and_then(Value::as_object)
1201        .and_then(|m| m.values().next())
1202        .and_then(|v| v.get("key"))
1203        .and_then(Value::as_str)
1204        .ok_or("agent-card missing verify_keys[*].key")?;
1205    let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1206
1207    // Body parses as JSON if possible, else stays a string.
1208    let body_value: Value =
1209        serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1210    let kind_id = parse_kind(kind);
1211
1212    let now = time::OffsetDateTime::now_utc()
1213        .format(&time::format_description::well_known::Rfc3339)
1214        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1215
1216    // v0.14.2 (#162 fix #4): canonicalize `to:` against the pinned
1217    // peer's full DID via the trust store. Bare-handle
1218    // `to:did:wire:<handle>` misses the long-fingerprint suffix
1219    // (`did:wire:sunlit-aurora-ec6f890d`) that pinned peers actually
1220    // publish — mismatch risks receiver rejection at canonical/cursor
1221    // verification. resolve_peer_did falls back to the bare form when
1222    // the peer isn't pinned yet (pre-pair queue best-effort).
1223    let trust_for_did = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
1224    let to_did = crate::trust::resolve_peer_did(&trust_for_did, peer);
1225    let mut event = json!({
1226        "timestamp": now,
1227        "from": did,
1228        "to": to_did,
1229        "type": kind,
1230        "kind": kind_id,
1231        "body": body_value,
1232    });
1233    if let Some(deadline) = deadline {
1234        event["time_sensitive_until"] =
1235            json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1236    }
1237    let signed =
1238        sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1239    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1240
1241    // v0.14.2 (paul, 2026-06-01): collapse send → outbox → push into
1242    // a synchronous POST by default. `queue: true` opts back into the
1243    // legacy outbox path for offline-buffer / batch / pre-pair queue
1244    // use cases.
1245    if !queue {
1246        let outcome = crate::send::attempt_deliver(peer, &signed).map_err(|e| e.to_string())?;
1247        let mut v = crate::send::delivery_json(&outcome, peer);
1248        // Carry the same daemon-health annotations the caller used to
1249        // get on the legacy `queued` response. With sync delivery
1250        // these are diagnostic-only (the verdict in `status` is the
1251        // authoritative answer), but they're cheap to compute and
1252        // existing consumers may key on them.
1253        let snap = crate::ensure_up::daemon_liveness();
1254        let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1255        if let Some(obj) = v.as_object_mut() {
1256            obj.insert("daemon_seen".into(), json!(snap.pidfile_alive));
1257            obj.insert("last_sync_age_seconds".into(), json!(last_sync_age));
1258            obj.insert(
1259                "stale_sync".into(),
1260                json!(config::stale_sync(last_sync_age)),
1261            );
1262        }
1263        return Ok(v);
1264    }
1265
1266    // Legacy --queue path. Outbox-write, daemon push loop drains.
1267    let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1268    let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1269    let snap = crate::ensure_up::daemon_liveness();
1270    let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1271    // Honesty check mirror of the CLI: if the peer is BOTH
1272    // unpinned in trust AND has no pending pair (outbound or
1273    // inbound), the queued event has nowhere to go and will sit
1274    // in outbox forever. Surface the warning as a structured
1275    // `warning` field so MCP-side agents can branch on it instead
1276    // of treating `status:"queued"` as success.
1277    let peer_pinned_in_trust = trust_for_did
1278        .get("agents")
1279        .and_then(Value::as_object)
1280        .map(|a| a.contains_key(peer))
1281        .unwrap_or(false);
1282    let peer_in_relay_state = config::read_relay_state()
1283        .ok()
1284        .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
1285        .map(|peers| peers.contains_key(peer))
1286        .unwrap_or(false);
1287    let pending_inbound = crate::pending_inbound_pair::list_pending_inbound()
1288        .ok()
1289        .map(|v| v.iter().any(|p| p.peer_handle == peer))
1290        .unwrap_or(false);
1291    let unpushable = !peer_pinned_in_trust && !peer_in_relay_state && !pending_inbound;
1292    let mut out = json!({
1293        "event_id": event_id,
1294        "status": "queued",
1295        "peer": peer,
1296        "outbox": outbox.to_string_lossy(),
1297        "daemon_seen": snap.pidfile_alive,
1298        "last_sync_age_seconds": last_sync_age,
1299        "stale_sync": config::stale_sync(last_sync_age),
1300    });
1301    if unpushable {
1302        out["warning"] = json!(format!(
1303            "`{peer}` is not pinned and has no pending pair — the event will sit in outbox forever unless you pair first (wire_dial)."
1304        ));
1305    }
1306    Ok(out)
1307}
1308
1309/// v0.14.2 (paul, post-#187): symmetric receive primitive. `wire_send`
1310/// became sync in #187; `wire_pull` is the mirror — trigger an
1311/// immediate relay GET on this agent's slot(s), write new events to
1312/// inbox, advance per-slot cursors, return the verdict. Thin wrapper
1313/// over `cli::run_sync_pull`; same code path the daemon's 5s pull
1314/// loop uses.
1315fn tool_pull() -> Result<Value, String> {
1316    crate::cli::run_sync_pull().map_err(|e| format!("{e:#}"))
1317}
1318
1319fn tool_tail(args: &Value) -> Result<Value, String> {
1320    use crate::config;
1321    use crate::signing::verify_message_v31;
1322
1323    let peer_filter = args.get("peer").and_then(Value::as_str);
1324    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1325    // wire #79: orientation parity with `wire tail` CLI — default newest-N,
1326    // `oldest=true` opts back into FIFO. Agents almost always want the
1327    // freshest inbox slice when re-tailing an established peer, not the
1328    // wire-init handshake noise.
1329    let oldest = args.get("oldest").and_then(Value::as_bool).unwrap_or(false);
1330    let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1331    if !inbox.exists() {
1332        return Ok(json!([]));
1333    }
1334    let trust = config::read_trust().map_err(|e| e.to_string())?;
1335    let entries: Vec<_> = std::fs::read_dir(&inbox)
1336        .map_err(|e| e.to_string())?
1337        .filter_map(|e| e.ok())
1338        .map(|e| e.path())
1339        .filter(|p| {
1340            p.extension().map(|x| x == "jsonl").unwrap_or(false)
1341                && match peer_filter {
1342                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1343                    None => true,
1344                }
1345        })
1346        .collect();
1347
1348    // (timestamp, per-file line index, event with verified meta). Sort key
1349    // mirrors the CLI cmd_tail for cross-tool consistency.
1350    let mut collected: Vec<(String, usize, Value)> = Vec::new();
1351    for path in &entries {
1352        let body = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1353        for (idx, line) in body.lines().enumerate() {
1354            let event: Value = match serde_json::from_str(line) {
1355                Ok(v) => v,
1356                Err(_) => continue,
1357            };
1358            let verified = verify_message_v31(&event, &trust).is_ok();
1359            let mut event_with_meta = event.clone();
1360            if let Some(obj) = event_with_meta.as_object_mut() {
1361                obj.insert("verified".into(), json!(verified));
1362            }
1363            let ts = event
1364                .get("timestamp")
1365                .and_then(Value::as_str)
1366                .unwrap_or("")
1367                .to_string();
1368            collected.push((ts, idx, event_with_meta));
1369        }
1370    }
1371    collected.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
1372
1373    let total = collected.len();
1374    let window: Vec<Value> = if limit == 0 {
1375        collected.into_iter().map(|(_, _, e)| e).collect()
1376    } else if oldest {
1377        collected
1378            .into_iter()
1379            .take(limit)
1380            .map(|(_, _, e)| e)
1381            .collect()
1382    } else {
1383        let start = total.saturating_sub(limit);
1384        collected
1385            .into_iter()
1386            .skip(start)
1387            .map(|(_, _, e)| e)
1388            .collect()
1389    };
1390    Ok(Value::Array(window))
1391}
1392
1393fn tool_verify(args: &Value) -> Result<Value, String> {
1394    use crate::config;
1395    use crate::signing::verify_message_v31;
1396
1397    let event_str = args
1398        .get("event")
1399        .and_then(Value::as_str)
1400        .ok_or("missing 'event'")?;
1401    let event: Value =
1402        serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1403    let trust = config::read_trust().map_err(|e| e.to_string())?;
1404    match verify_message_v31(&event, &trust) {
1405        Ok(()) => Ok(json!({"verified": true})),
1406        Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1407    }
1408}
1409
1410// ---------- pairing tools ----------
1411
1412/// v0.13: bootstrap a freshly-resolved session-keyed identity. Runs once per
1413/// session home (gated on `is_initialized`); no-op under WIRE_MCP_SKIP_AUTO_UP.
1414/// init (one-name) + federation slot via `ensure_self_with_relay`, then a
1415/// best-effort phonebook claim of the DID-derived persona. Network failures
1416/// are swallowed — the identity is still created locally; the claim retries on
1417/// a later start.
1418fn ensure_session_bootstrapped() {
1419    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
1420        return;
1421    }
1422    if crate::config::is_initialized().unwrap_or(false) {
1423        return; // this session home already has an identity
1424    }
1425    let (did, relay_url, slot_id, slot_token) =
1426        match crate::pair_invite::ensure_self_with_relay(None) {
1427            Ok(t) => t,
1428            Err(_) => return, // offline / relay down — init may have happened locally; skip claim
1429        };
1430    if let Ok(card) = crate::config::read_agent_card() {
1431        let persona = crate::agent_card::display_handle_from_did(&did).to_string();
1432        let client = crate::relay_client::RelayClient::new(&relay_url);
1433        let _ = client.handle_claim_v2(&persona, &slot_id, &slot_token, None, &card, None);
1434    }
1435}
1436
1437fn tool_init(args: &Value) -> Result<Value, String> {
1438    let handle = args
1439        .get("handle")
1440        .and_then(Value::as_str)
1441        .ok_or("missing 'handle'")?;
1442    let name = args.get("name").and_then(Value::as_str);
1443    let relay = args.get("relay_url").and_then(Value::as_str);
1444    crate::init::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1445}
1446
1447// ---------- invite-URL one-paste pair (v0.4.0) ----------
1448
1449fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1450    let relay_url = args.get("relay_url").and_then(Value::as_str);
1451    let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1452    let uses = args
1453        .get("uses")
1454        .and_then(Value::as_u64)
1455        .map(|u| u as u32)
1456        .unwrap_or(1);
1457    let url =
1458        crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1459    let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1460    Ok(json!({
1461        "invite_url": url,
1462        "ttl_secs": ttl_resolved,
1463        "uses": uses,
1464    }))
1465}
1466
1467fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1468    let url = args
1469        .get("url")
1470        .and_then(Value::as_str)
1471        .ok_or("missing 'url'")?;
1472    crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1473}
1474
1475// ---------- v0.5 — agentic hotline tools ----------
1476
1477/// wire_dial (MCP): mirror the CLI `dial` resolution ladder. The prior
1478/// wiring routed straight to `tool_add`, which reads a required `handle`
1479/// arg — but the wire_dial schema only provides `name`, so every dial
1480/// errored `missing 'handle'`. This reads `name` and routes:
1481///   • `<nick>@<relay>`  -> federation pair (via tool_add).
1482///   • already-pinned     -> no-op success (peer already reachable).
1483///   • otherwise          -> honest error. Bare-nickname / local-sister
1484///     resolution over MCP is not yet wired (CLI `wire dial` does it);
1485///     use `<nick>@<relay>` or `wire_send` (auto-pairs on miss).
1486fn tool_dial(args: &Value) -> Result<Value, String> {
1487    let name = args
1488        .get("name")
1489        .and_then(Value::as_str)
1490        .or_else(|| args.get("handle").and_then(Value::as_str))
1491        .ok_or("missing 'name'")?;
1492
1493    if name.contains('@') {
1494        // Federation path. Present `name` as the `handle` tool_add expects.
1495        let mut a = args.clone();
1496        if let Some(obj) = a.as_object_mut() {
1497            obj.insert("handle".into(), Value::String(name.to_string()));
1498        }
1499        return tool_add(&a);
1500    }
1501
1502    let relay_state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1503    let pinned = relay_state
1504        .get("peers")
1505        .and_then(Value::as_object)
1506        .map(|m| m.contains_key(name))
1507        .unwrap_or(false);
1508    if pinned {
1509        return Ok(json!({
1510            "name_input": name,
1511            "status": "already_pinned",
1512            "peer_handle": name,
1513        }));
1514    }
1515
1516    Err(format!(
1517        "cannot resolve `{name}` over MCP: bare-nickname / local-sister dialling is not yet \
1518         wired into the MCP surface. Use a federation handle `{name}@<relay>`, or `wire_send` \
1519         (it auto-pairs on miss)."
1520    ))
1521}
1522
1523fn tool_add(args: &Value) -> Result<Value, String> {
1524    let handle = args
1525        .get("handle")
1526        .and_then(Value::as_str)
1527        .ok_or("missing 'handle'")?;
1528    let relay_override = args.get("relay_url").and_then(Value::as_str);
1529
1530    let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1531
1532    // Ensure self has identity + relay slot (auto-inits if needed).
1533    let (our_did, our_relay, our_slot_id, our_slot_token) =
1534        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1535
1536    // Resolve peer via .well-known.
1537    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1538        .map_err(|e| format!("{e:#}"))?;
1539    let peer_card = resolved
1540        .get("card")
1541        .cloned()
1542        .ok_or("resolved missing card")?;
1543    let peer_did = resolved
1544        .get("did")
1545        .and_then(Value::as_str)
1546        .ok_or("resolved missing did")?
1547        .to_string();
1548    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1549    let peer_slot_id = resolved
1550        .get("slot_id")
1551        .and_then(Value::as_str)
1552        .ok_or("resolved missing slot_id")?
1553        .to_string();
1554    let peer_relay = resolved
1555        .get("relay_url")
1556        .and_then(Value::as_str)
1557        .map(str::to_string)
1558        .or_else(|| relay_override.map(str::to_string))
1559        .unwrap_or_else(|| format!("https://{}", parsed.domain));
1560
1561    // Pin peer in trust + relay-state. slot_token arrives via ack later.
1562    let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1563    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1564    crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1565    let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1566    let existing_token = relay_state
1567        .get("peers")
1568        .and_then(|p| p.get(&peer_handle))
1569        .and_then(|p| p.get("slot_token"))
1570        .and_then(Value::as_str)
1571        .map(str::to_string)
1572        .unwrap_or_default();
1573    relay_state["peers"][&peer_handle] = json!({
1574        "relay_url": peer_relay,
1575        "slot_id": peer_slot_id,
1576        "slot_token": existing_token,
1577    });
1578    crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1579
1580    // Build + sign pair_drop event (no nonce — open-mode handle pair).
1581    let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1582    let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1583    let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1584    let pk_b64 = our_card
1585        .get("verify_keys")
1586        .and_then(Value::as_object)
1587        .and_then(|m| m.values().next())
1588        .and_then(|v| v.get("key"))
1589        .and_then(Value::as_str)
1590        .ok_or("our card missing verify_keys[*].key")?;
1591    let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1592    let now = time::OffsetDateTime::now_utc()
1593        .format(&time::format_description::well_known::Rfc3339)
1594        .unwrap_or_default();
1595    let event = json!({
1596        "timestamp": now,
1597        "from": our_did,
1598        "to": peer_did,
1599        "type": "pair_drop",
1600        "kind": 1100u32,
1601        "body": {
1602            "card": our_card,
1603            "relay_url": our_relay,
1604            "slot_id": our_slot_id,
1605            "slot_token": our_slot_token,
1606        },
1607    });
1608    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
1609        .map_err(|e| format!("{e:#}"))?;
1610
1611    let client = crate::relay_client::RelayClient::new(&peer_relay);
1612    let resp = client
1613        .handle_intro(&parsed.nick, &signed)
1614        .map_err(|e| format!("{e:#}"))?;
1615    let event_id = signed
1616        .get("event_id")
1617        .and_then(Value::as_str)
1618        .unwrap_or("")
1619        .to_string();
1620    Ok(json!({
1621        "handle": handle,
1622        "paired_with": peer_did,
1623        "peer_handle": peer_handle,
1624        "event_id": event_id,
1625        "drop_response": resp,
1626        "status": "drop_sent",
1627    }))
1628}
1629
1630/// MCP `wire_accept` (v0.9+, formerly wire_pair_accept) — bilateral completion
1631/// of a pending-inbound pair request. The agent SHOULD have surfaced the
1632/// pending request to the operator before calling this; acceptance grants
1633/// peer authenticated write access to this agent's inbox.
1634fn tool_pair_accept(args: &Value) -> Result<Value, String> {
1635    let peer = args
1636        .get("peer")
1637        .and_then(Value::as_str)
1638        .ok_or("missing 'peer'")?;
1639    let nick = crate::agent_card::bare_handle(peer);
1640    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
1641        .map_err(|e| format!("{e:#}"))?
1642        .ok_or_else(|| {
1643            format!(
1644                "no pending pair request from {nick}. Call wire_pending to enumerate, \
1645                 or wire_add to send a fresh outbound pair request."
1646            )
1647        })?;
1648
1649    // Pin trust with VERIFIED — operator-equivalent consent gesture (the
1650    // agent is acting on the operator's instruction to accept).
1651    let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1652    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
1653    crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1654
1655    // Record peer's relay coords + slot_token from the stored drop.
1656    let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1657    relay_state["peers"][&pending.peer_handle] = json!({
1658        "relay_url": pending.peer_relay_url,
1659        "slot_id": pending.peer_slot_id,
1660        "slot_token": pending.peer_slot_token,
1661    });
1662    crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1663
1664    // Ship our slot_token via pair_drop_ack — Bug 2 fix: iterate the peer's
1665    // advertised endpoints in priority order, only fail if all are dead. The
1666    // pending record's `peer_endpoints` carries the full advertised list when
1667    // the pair_drop was written by a v0.5.17+ peer; fall back to a one-element
1668    // slice from the legacy triple for older records so we still hit the
1669    // failover helper with a valid input.
1670    let ack_endpoints: Vec<crate::endpoints::Endpoint> = if pending.peer_endpoints.is_empty() {
1671        vec![crate::endpoints::Endpoint::federation(
1672            pending.peer_relay_url.clone(),
1673            pending.peer_slot_id.clone(),
1674            pending.peer_slot_token.clone(),
1675        )]
1676    } else {
1677        pending.peer_endpoints.clone()
1678    };
1679    crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &ack_endpoints).map_err(|e| {
1680        format!(
1681            "pair_drop_ack send to {} (across {} endpoint(s)) failed: {e:#}",
1682            pending.peer_handle,
1683            ack_endpoints.len()
1684        )
1685    })?;
1686
1687    crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1688
1689    Ok(json!({
1690        "status": "bilateral_accepted",
1691        "peer_handle": pending.peer_handle,
1692        "peer_did": pending.peer_did,
1693        "peer_relay_url": pending.peer_relay_url,
1694        "via": "pending_inbound",
1695    }))
1696}
1697
1698/// MCP `wire_reject` (v0.9+, formerly wire_pair_reject) — delete a
1699/// pending-inbound record without pairing. Peer never receives our
1700/// slot_token. Idempotent.
1701fn tool_pair_reject(args: &Value) -> Result<Value, String> {
1702    let peer = args
1703        .get("peer")
1704        .and_then(Value::as_str)
1705        .ok_or("missing 'peer'")?;
1706    let nick = crate::agent_card::bare_handle(peer);
1707    let existed =
1708        crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1709    crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1710    Ok(json!({
1711        "peer": nick,
1712        "rejected": existed.is_some(),
1713        "had_pending": existed.is_some(),
1714    }))
1715}
1716
1717/// MCP `wire_pending` (v0.9+, formerly wire_pair_list_inbound) — enumerate
1718/// pending-inbound pair requests for operator review. Flat array sorted
1719/// oldest-first.
1720fn tool_pair_list_inbound() -> Result<Value, String> {
1721    let items =
1722        crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
1723    Ok(json!(items))
1724}
1725
1726fn tool_claim_handle(args: &Value) -> Result<Value, String> {
1727    let typed = args.get("nick").and_then(Value::as_str);
1728    let relay_override = args.get("relay_url").and_then(Value::as_str);
1729    let public_url = args.get("public_url").and_then(Value::as_str);
1730
1731    // Auto-init + ensure slot.
1732    let (_, our_relay, our_slot_id, our_slot_token) =
1733        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1734    let claim_relay = relay_override.unwrap_or(&our_relay);
1735    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1736
1737    // One-name rule (v0.13.1): the claimed handle is ALWAYS the DID-derived
1738    // persona, so the phonebook entry can never drift from the agent-card
1739    // handle. `nick` is optional + advisory — a value that differs is ignored.
1740    // See cmd_claim for the rationale (closes the claim-path "two names" hole).
1741    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
1742    let canonical = crate::agent_card::display_handle_from_did(did).to_string();
1743    let nick = if canonical.is_empty() {
1744        typed.unwrap_or_default().to_string()
1745    } else {
1746        canonical
1747    };
1748    let typed_nick_ignored = typed.map(|t| t != nick).unwrap_or(false);
1749
1750    let client = crate::relay_client::RelayClient::new(claim_relay);
1751    let resp = client
1752        .handle_claim(&nick, &our_slot_id, &our_slot_token, public_url, &card)
1753        .map_err(|e| format!("{e:#}"))?;
1754    Ok(json!({
1755        "nick": nick,
1756        "relay": claim_relay,
1757        "response": resp,
1758        "one_name": true,
1759        "typed_nick_ignored": typed_nick_ignored,
1760    }))
1761}
1762
1763fn tool_whois(args: &Value) -> Result<Value, String> {
1764    if let Some(handle) = args.get("handle").and_then(Value::as_str) {
1765        // v0.14.x: mirror the CLI's resolution order. Bare nicks (no `@`)
1766        // route through the local resolver first (pinned peers + local
1767        // sister sessions); federation handles fall through to
1768        // `parse_handle` + remote resolution. Previously the MCP
1769        // surface only accepted federation-shaped handles and rejected
1770        // bare nicks with `missing '@' separator`, breaking
1771        // agent-side discovery of paired-but-not-federated peers.
1772        // Mirrors `cli::cmd_whois_local` for the local arms; mirrors
1773        // `cli::cmd_whois` for the federation arm.
1774        if !handle.contains('@')
1775            && let Ok(target) = crate::cli::resolve_name_to_target(handle)
1776        {
1777            return Ok(dial_target_to_whois_json(&target));
1778        }
1779        let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1780        let relay_override = args.get("relay_url").and_then(Value::as_str);
1781        crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
1782    } else {
1783        // Self. v0.14.x: surface inline op claims so MCP whois stays in
1784        // parity with `wire whoami --json` / CLI self-whois (#114 + #115
1785        // shared the same helper).
1786        let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1787        let mut payload = serde_json::Map::new();
1788        payload.insert(
1789            "did".into(),
1790            card.get("did").cloned().unwrap_or(Value::Null),
1791        );
1792        payload.insert(
1793            "profile".into(),
1794            card.get("profile").cloned().unwrap_or(Value::Null),
1795        );
1796        for (k, v) in crate::cli::op_claims_from_card(&card) {
1797            payload.insert(k, v);
1798        }
1799        Ok(Value::Object(payload))
1800    }
1801}
1802
1803/// Convert a `cli::DialTarget` (the CLI's local-resolver hit) into the
1804/// JSON shape MCP whois callers expect. Mirrors the human-readable arms
1805/// of `cli::cmd_whois_local` but keyed for programmatic consumption.
1806/// Surfaces inline op claims from the peer's pinned card via the same
1807/// `op_claims_from_card` helper used everywhere else in v0.14.x.
1808fn dial_target_to_whois_json(target: &crate::cli::DialTarget) -> Value {
1809    use crate::cli::DialTarget;
1810    match target {
1811        DialTarget::PinnedPeer {
1812            handle,
1813            did,
1814            nickname,
1815            emoji,
1816            tier,
1817        } => {
1818            let op_claims = crate::config::read_trust()
1819                .ok()
1820                .and_then(|t| {
1821                    t.get("agents")
1822                        .and_then(Value::as_object)
1823                        .and_then(|m| m.get(handle))
1824                        .and_then(|a| a.get("card").cloned())
1825                })
1826                .map(|c| crate::cli::op_claims_from_card(&c))
1827                .unwrap_or_default();
1828            let mut payload = serde_json::Map::new();
1829            payload.insert("kind".into(), json!("pinned_peer"));
1830            payload.insert("handle".into(), json!(handle));
1831            payload.insert("did".into(), json!(did));
1832            payload.insert("nickname".into(), json!(nickname));
1833            payload.insert("emoji".into(), json!(emoji));
1834            payload.insert("tier".into(), json!(tier));
1835            for (k, v) in op_claims {
1836                payload.insert(k, v);
1837            }
1838            Value::Object(payload)
1839        }
1840        DialTarget::LocalSister {
1841            session_name,
1842            handle,
1843            did,
1844            nickname,
1845            emoji,
1846        } => json!({
1847            "kind": "local_sister",
1848            "session_name": session_name,
1849            "handle": handle,
1850            "did": did,
1851            "nickname": nickname,
1852            "emoji": emoji,
1853        }),
1854    }
1855}
1856
1857fn tool_profile_set(args: &Value) -> Result<Value, String> {
1858    let field = args
1859        .get("field")
1860        .and_then(Value::as_str)
1861        .ok_or("missing 'field'")?;
1862    let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
1863    // If value is a string that itself parses as JSON (e.g. "[\"rust\"]"),
1864    // unwrap it. Otherwise pass as-is. Lets agents send either typed values
1865    // or stringified JSON.
1866    let value = if let Some(s) = raw_value.as_str() {
1867        serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
1868    } else {
1869        raw_value
1870    };
1871    let new_profile =
1872        crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
1873    Ok(json!({
1874        "field": field,
1875        "profile": new_profile,
1876    }))
1877}
1878
1879fn tool_profile_get() -> Result<Value, String> {
1880    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1881    Ok(json!({
1882        "did": card.get("did").cloned().unwrap_or(Value::Null),
1883        "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1884    }))
1885}
1886
1887// ---------- helpers ----------
1888
1889fn parse_kind(s: &str) -> u32 {
1890    if let Ok(n) = s.parse::<u32>() {
1891        return n;
1892    }
1893    for (id, name) in crate::signing::kinds() {
1894        if *name == s {
1895            return *id;
1896        }
1897    }
1898    1
1899}
1900
1901fn error_response(id: &Value, code: i32, message: &str) -> Value {
1902    json!({
1903        "jsonrpc": "2.0",
1904        "id": id,
1905        "error": {"code": code, "message": message}
1906    })
1907}
1908
1909#[cfg(test)]
1910mod tests {
1911    use super::*;
1912
1913    #[test]
1914    fn unknown_method_returns_jsonrpc_error() {
1915        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
1916        let resp = handle_request(&req, &McpState::default());
1917        assert_eq!(resp["error"]["code"], -32601);
1918    }
1919
1920    #[test]
1921    fn initialize_advertises_tools_capability() {
1922        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
1923        let resp = handle_request(&req, &McpState::default());
1924        assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
1925        assert!(resp["result"]["capabilities"]["tools"].is_object());
1926        assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
1927    }
1928
1929    #[test]
1930    fn tools_list_includes_pairing_and_messaging() {
1931        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
1932        let resp = handle_request(&req, &McpState::default());
1933        let names: Vec<&str> = resp["result"]["tools"]
1934            .as_array()
1935            .unwrap()
1936            .iter()
1937            .filter_map(|t| t["name"].as_str())
1938            .collect();
1939        for required in [
1940            "wire_whoami",
1941            "wire_peers",
1942            "wire_send",
1943            "wire_tail",
1944            "wire_verify",
1945            "wire_init",
1946            "wire_dial",
1947        ] {
1948            assert!(
1949                names.contains(&required),
1950                "missing required tool {required}"
1951            );
1952        }
1953        // The SAS code-phrase pair tools were removed (RFC-005 follow-on) —
1954        // they must NOT be advertised.
1955        for removed in [
1956            "wire_pair_initiate",
1957            "wire_pair_join",
1958            "wire_pair_check",
1959            "wire_pair_confirm",
1960            "wire_pair_initiate_detached",
1961            "wire_pair_join_detached",
1962            "wire_pair_list_pending",
1963            "wire_pair_confirm_detached",
1964            "wire_pair_cancel_pending",
1965        ] {
1966            assert!(
1967                !names.contains(&removed),
1968                "SAS pair tool {removed} must not be advertised after removal"
1969            );
1970        }
1971        // wire_join (the old direct alias for the SAS pair-join) is explicitly
1972        // NOT in the catalog. Calling it returns a deprecation pointing to
1973        // wire_dial (test below covers this).
1974        assert!(
1975            !names.contains(&"wire_join"),
1976            "wire_join must not be advertised — SAS pairing removed"
1977        );
1978    }
1979
1980    #[test]
1981    fn legacy_wire_join_call_returns_helpful_error() {
1982        let req = json!({
1983            "jsonrpc": "2.0",
1984            "id": 1,
1985            "method": "tools/call",
1986            "params": {"name": "wire_join", "arguments": {}}
1987        });
1988        let resp = handle_request(&req, &McpState::default());
1989        assert_eq!(resp["result"]["isError"], true);
1990        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
1991        assert!(
1992            text.contains("wire_dial"),
1993            "expected redirect to wire_dial, got: {text}"
1994        );
1995    }
1996
1997    #[test]
1998    fn tools_list_canonical_present_deprecated_absent() {
1999        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2000        let resp = handle_request(&req, &McpState::default());
2001        let names: Vec<&str> = resp["result"]["tools"]
2002            .as_array()
2003            .unwrap()
2004            .iter()
2005            .filter_map(|t| t["name"].as_str())
2006            .collect();
2007
2008        // Canonical names must be present.
2009        for required in ["wire_accept", "wire_reject", "wire_pending"] {
2010            assert!(
2011                names.contains(&required),
2012                "canonical tool {required} missing from tools/list"
2013            );
2014        }
2015
2016        // Deprecated aliases must NOT be advertised (RFC-005 Phase 2).
2017        for removed in [
2018            "wire_pair_accept",
2019            "wire_pair_reject",
2020            "wire_pair_list_inbound",
2021        ] {
2022            assert!(
2023                !names.contains(&removed),
2024                "deprecated tool {removed} must not appear in tools/list"
2025            );
2026        }
2027    }
2028
2029    #[test]
2030    fn deprecated_pair_accept_call_returns_helpful_error() {
2031        for (old_name, canonical) in [
2032            ("wire_pair_accept", "wire_accept"),
2033            ("wire_pair_reject", "wire_reject"),
2034            ("wire_pair_list_inbound", "wire_pending"),
2035        ] {
2036            let req = json!({
2037                "jsonrpc": "2.0",
2038                "id": 1,
2039                "method": "tools/call",
2040                "params": {"name": old_name, "arguments": {}}
2041            });
2042            let resp = handle_request(&req, &McpState::default());
2043            assert_eq!(
2044                resp["result"]["isError"], true,
2045                "calling {old_name} should return isError:true"
2046            );
2047            let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2048            assert!(
2049                text.contains(canonical),
2050                "error for {old_name} should mention {canonical}, got: {text}"
2051            );
2052        }
2053    }
2054
2055    #[test]
2056    fn initialize_advertises_resources_capability() {
2057        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
2058        let resp = handle_request(&req, &McpState::default());
2059        let caps = &resp["result"]["capabilities"];
2060        assert!(
2061            caps["resources"].is_object(),
2062            "resources capability must be present, got {resp}"
2063        );
2064        assert_eq!(
2065            caps["resources"]["subscribe"], true,
2066            "subscribe shipped in v0.2.1"
2067        );
2068    }
2069
2070    #[test]
2071    fn resources_read_with_bad_uri_errors() {
2072        let req = json!({
2073            "jsonrpc": "2.0",
2074            "id": 1,
2075            "method": "resources/read",
2076            "params": {"uri": "http://example.com/not-a-wire-uri"}
2077        });
2078        let resp = handle_request(&req, &McpState::default());
2079        assert!(resp.get("error").is_some(), "expected error, got {resp}");
2080    }
2081
2082    #[test]
2083    fn parse_inbox_uri_handles_variants() {
2084        assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
2085        assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
2086        assert!(
2087            parse_inbox_uri("wire://inbox/")
2088                .unwrap()
2089                .starts_with("__invalid__"),
2090            "empty peer must be invalid"
2091        );
2092        assert!(
2093            parse_inbox_uri("http://other")
2094                .unwrap()
2095                .starts_with("__invalid__"),
2096            "non-wire scheme must be invalid"
2097        );
2098    }
2099
2100    #[test]
2101    fn ping_returns_empty_result() {
2102        let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2103        let resp = handle_request(&req, &McpState::default());
2104        assert_eq!(resp["id"], 7);
2105        assert!(resp["result"].is_object());
2106    }
2107
2108    #[test]
2109    fn notification_returns_null_no_reply() {
2110        let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2111        let resp = handle_request(&req, &McpState::default());
2112        assert_eq!(resp, Value::Null);
2113    }
2114
2115    /// v0.6.1 regression: `detect_session_wire_home` must return the
2116    /// session's home dir when the cwd is in the registry AND the
2117    /// session dir exists on disk. The original v0.6.1 shipped with
2118    /// only an eprintln "verification" — this test asserts the
2119    /// observable return value so the env-set-but-not-consumed class
2120    /// of bug fails loudly.
2121    #[test]
2122    fn detect_session_wire_home_resolves_registered_cwd() {
2123        crate::config::test_support::with_temp_home(|| {
2124            // Set up sessions/registry.json + sessions/test-alpha/ under
2125            // the temp WIRE_HOME so session::read_registry +
2126            // session::session_dir resolve through it.
2127            let wire_home = std::env::var("WIRE_HOME").unwrap();
2128            let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2129            let session_home = sessions_root.join("test-alpha");
2130            std::fs::create_dir_all(&session_home).unwrap();
2131            let fake_cwd = "/tmp/fake-project-cwd-abc123";
2132            let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2133            std::fs::write(
2134                sessions_root.join("registry.json"),
2135                serde_json::to_vec_pretty(&registry).unwrap(),
2136            )
2137            .unwrap();
2138
2139            // Hit happy path.
2140            let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2141            assert_eq!(
2142                got.as_deref(),
2143                Some(session_home.as_path()),
2144                "registered cwd must resolve to session_home"
2145            );
2146
2147            // Unregistered cwd → None.
2148            let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2149                "/tmp/cwd-not-in-registry-xyz789",
2150            ));
2151            assert!(nope.is_none(), "unregistered cwd must return None");
2152
2153            // Registered cwd but session dir missing → None (defensive:
2154            // stale registry entry pointing at a deleted session).
2155            let stale_cwd = "/tmp/stale-session-cwd";
2156            let stale_registry =
2157                json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2158            std::fs::write(
2159                sessions_root.join("registry.json"),
2160                serde_json::to_vec_pretty(&stale_registry).unwrap(),
2161            )
2162            .unwrap();
2163            let stale_got =
2164                crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2165            assert!(
2166                stale_got.is_none(),
2167                "registered cwd whose session dir is missing must return None"
2168            );
2169        });
2170    }
2171
2172    // v0.14.x: shape tests for `dial_target_to_whois_json`. The MCP whois
2173    // bare-nick fix routes through `cli::resolve_name_to_target` (returns
2174    // a `DialTarget`) and reshapes it for JSON-RPC consumption. These
2175    // tests pin the response shape so a future refactor of either side
2176    // (resolver or wire shape) catches the contract drift.
2177
2178    #[test]
2179    fn dial_target_to_whois_json_pinned_peer_shape() {
2180        let target = crate::cli::DialTarget::PinnedPeer {
2181            handle: "slate-lotus".into(),
2182            did: "did:wire:slate-lotus-88232017".into(),
2183            nickname: Some("slate-lotus".into()),
2184            emoji: Some("🪴".into()),
2185            tier: "VERIFIED".into(),
2186        };
2187        crate::config::test_support::with_temp_home(|| {
2188            let out = dial_target_to_whois_json(&target);
2189            assert_eq!(out.get("kind").and_then(Value::as_str), Some("pinned_peer"));
2190            assert_eq!(
2191                out.get("handle").and_then(Value::as_str),
2192                Some("slate-lotus")
2193            );
2194            assert_eq!(out.get("tier").and_then(Value::as_str), Some("VERIFIED"));
2195            // op claims are absent when trust.json has no row for this
2196            // peer (the helper falls through to an empty map). No
2197            // spurious `null` op_did keys.
2198            assert!(out.get("op_did").is_none());
2199        });
2200    }
2201
2202    #[test]
2203    fn dial_target_to_whois_json_local_sister_shape() {
2204        let target = crate::cli::DialTarget::LocalSister {
2205            session_name: "vesper-valley".into(),
2206            handle: "vesper-valley".into(),
2207            did: Some("did:wire:vesper-valley-deadbeef".into()),
2208            nickname: Some("vesper-valley".into()),
2209            emoji: Some("🦌".into()),
2210        };
2211        let out = dial_target_to_whois_json(&target);
2212        assert_eq!(
2213            out.get("kind").and_then(Value::as_str),
2214            Some("local_sister")
2215        );
2216        assert_eq!(
2217            out.get("session_name").and_then(Value::as_str),
2218            Some("vesper-valley")
2219        );
2220        assert_eq!(
2221            out.get("did").and_then(Value::as_str),
2222            Some("did:wire:vesper-valley-deadbeef")
2223        );
2224        // LocalSister carries no card → no op_claims path. Spot-check
2225        // no leakage from the PinnedPeer arm.
2226        assert!(out.get("tier").is_none());
2227        assert!(out.get("op_did").is_none());
2228    }
2229}