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, but the user types the SAS digits back)**
18//!   - `wire_init`           — idempotent identity creation; same handle = no-op,
19//!     different handle = error (cannot re-key silently)
20//!   - `wire_pair_initiate`  — host opens a pair-slot; returns code phrase
21//!     agent shows to user out-of-band
22//!   - `wire_pair_join`      — guest accepts a code phrase; both sides reach SAS-ready
23//!   - `wire_pair_check`     — poll a pending session_id (used when initiate
24//!     returned before peer was on the line)
25//!   - `wire_pair_confirm`   — user types the 6 SAS digits back; mismatch aborts
26//!
27//! ## Why pairing is now agent-callable (T10 update)
28//!
29//! v0.1 originally refused `wire_init` / `wire_pair_*` over MCP entirely on
30//! the theory that a fully-autonomous agent would skip the SAS confirmation.
31//! The new design preserves the human gate by requiring the user to type the
32//! 6-digit SAS back into chat — `wire_pair_confirm(session_id, typed_digits)`
33//! compares against the cached SAS server-side, mismatch aborts the session.
34//!
35//! Defense-in-depth:
36//!   1. SAS digits are returned as tool output the agent renders to the user.
37//!      A malicious agent that fabricates digits in chat fails because the
38//!      user's peer reads their independently-derived SAS over a side channel
39//!      (voice / unrelated text channel). Mismatch on type-back aborts.
40//!   2. The host runtime (Claude Desktop, etc.) is responsible for surfacing
41//!      the type-back step to the actual user, not auto-filling. Wire cannot
42//!      enforce this — see THREAT_MODEL.md T14.
43//!
44//! Concurrent multi-peer: each pair flow has its own session_id (the relay
45//! pair_id) and its own `Mutex<PairSessionState>` in the in-memory store.
46//! Pairing with N peers in parallel is fully supported.
47
48use anyhow::Result;
49use serde_json::{Value, json};
50use std::collections::HashSet;
51use std::io::{BufRead, BufReader, Write};
52use std::sync::{Arc, Mutex};
53
54/// Shared MCP-session state. Today: subscribed resource URIs + a writer
55/// channel for unsolicited notifications (push). Future per-session cursors,
56/// etc. go here.
57#[derive(Clone, Default)]
58pub struct McpState {
59    /// Resource URIs the client has subscribed to. Wildcard support is
60    /// intentionally NOT done — clients subscribe to specific URIs and
61    /// receive `notifications/resources/updated` only for those URIs.
62    pub subscribed: Arc<Mutex<HashSet<String>>>,
63    /// Writer-channel sender for emitting unsolicited notifications
64    /// (notifications/resources/list_changed, etc.). Populated by `run()`
65    /// before tools are dispatched; None in unit tests.
66    pub notif_tx: Arc<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
67}
68
69const PROTOCOL_VERSION: &str = "2025-06-18";
70const SERVER_NAME: &str = "wire";
71const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
72
73/// Run the MCP server until stdin closes.
74///
75/// Threading model (Goal 2.1):
76///
77/// - **Main thread**: reads stdin line-by-line, parses JSON-RPC, calls
78///   `handle_request` to compute a response, hands it to the writer via the
79///   mpsc channel.
80/// - **Writer thread**: single owner of stdout. Drains responses + push
81///   notifications from the channel, writes each as one line + flush. Single
82///   writer = no interleaving between responses and notifications.
83/// - **Watcher thread**: holds an `InboxWatcher::from_head` (starts at EOF —
84///   each MCP session only sees fresh events). Polls every 2s. For each new
85///   inbox event, checks the shared subscription set; if any matching
86///   `wire://inbox/<peer>` or `wire://inbox/all` URI is subscribed, pushes
87///   a `notifications/resources/updated` message into the channel.
88///
89/// v0.6.7: `detect_session_wire_home` moved to
90/// `session::detect_session_wire_home` (shared with the CLI auto-detect at
91/// `cli::run` entry). The mcp-only wrapper was removed; the regression test
92/// now calls the session-module version directly.
93pub fn run() -> Result<()> {
94    use std::sync::atomic::{AtomicBool, Ordering};
95    use std::sync::mpsc;
96    use std::time::{Duration, Instant};
97
98    // v0.6.1: auto-detect WIRE_HOME from cwd. If the operator already
99    // set it (explicit override via `.mcp.json env.WIRE_HOME`), respect
100    // that. Else: if the cwd maps to a `wire session` entry in the
101    // registry, adopt that session's WIRE_HOME for this MCP process so
102    // every subsequent tool call routes to the right inbox / outbox /
103    // identity.
104    //
105    // v0.6.7: identical helper now also runs at CLI entry (cli::run),
106    // so `wire whoami` / `wire monitor` from a session cwd resolve to
107    // the same identity the MCP server uses. Before v0.6.7 the CLI
108    // silently fell back to the default WIRE_HOME, leaving operators
109    // unable to tell which identity their monitor was tailing.
110    crate::session::maybe_adopt_session_wire_home("mcp");
111
112    // v0.7.0-alpha.2: if auto-detect found no session for this cwd
113    // (including via parent-walk), create one inline so every Claude
114    // tab in a fresh project gets its own wire identity rather than
115    // silently sharing the machine-wide default. Opt out via
116    // `WIRE_AUTO_INIT=0`.
117    crate::cli::maybe_auto_init_cwd_session("mcp");
118
119    // v0.13: a session-keyed WIRE_HOME (sessions/by-key/<hash>) starts empty.
120    // Bootstrap its identity on first MCP start — one-name init + federation
121    // slot + phonebook claim — so each Claude session is its own reachable,
122    // claimed identity. One-time per home (gated on is_initialized);
123    // best-effort (offline → init-only, no claim). Skipped under
124    // WIRE_MCP_SKIP_AUTO_UP (tests + manual-identity operators).
125    ensure_session_bootstrapped();
126
127    // v0.6.10: surface multi-agent identity collisions explicitly.
128    // Two Claudes (or any MCP-host pair) launched in the same cwd
129    // auto-detect into the same wire session and silently share an
130    // inbox cursor. v0.6.7 made this invisible by design ("just adopt
131    // the cwd's session"); operators hit it as "they look identical"
132    // and burn hours debugging. The warning gives them a clear
133    // remediation path the first time they see it.
134    crate::session::warn_on_identity_collision(std::process::id(), "mcp");
135
136    let state = McpState::default();
137    let shutdown = Arc::new(AtomicBool::new(false));
138
139    let (tx, rx) = mpsc::channel::<String>();
140
141    // Expose the tx clone via state so tool handlers can push unsolicited
142    // notifications (notifications/resources/list_changed after a pair pin).
143    if let Ok(mut g) = state.notif_tx.lock() {
144        *g = Some(tx.clone());
145    }
146
147    // Writer thread — single owner of stdout. Exits when all senders drop.
148    let writer_handle = std::thread::spawn(move || {
149        let stdout = std::io::stdout();
150        let mut w = stdout.lock();
151        while let Ok(line) = rx.recv() {
152            if writeln!(w, "{line}").is_err() {
153                break;
154            }
155            if w.flush().is_err() {
156                break;
157            }
158        }
159    });
160
161    // Watcher thread — polls inbox every 2s and emits
162    // notifications/resources/updated on grow. Observes `shutdown` so we
163    // can exit cleanly on stdin EOF (otherwise its tx_w clone keeps the
164    // writer thread blocked on rx.recv forever).
165    let subs_w = state.subscribed.clone();
166    let tx_w = tx.clone();
167    let shutdown_w = shutdown.clone();
168    let watcher_handle = std::thread::spawn(move || {
169        let mut watcher = match crate::inbox_watch::InboxWatcher::from_head() {
170            Ok(w) => w,
171            Err(_) => return,
172        };
173        // Per-code fingerprint (status string) of the last seen pending-pair
174        // snapshot. Used to detect transitions so we emit at most one
175        // notification per actual change (not per poll).
176        let mut prev_pending: std::collections::HashMap<String, String> =
177            std::collections::HashMap::new();
178        let poll_interval = Duration::from_secs(2);
179        let mut next_poll = Instant::now() + poll_interval;
180        loop {
181            if shutdown_w.load(Ordering::SeqCst) {
182                return;
183            }
184            std::thread::sleep(Duration::from_millis(100));
185            if Instant::now() < next_poll {
186                continue;
187            }
188            next_poll = Instant::now() + poll_interval;
189            let subs_snapshot = match subs_w.lock() {
190                Ok(g) => g.clone(),
191                Err(_) => return,
192            };
193
194            let mut affected: HashSet<String> = HashSet::new();
195
196            // ---- inbox events ----
197            if !subs_snapshot.is_empty()
198                && let Ok(events) = watcher.poll()
199            {
200                for ev in &events {
201                    if subs_snapshot.contains("wire://inbox/all") {
202                        affected.insert("wire://inbox/all".to_string());
203                    }
204                    let peer_uri = format!("wire://inbox/{}", ev.peer);
205                    if subs_snapshot.contains(&peer_uri) {
206                        affected.insert(peer_uri);
207                    }
208                }
209            }
210
211            // ---- pending-pair state changes ----
212            // Always poll (cheap dir read); only emit if subscribed.
213            if let Ok(items) = crate::pending_pair::list_pending() {
214                let mut cur: std::collections::HashMap<String, String> =
215                    std::collections::HashMap::new();
216                for p in &items {
217                    cur.insert(p.code.clone(), p.status.clone());
218                }
219                // Detect any change vs. prev_pending: new code, removed code,
220                // or status flip on existing code.
221                let changed = cur.len() != prev_pending.len()
222                    || cur.iter().any(|(k, v)| prev_pending.get(k) != Some(v))
223                    || prev_pending.keys().any(|k| !cur.contains_key(k));
224                if changed && subs_snapshot.contains("wire://pending-pair/all") {
225                    affected.insert("wire://pending-pair/all".to_string());
226                }
227                prev_pending = cur;
228            }
229
230            for uri in affected {
231                let notif = json!({
232                    "jsonrpc": "2.0",
233                    "method": "notifications/resources/updated",
234                    "params": {"uri": uri}
235                });
236                if tx_w.send(notif.to_string()).is_err() {
237                    return;
238                }
239            }
240        }
241    });
242
243    let stdin = std::io::stdin();
244    let mut reader = BufReader::new(stdin.lock());
245    let mut line = String::new();
246    loop {
247        line.clear();
248        let n = reader.read_line(&mut line)?;
249        if n == 0 {
250            // EOF — signal watcher to exit; clear the notif_tx Sender clone
251            // that state holds (otherwise writer's rx.recv() never sees
252            // all-senders-dropped); drop main tx; wait for worker threads.
253            shutdown.store(true, Ordering::SeqCst);
254            if let Ok(mut g) = state.notif_tx.lock() {
255                *g = None;
256            }
257            drop(tx);
258            let _ = watcher_handle.join();
259            let _ = writer_handle.join();
260            return Ok(());
261        }
262        let trimmed = line.trim();
263        if trimmed.is_empty() {
264            continue;
265        }
266        let request: Value = match serde_json::from_str(trimmed) {
267            Ok(v) => v,
268            Err(e) => {
269                let err = error_response(&Value::Null, -32700, &format!("parse error: {e}"));
270                let _ = tx.send(err.to_string());
271                continue;
272            }
273        };
274        let response = handle_request(&request, &state);
275        // Notifications (no `id`) get no response.
276        if response.get("id").is_some() || response.get("error").is_some() {
277            let _ = tx.send(response.to_string());
278        }
279    }
280}
281
282fn handle_request(req: &Value, state: &McpState) -> Value {
283    let id = req.get("id").cloned().unwrap_or(Value::Null);
284    let method = match req.get("method").and_then(Value::as_str) {
285        Some(m) => m,
286        None => return error_response(&id, -32600, "missing method"),
287    };
288    match method {
289        "initialize" => handle_initialize(&id),
290        "notifications/initialized" => Value::Null, // notification — no reply
291        "tools/list" => handle_tools_list(&id),
292        "tools/call" => handle_tools_call(&id, req.get("params").unwrap_or(&Value::Null), state),
293        "resources/list" => handle_resources_list(&id),
294        "resources/read" => handle_resources_read(&id, req.get("params").unwrap_or(&Value::Null)),
295        "resources/subscribe" => {
296            handle_resources_subscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
297        }
298        "resources/unsubscribe" => {
299            handle_resources_unsubscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
300        }
301        "ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}),
302        other => error_response(&id, -32601, &format!("method not found: {other}")),
303    }
304}
305
306// ---------- resources (Goal 2) ----------
307//
308// MCP resources expose semi-static state for agents that want a "read this
309// when relevant" surface instead of polling tools. v0.2 ships read-only;
310// subscribe (push-notify on inbox grow) is v0.2.1 — requires a background
311// watcher thread + async stdout writer.
312//
313// Resource URI scheme:
314//   wire://inbox/<peer>    last 50 verified events for that pinned peer
315//   wire://inbox/all       last 50 events across all peers, newest first
316
317fn handle_resources_list(id: &Value) -> Value {
318    let mut resources = vec![
319        json!({
320            "uri": "wire://inbox/all",
321            "name": "wire inbox (all peers)",
322            "description": "Most recent verified events from all pinned peers, JSONL.",
323            "mimeType": "application/x-ndjson"
324        }),
325        json!({
326            "uri": "wire://pending-pair/all",
327            "name": "wire pending pair sessions",
328            "description": "All detached pair-host/pair-join sessions the local daemon is driving. Subscribe to receive notifications/resources/updated when status changes (notably polling → sas_ready: the agent should then surface the SAS digits to the user and call wire_pair_confirm with the typed-back digits).",
329            "mimeType": "application/json"
330        }),
331    ];
332
333    if let Ok(trust) = crate::config::read_trust() {
334        let agents = trust
335            .get("agents")
336            .and_then(Value::as_object)
337            .cloned()
338            .unwrap_or_default();
339        let self_did = crate::config::read_agent_card()
340            .ok()
341            .and_then(|c| c.get("did").and_then(Value::as_str).map(str::to_string));
342        for (handle, agent) in agents.iter() {
343            let did = agent
344                .get("did")
345                .and_then(Value::as_str)
346                .unwrap_or("")
347                .to_string();
348            if Some(did.as_str()) == self_did.as_deref() {
349                continue;
350            }
351            resources.push(json!({
352                "uri": format!("wire://inbox/{handle}"),
353                "name": format!("inbox from {handle}"),
354                "description": format!("Recent verified events from did:wire:{handle}."),
355                "mimeType": "application/x-ndjson"
356            }));
357        }
358    }
359
360    json!({
361        "jsonrpc": "2.0",
362        "id": id,
363        "result": {
364            "resources": resources
365        }
366    })
367}
368
369fn handle_resources_subscribe(id: &Value, params: &Value, state: &McpState) -> Value {
370    let uri = match params.get("uri").and_then(Value::as_str) {
371        Some(u) => u.to_string(),
372        None => return error_response(id, -32602, "missing 'uri'"),
373    };
374    // Validate the URI shape. Accept wire://inbox/<peer>, wire://inbox/all,
375    // wire://pending-pair/all. Anything else is rejected so we don't pile up
376    // dead subscriptions.
377    let inbox_peer = parse_inbox_uri(&uri);
378    let is_pending = uri == "wire://pending-pair/all";
379    if let Some(ref p) = inbox_peer
380        && p.starts_with("__invalid__")
381        && !is_pending
382    {
383        return error_response(
384            id,
385            -32602,
386            "subscribe URI must be wire://inbox/<peer>, wire://inbox/all, or wire://pending-pair/all",
387        );
388    }
389    if let Ok(mut g) = state.subscribed.lock() {
390        g.insert(uri);
391    }
392    json!({"jsonrpc": "2.0", "id": id, "result": {}})
393}
394
395fn handle_resources_unsubscribe(id: &Value, params: &Value, state: &McpState) -> Value {
396    let uri = match params.get("uri").and_then(Value::as_str) {
397        Some(u) => u.to_string(),
398        None => return error_response(id, -32602, "missing 'uri'"),
399    };
400    if let Ok(mut g) = state.subscribed.lock() {
401        g.remove(&uri);
402    }
403    json!({"jsonrpc": "2.0", "id": id, "result": {}})
404}
405
406fn handle_resources_read(id: &Value, params: &Value) -> Value {
407    let uri = match params.get("uri").and_then(Value::as_str) {
408        Some(u) => u,
409        None => return error_response(id, -32602, "missing 'uri'"),
410    };
411    // pending-pair takes priority over inbox parsing.
412    if uri == "wire://pending-pair/all" {
413        return match crate::pending_pair::list_pending() {
414            Ok(items) => {
415                let body = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string());
416                json!({
417                    "jsonrpc": "2.0",
418                    "id": id,
419                    "result": {
420                        "contents": [{
421                            "uri": uri,
422                            "mimeType": "application/json",
423                            "text": body,
424                        }]
425                    }
426                })
427            }
428            Err(e) => error_response(id, -32603, &e.to_string()),
429        };
430    }
431    let peer_opt = parse_inbox_uri(uri);
432    match read_inbox_resource(peer_opt) {
433        Ok(payload) => json!({
434            "jsonrpc": "2.0",
435            "id": id,
436            "result": {
437                "contents": [{
438                    "uri": uri,
439                    "mimeType": "application/x-ndjson",
440                    "text": payload,
441                }]
442            }
443        }),
444        Err(e) => error_response(id, -32603, &e.to_string()),
445    }
446}
447
448/// Parse `wire://inbox/<peer>` → Some(peer). `wire://inbox/all` → None.
449/// Anything else → returns a marker that triggers "unknown URI" on read.
450fn parse_inbox_uri(uri: &str) -> Option<String> {
451    if let Some(rest) = uri.strip_prefix("wire://inbox/") {
452        if rest == "all" {
453            return None;
454        }
455        if !rest.is_empty() {
456            return Some(rest.to_string());
457        }
458    }
459    Some(format!("__invalid__{uri}"))
460}
461
462fn read_inbox_resource(peer_opt: Option<String>) -> Result<String, String> {
463    const LIMIT: usize = 50;
464    // Validate URI shape FIRST — an invalid URI is an error regardless of
465    // whether the inbox dir exists yet.
466    if let Some(ref p) = peer_opt
467        && p.starts_with("__invalid__")
468    {
469        return Err(
470            "unknown resource URI (must be wire://inbox/<peer> or wire://inbox/all)".into(),
471        );
472    }
473    let inbox = crate::config::inbox_dir().map_err(|e| e.to_string())?;
474    if !inbox.exists() {
475        return Ok(String::new());
476    }
477    let trust = crate::config::read_trust().map_err(|e| e.to_string())?;
478
479    let paths: Vec<std::path::PathBuf> = match peer_opt {
480        Some(p) => {
481            let path = inbox.join(format!("{p}.jsonl"));
482            if !path.exists() {
483                return Ok(String::new());
484            }
485            vec![path]
486        }
487        None => std::fs::read_dir(&inbox)
488            .map_err(|e| e.to_string())?
489            .flatten()
490            .map(|e| e.path())
491            .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
492            .collect(),
493    };
494
495    let mut events: Vec<(String, bool, Value)> = Vec::new();
496    for path in paths {
497        let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
498        let peer = path
499            .file_stem()
500            .and_then(|s| s.to_str())
501            .unwrap_or("")
502            .to_string();
503        for line in body.lines() {
504            let event: Value = match serde_json::from_str(line) {
505                Ok(v) => v,
506                Err(_) => continue,
507            };
508            let verified = crate::signing::verify_message_v31(&event, &trust).is_ok();
509            events.push((peer.clone(), verified, event));
510        }
511    }
512    // Newest last (JSONL append order is chronological); take tail LIMIT.
513    let take_from = events.len().saturating_sub(LIMIT);
514    let tail = &events[take_from..];
515
516    let mut out = String::new();
517    for (_peer, verified, mut event) in tail.iter().cloned() {
518        if let Some(obj) = event.as_object_mut() {
519            obj.insert("verified".into(), json!(verified));
520        }
521        out.push_str(&serde_json::to_string(&event).map_err(|e| e.to_string())?);
522        out.push('\n');
523    }
524    Ok(out)
525}
526
527fn handle_initialize(id: &Value) -> Value {
528    json!({
529        "jsonrpc": "2.0",
530        "id": id,
531        "result": {
532            "protocolVersion": PROTOCOL_VERSION,
533            "capabilities": {
534                "tools": {"listChanged": false},
535                "resources": {
536                    "listChanged": false,
537                    // Goal 2.1 (v0.2.1): subscribe shipped. A background watcher
538                    // thread polls the inbox every 2s and pushes
539                    // notifications/resources/updated via a writer-thread channel
540                    // for any subscribed URI.
541                    "subscribe": true
542                }
543            },
544            "serverInfo": {
545                "name": SERVER_NAME,
546                "version": SERVER_VERSION,
547            },
548            "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. Legacy MCP tools (wire_pair_accept / wire_pair_reject / wire_pair_list_inbound, wire_pair_initiate/join/confirm) still callable but DEPRECATED — prefer canonical. See docs/AGENT_INTEGRATION.md for the full monitor recipe and THREAT_MODEL.md (T10/T14)."
549        }
550    })
551}
552
553fn handle_tools_list(id: &Value) -> Value {
554    json!({
555        "jsonrpc": "2.0",
556        "id": id,
557        "result": {
558            "tools": tool_defs(),
559        }
560    })
561}
562
563fn tool_defs() -> Vec<Value> {
564    vec![
565        json!({
566            "name": "wire_whoami",
567            "description": "Return this agent's DID, fingerprint, key_id, public key, and capabilities. Read-only.",
568            "inputSchema": {"type": "object", "properties": {}, "required": []}
569        }),
570        json!({
571            "name": "wire_peers",
572            "description": "List pinned peers with their tier (UNTRUSTED/VERIFIED/ATTESTED) and advertised capabilities. Read-only.",
573            "inputSchema": {"type": "object", "properties": {}, "required": []}
574        }),
575        json!({
576            "name": "wire_status",
577            "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.",
578            "inputSchema": {"type": "object", "properties": {}, "required": []}
579        }),
580        json!({
581            "name": "wire_send",
582            "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.",
583            "inputSchema": {
584                "type": "object",
585                "properties": {
586                    "peer": {"type": "string", "description": "Peer handle (without did:wire: prefix). Must be a pinned peer; check wire_peers first."},
587                    "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."},
588                    "body": {"type": "string", "description": "Event body. Plain text becomes a JSON string; valid JSON is parsed and embedded structurally."},
589                    "time_sensitive_until": {"type": "string", "description": "Optional advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp."}
590                },
591                "required": ["peer", "kind", "body"]
592            }
593        }),
594        json!({
595            "name": "wire_pull",
596            "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.",
597            "inputSchema": {"type": "object", "properties": {}, "required": []}
598        }),
599        json!({
600            "name": "wire_tail",
601            "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).",
602            "inputSchema": {
603                "type": "object",
604                "properties": {
605                    "peer": {"type": "string", "description": "Optional peer handle to filter inbox by."},
606                    "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Max events to return."},
607                    "oldest": {"type": "boolean", "default": false, "description": "Return the FIRST `limit` events (oldest-N) instead of the default last-N (newest-N)."}
608                },
609                "required": []
610            }
611        }),
612        json!({
613            "name": "wire_verify",
614            "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).",
615            "inputSchema": {
616                "type": "object",
617                "properties": {
618                    "event": {"type": "string", "description": "JSON-encoded signed event."}
619                },
620                "required": ["event"]
621            }
622        }),
623        json!({
624            "name": "wire_init",
625            "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.",
626            "inputSchema": {
627                "type": "object",
628                "properties": {
629                    "handle": {"type": "string", "description": "Short handle (becomes did:wire:<handle>). ASCII alphanumeric / '-' / '_' only."},
630                    "name": {"type": "string", "description": "Optional display name (defaults to capitalized handle)."},
631                    "relay_url": {"type": "string", "description": "Optional relay URL — if set, also binds a relay slot."}
632                },
633                "required": ["handle"]
634            }
635        }),
636        json!({
637            "name": "wire_pair_initiate",
638            "description": "Open a host-side pair-slot. AUTO-INITS the local identity if `handle` is provided and not yet inited (idempotent). Returns a code phrase the agent shows to the user out-of-band (voice / separate text channel) for the peer to paste into their wire_pair_join. Blocks up to max_wait_secs (default 30) for the peer to join, returning SAS inline if so — wire_pair_check is only needed when the host's 30s window closes before the peer joins. Multiple concurrent sessions supported (each call returns a distinct session_id).",
639            "inputSchema": {
640                "type": "object",
641                "properties": {
642                    "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
643                    "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
644                    "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 30, "description": "How long to block waiting for peer to join before returning waiting-state. 0 = return immediately with code phrase only."}
645                },
646                "required": []
647            }
648        }),
649        json!({
650            "name": "wire_pair_join",
651            "description": "Accept a code phrase from the host (the user types it in after the host shares it out-of-band). AUTO-INITS the local identity if `handle` is provided and not yet inited (idempotent). Returns SAS digits inline once SPAKE2 completes (typically <1s — host is already waiting). The user MUST then type the 6 SAS digits back into chat — pass them to wire_pair_confirm with the returned session_id.",
652            "inputSchema": {
653                "type": "object",
654                "properties": {
655                    "code_phrase": {"type": "string", "description": "Code phrase from the host (e.g. '73-2QXC4P')."},
656                    "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
657                    "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
658                    "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 30, "description": "How long to block waiting for SPAKE2 exchange to complete."}
659                },
660                "required": ["code_phrase"]
661            }
662        }),
663        json!({
664            "name": "wire_pair_check",
665            "description": "Poll a pending pair session. Returns {state: 'waiting'|'sas_ready'|'finalized'|'aborted', sas?, peer_handle?}. Rarely needed — wire_pair_initiate now blocks 30s by default, covering most cases.",
666            "inputSchema": {
667                "type": "object",
668                "properties": {
669                    "session_id": {"type": "string"},
670                    "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 8}
671                },
672                "required": ["session_id"]
673            }
674        }),
675        json!({
676            "name": "wire_pair_confirm",
677            "description": "Verify the user typed the correct SAS digits, then finalize pairing (AEAD bootstrap exchange + pin peer). AUTO-SUBSCRIBES to wire://inbox/<peer> so the agent gets push notifications/resources/updated as new events arrive. The 6-digit SAS comes from the user via the agent's chat — the user reads digits from their peer (out-of-band side channel), then types them back into chat. Mismatch ABORTS this session permanently — start a fresh wire_pair_initiate. Accepts dashes/spaces ('384-217' or '384217' or '384 217').",
678            "inputSchema": {
679                "type": "object",
680                "properties": {
681                    "session_id": {"type": "string"},
682                    "user_typed_digits": {"type": "string", "description": "The 6 SAS digits the user typed back, e.g. '384217' or '384-217'."}
683                },
684                "required": ["session_id", "user_typed_digits"]
685            }
686        }),
687        json!({
688            "name": "wire_pair_initiate_detached",
689            "description": "Detached variant of wire_pair_initiate: queues a host-side pair via the local `wire daemon` (auto-spawned if not running) and returns IMMEDIATELY with the code phrase. The daemon drives the handshake in the background. Subscribe to wire://pending-pair/all to get notifications/resources/updated when status → sas_ready, then call wire_pair_confirm_detached(code, digits). Use this if your agent prompt expects to surface the code first and confirm later (across multiple chat turns) rather than block 30s.",
690            "inputSchema": {
691                "type": "object",
692                "properties": {
693                    "handle": {"type": "string", "description": "Optional handle for auto-init (idempotent)."},
694                    "relay_url": {"type": "string"}
695                }
696            }
697        }),
698        json!({
699            "name": "wire_pair_join_detached",
700            "description": "Detached variant of wire_pair_join. Same flow as wire_pair_initiate_detached but as guest: queues a pair-join on the local daemon. Returns immediately. Subscribe to wire://pending-pair/all for the eventual sas_ready notification.",
701            "inputSchema": {
702                "type": "object",
703                "properties": {
704                    "handle": {"type": "string"},
705                    "code_phrase": {"type": "string"},
706                    "relay_url": {"type": "string"}
707                },
708                "required": ["code_phrase"]
709            }
710        }),
711        json!({
712            "name": "wire_pair_list_pending",
713            "description": "Return the local daemon's pending detached pair sessions (all states). Same shape as `wire pair-list` JSON. Cheap call — agent can poll, but prefer subscribing to wire://pending-pair/all for push notifications.",
714            "inputSchema": {"type": "object", "properties": {}}
715        }),
716        json!({
717            "name": "wire_pair_confirm_detached",
718            "description": "Confirm a detached pair after SAS surfaces (status=sas_ready). The user must read the SAS digits aloud to their peer over a side channel; if they match the peer's digits, the user types digits back into chat — pass those to this tool. Mismatch ABORTS. The daemon picks up the confirmation on its next tick and finalizes.",
719            "inputSchema": {
720                "type": "object",
721                "properties": {
722                    "code_phrase": {"type": "string"},
723                    "user_typed_digits": {"type": "string"}
724                },
725                "required": ["code_phrase", "user_typed_digits"]
726            }
727        }),
728        json!({
729            "name": "wire_pair_cancel_pending",
730            "description": "Cancel a pending detached pair. Releases the relay slot and removes the local pending file. Safe to call regardless of current status (idempotent).",
731            "inputSchema": {
732                "type": "object",
733                "properties": {"code_phrase": {"type": "string"}},
734                "required": ["code_phrase"]
735            }
736        }),
737        json!({
738            "name": "wire_invite_mint",
739            "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}.",
740            "inputSchema": {
741                "type": "object",
742                "properties": {
743                    "relay_url": {"type": "string", "description": "Override relay for first-time auto-allocate."},
744                    "ttl_secs": {"type": "integer", "description": "Invite lifetime in seconds (default 86400)."},
745                    "uses": {"type": "integer", "description": "Number of distinct peers that can accept before consumption (default 1)."}
746                }
747            }
748        }),
749        json!({
750            "name": "wire_invite_accept",
751            "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}.",
752            "inputSchema": {
753                "type": "object",
754                "properties": {
755                    "url": {"type": "string", "description": "Full wire://pair?v=1&inv=... URL."}
756                },
757                "required": ["url"]
758            }
759        }),
760        // v0.5 — agentic hotline.
761        json!({
762            "name": "wire_add",
763            "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 pair-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_pair_accept` or `wire_pair_reject` instead.",
764            "inputSchema": {
765                "type": "object",
766                "properties": {
767                    "handle": {"type": "string", "description": "Peer handle like `nick@domain`."},
768                    "relay_url": {"type": "string", "description": "Override resolver URL (default: `https://<domain>`)."}
769                },
770                "required": ["handle"]
771            }
772        }),
773        json!({
774            "name": "wire_pair_accept",
775            "description": "Accept a pending-inbound pair request (v0.5.14). When a stranger has run `wire add you@<your-relay>` against this agent's handle, their signed pair_drop sits in pending-inbound — see `wire_pair_list_inbound` to enumerate. Calling this command pins them VERIFIED, ships our slot_token via `pair_drop_ack`, and deletes the pending record. Requires explicit operator consent: the agent SHOULD surface the pending request to the user (e.g. via OS toast or in chat) before calling this, because accepting grants the peer authenticated write access to this agent's inbox. Errors if no pending record exists for the named peer.",
776            "inputSchema": {
777                "type": "object",
778                "properties": {
779                    "peer": {"type": "string", "description": "Bare peer handle (without `@<relay>`). Match exactly what `wire_pair_list_inbound` returned in `peer_handle`."}
780                },
781                "required": ["peer"]
782            }
783        }),
784        json!({
785            "name": "wire_pair_reject",
786            "description": "Refuse a pending-inbound pair request (v0.5.14). Deletes the pending record. The peer never receives our slot_token; from their side the pair stays pending until they time out or remove their outbound record. Idempotent — succeeds with `rejected: false` if no record existed for that peer.",
787            "inputSchema": {
788                "type": "object",
789                "properties": {
790                    "peer": {"type": "string", "description": "Bare peer handle (without `@<relay>`)."}
791                },
792                "required": ["peer"]
793            }
794        }),
795        json!({
796            "name": "wire_pair_list_inbound",
797            "description": "DEPRECATED in v0.9 — use `wire_pending`. List pending-inbound pair requests (v0.5.14). Returns a flat array of `{peer_handle, peer_did, peer_relay_url, peer_slot_id, received_at, event_id}` records, oldest first.",
798            "inputSchema": {"type": "object", "properties": {}}
799        }),
800        // v0.10.1: canonical MCP names mirroring the operator-facing
801        // verbs (wire dial / accept / reject / pending). Old wire_pair_*
802        // names stay callable as aliases (see dispatch); these new
803        // entries are what appears in tools/list for new clients.
804        json!({
805            "name": "wire_dial",
806            "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.",
807            "inputSchema": {
808                "type": "object",
809                "properties": {
810                    "name": {"type": "string", "description": "Peer name — character nickname / session / handle / DID / `<handle>@<relay>`."}
811                },
812                "required": ["name"]
813            }
814        }),
815        json!({
816            "name": "wire_accept",
817            "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.",
818            "inputSchema": {
819                "type": "object",
820                "properties": {
821                    "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle, from wire_pending)."}
822                },
823                "required": ["peer"]
824            }
825        }),
826        json!({
827            "name": "wire_reject",
828            "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.",
829            "inputSchema": {
830                "type": "object",
831                "properties": {
832                    "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle)."}
833                },
834                "required": ["peer"]
835            }
836        }),
837        json!({
838            "name": "wire_pending",
839            "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.",
840            "inputSchema": {"type": "object", "properties": {}}
841        }),
842        json!({
843            "name": "wire_claim",
844            "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).",
845            "inputSchema": {
846                "type": "object",
847                "properties": {
848                    "nick": {"type": "string", "description": "Optional + advisory. Ignored if it differs from your DID-derived persona (one-name rule)."},
849                    "relay_url": {"type": "string", "description": "Relay to claim on. Default = our relay."},
850                    "public_url": {"type": "string", "description": "Public URL the relay should advertise to resolvers."}
851                }
852            }
853        }),
854        json!({
855            "name": "wire_whois",
856            "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.",
857            "inputSchema": {
858                "type": "object",
859                "properties": {
860                    "handle": {"type": "string", "description": "Optional `nick@domain`. Omit for self."},
861                    "relay_url": {"type": "string", "description": "Override resolver URL."}
862                }
863            }
864        }),
865        json!({
866            "name": "wire_profile_set",
867            "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.",
868            "inputSchema": {
869                "type": "object",
870                "properties": {
871                    "field": {"type": "string", "description": "One of: display_name, emoji, motto, vibe, pronouns, avatar_url, handle, now."},
872                    "value": {"description": "String for most fields; array for vibe; object for now. Pass JSON null to clear a field."}
873                },
874                "required": ["field", "value"]
875            }
876        }),
877        json!({
878            "name": "wire_profile_get",
879            "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.",
880            "inputSchema": {"type": "object", "properties": {}}
881        }),
882        // ---- group chat (v0.13.4): a group is a shared relay-room slot; the
883        // creator-signed roster carries member keys so members verify each
884        // other without pairing. GroupTier (creator/member/introduced) is a
885        // SEPARATE axis from bilateral peer trust. ----
886        json!({
887            "name": "wire_group_create",
888            "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.",
889            "inputSchema": {
890                "type": "object",
891                "properties": {"name": {"type": "string", "description": "Human label for the group."}},
892                "required": ["name"]
893            }
894        }),
895        json!({
896            "name": "wire_group_add",
897            "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.",
898            "inputSchema": {
899                "type": "object",
900                "properties": {
901                    "group": {"type": "string", "description": "Group id or name."},
902                    "peer": {"type": "string", "description": "Handle of a VERIFIED pinned peer."}
903                },
904                "required": ["group", "peer"]
905            }
906        }),
907        json!({
908            "name": "wire_group_send",
909            "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).",
910            "inputSchema": {
911                "type": "object",
912                "properties": {
913                    "group": {"type": "string", "description": "Group id or name."},
914                    "message": {"type": "string", "description": "Message text."}
915                },
916                "required": ["group", "message"]
917            }
918        }),
919        json!({
920            "name": "wire_group_tail",
921            "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.",
922            "inputSchema": {
923                "type": "object",
924                "properties": {
925                    "group": {"type": "string", "description": "Group id or name."},
926                    "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 20, "description": "Max timeline entries to return."}
927                },
928                "required": ["group"]
929            }
930        }),
931        json!({
932            "name": "wire_group_list",
933            "description": "List the groups this agent is in, with each group's members and their GroupTiers (creator/member/introduced). Read-only, local.",
934            "inputSchema": {"type": "object", "properties": {}, "required": []}
935        }),
936        json!({
937            "name": "wire_group_invite",
938            "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.",
939            "inputSchema": {
940                "type": "object",
941                "properties": {"group": {"type": "string", "description": "Group id or name."}},
942                "required": ["group"]
943            }
944        }),
945        json!({
946            "name": "wire_group_join",
947            "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.",
948            "inputSchema": {
949                "type": "object",
950                "properties": {"code": {"type": "string", "description": "The `wire-group:` join code."}},
951                "required": ["code"]
952            }
953        }),
954    ]
955}
956
957fn handle_tools_call(id: &Value, params: &Value, state: &McpState) -> Value {
958    let name = match params.get("name").and_then(Value::as_str) {
959        Some(n) => n,
960        None => return error_response(id, -32602, "missing tool name"),
961    };
962    let args = params
963        .get("arguments")
964        .cloned()
965        .unwrap_or_else(|| json!({}));
966
967    let result = match name {
968        "wire_whoami" => tool_whoami(),
969        "wire_status" => tool_status(),
970        "wire_peers" => tool_peers(),
971        "wire_send" => tool_send(&args),
972        "wire_pull" => tool_pull(),
973        "wire_tail" => tool_tail(&args),
974        "wire_verify" => tool_verify(&args),
975        "wire_init" => tool_init(&args),
976        "wire_pair_initiate" => tool_pair_initiate(&args),
977        "wire_pair_join" => tool_pair_join(&args),
978        "wire_pair_check" => tool_pair_check(&args),
979        "wire_pair_confirm" => tool_pair_confirm(&args, state),
980        "wire_pair_initiate_detached" => tool_pair_initiate_detached(&args),
981        "wire_pair_join_detached" => tool_pair_join_detached(&args),
982        "wire_pair_list_pending" => tool_pair_list_pending(),
983        "wire_pair_confirm_detached" => tool_pair_confirm_detached(&args),
984        "wire_pair_cancel_pending" => tool_pair_cancel_pending(&args),
985        "wire_invite_mint" => tool_invite_mint(&args),
986        "wire_invite_accept" => tool_invite_accept(&args),
987        // v0.5 — agentic hotline (handle + profile + zero-paste discovery).
988        "wire_add" => tool_add(&args),
989        // v0.5.14 — bilateral-required pair: inbound queue management.
990        // v0.10.1: canonical names introduced (wire_accept, wire_reject,
991        // wire_pending, wire_dial); legacy wire_pair_* names stay as
992        // aliases for back-compat. Both surface in tools/list with
993        // legacy descriptions tagged DEPRECATED.
994        "wire_pair_accept" | "wire_accept" => tool_pair_accept(&args),
995        "wire_pair_reject" | "wire_reject" => tool_pair_reject(&args),
996        "wire_pair_list_inbound" | "wire_pending" => tool_pair_list_inbound(),
997        "wire_dial" => tool_dial(&args),
998        "wire_claim" => tool_claim_handle(&args),
999        "wire_whois" => tool_whois(&args),
1000        "wire_profile_set" => tool_profile_set(&args),
1001        "wire_profile_get" => tool_profile_get(),
1002        // v0.13.4 — group chat (shared-room slot + introduce-on-vouch).
1003        "wire_group_create" => tool_group_create(&args),
1004        "wire_group_add" => tool_group_add(&args),
1005        "wire_group_send" => tool_group_send(&args),
1006        "wire_group_tail" => tool_group_tail(&args),
1007        "wire_group_list" => tool_group_list(),
1008        "wire_group_invite" => tool_group_invite(&args),
1009        "wire_group_join" => tool_group_join(&args),
1010        // Legacy alias kept for older agent prompts that reference `wire_join`.
1011        // Surfaces the operator-friendly error pointing to wire_pair_join.
1012        "wire_join" => Err(
1013            "wire_join was renamed to wire_pair_join (use code_phrase argument). \
1014             See docs/AGENT_INTEGRATION.md."
1015                .into(),
1016        ),
1017        other => Err(format!("unknown tool: {other}")),
1018    };
1019
1020    match result {
1021        Ok(value) => json!({
1022            "jsonrpc": "2.0",
1023            "id": id,
1024            "result": {
1025                "content": [{
1026                    "type": "text",
1027                    "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
1028                }],
1029                "isError": false
1030            }
1031        }),
1032        Err(message) => json!({
1033            "jsonrpc": "2.0",
1034            "id": id,
1035            "result": {
1036                "content": [{"type": "text", "text": message}],
1037                "isError": true
1038            }
1039        }),
1040    }
1041}
1042
1043// ---------- tool implementations ----------
1044
1045fn tool_whoami() -> Result<Value, String> {
1046    use crate::config;
1047    use crate::signing::{b64decode, fingerprint, make_key_id};
1048
1049    if !config::is_initialized().map_err(|e| e.to_string())? {
1050        return Err("not initialized — operator must run `wire init <handle>` first".into());
1051    }
1052    let card = config::read_agent_card().map_err(|e| e.to_string())?;
1053    let did = card
1054        .get("did")
1055        .and_then(Value::as_str)
1056        .unwrap_or("")
1057        .to_string();
1058    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1059    let pk_b64 = card
1060        .get("verify_keys")
1061        .and_then(Value::as_object)
1062        .and_then(|m| m.values().next())
1063        .and_then(|v| v.get("key"))
1064        .and_then(Value::as_str)
1065        .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
1066    let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1067    let fp = fingerprint(&pk_bytes);
1068    let key_id = make_key_id(&handle, &pk_bytes);
1069    let capabilities = card
1070        .get("capabilities")
1071        .cloned()
1072        .unwrap_or_else(|| json!(["wire/v3.2"]));
1073    // v0.12: surface the DID-derived persona (nickname + emoji + palette)
1074    // that the CLI `wire whoami`/`here` already emit, so agents and toasts
1075    // see the persona, not just the raw handle.
1076    let persona =
1077        serde_json::to_value(crate::character::Character::from_card(&card)).unwrap_or(Value::Null);
1078    // v0.14: surface the RFC-001 op claims (op_did / op_pubkey / op_cert /
1079    // org_memberships / schema_version) when enrolled, mirroring the CLI
1080    // `wire whoami --json` shape. Same `op_claims_from_card` helper as
1081    // CLI ⇒ MCP + CLI stay in lock-step as the inline set grows. Older
1082    // cards / unenrolled ⇒ no extra keys (no JSON null-spam).
1083    let mut payload = serde_json::Map::new();
1084    payload.insert("did".into(), json!(did));
1085    payload.insert("handle".into(), json!(handle));
1086    payload.insert("persona".into(), persona);
1087    payload.insert("fingerprint".into(), json!(fp));
1088    payload.insert("key_id".into(), json!(key_id));
1089    payload.insert("public_key_b64".into(), json!(pk_b64));
1090    payload.insert("capabilities".into(), capabilities);
1091    for (k, v) in crate::cli::op_claims_from_card(&card) {
1092        payload.insert(k, v);
1093    }
1094    Ok(Value::Object(payload))
1095}
1096
1097fn tool_peers() -> Result<Value, String> {
1098    use crate::config;
1099
1100    let trust = config::read_trust().map_err(|e| e.to_string())?;
1101    let agents = trust
1102        .get("agents")
1103        .and_then(Value::as_object)
1104        .cloned()
1105        .unwrap_or_default();
1106    // v0.14.3 (coral dogfood 2026-06-01): use effective tier so the
1107    // MCP surface matches the CLI ones (wire status / wire peers /
1108    // wire here all switched to effective_tier in #199 + #201).
1109    // Pre-fix, agents calling wire_peers via MCP got raw
1110    // trust-promoted VERIFIED even when the bilateral handshake
1111    // never delivered the slot credentials → daemon can't push but
1112    // agent thought it could.
1113    let relay_state =
1114        config::read_relay_state().unwrap_or_else(|_| json!({"self": null, "peers": {}}));
1115    let mut self_did: Option<String> = None;
1116    if let Ok(card) = config::read_agent_card() {
1117        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
1118    }
1119    let mut peers = Vec::new();
1120    for (handle, agent) in agents.iter() {
1121        let did = agent
1122            .get("did")
1123            .and_then(Value::as_str)
1124            .unwrap_or("")
1125            .to_string();
1126        if Some(did.as_str()) == self_did.as_deref() {
1127            continue;
1128        }
1129        // v0.12: include the persona (respecting the peer's advertised
1130        // override when their card carries one, else DID-derived) so MCP
1131        // callers render the nickname/emoji instead of the raw handle.
1132        let persona = match agent.get("card") {
1133            Some(c) => crate::character::Character::from_card(c),
1134            None => crate::character::Character::from_did(&did),
1135        };
1136        // v0.14: surface peer's inline op claims (when their pinned card
1137        // carries them) so paired agents see ORG_VERIFIED-source membership
1138        // without reading trust.json directly. Identical shape to the CLI
1139        // `wire peers --json` row; older peers ⇒ no extra keys.
1140        let peer_op_claims = agent
1141            .get("card")
1142            .map(crate::cli::op_claims_from_card)
1143            .unwrap_or_default();
1144        let mut row = serde_json::Map::new();
1145        row.insert("handle".into(), json!(handle));
1146        row.insert(
1147            "persona".into(),
1148            serde_json::to_value(&persona).unwrap_or(Value::Null),
1149        );
1150        row.insert("did".into(), json!(did));
1151        row.insert(
1152            "tier".into(),
1153            json!(crate::trust::effective_tier(&trust, &relay_state, handle)),
1154        );
1155        row.insert(
1156            "capabilities".into(),
1157            agent
1158                .get("card")
1159                .and_then(|c| c.get("capabilities"))
1160                .cloned()
1161                .unwrap_or_else(|| json!([])),
1162        );
1163        for (k, v) in peer_op_claims {
1164            row.insert(k, v);
1165        }
1166        peers.push(Value::Object(row));
1167    }
1168    Ok(json!(peers))
1169}
1170
1171/// Run `wire group <args> --json` by spawning this same binary, inheriting the
1172/// MCP session's WIRE_* env so it resolves the same identity/home. Group ops are
1173/// infrequent, so this reuses the exact, tested CLI logic — including the
1174/// verification-sensitive invite/join paths — rather than duplicating it here.
1175fn group_cli_json(args: &[&str]) -> Result<Value, String> {
1176    let exe = std::env::current_exe().map_err(|e| format!("locating wire binary: {e}"))?;
1177    let out = std::process::Command::new(exe)
1178        .arg("group")
1179        .args(args)
1180        .arg("--json")
1181        .env("WIRE_QUIET_AUTOSESSION", "1") // suppress the adopt-session stderr line
1182        .output()
1183        .map_err(|e| format!("spawning `wire group`: {e}"))?;
1184    if !out.status.success() {
1185        let err = String::from_utf8_lossy(&out.stderr);
1186        return Err(err.trim().to_string());
1187    }
1188    let s = String::from_utf8_lossy(&out.stdout);
1189    // Last JSON object line is the result (any adopt chatter went to stderr).
1190    let line = s
1191        .lines()
1192        .rev()
1193        .find(|l| l.trim_start().starts_with('{'))
1194        .unwrap_or("{}");
1195    serde_json::from_str(line).map_err(|e| format!("parsing `wire group` output: {e}"))
1196}
1197
1198fn tool_group_create(args: &Value) -> Result<Value, String> {
1199    let name = args
1200        .get("name")
1201        .and_then(Value::as_str)
1202        .ok_or("missing 'name'")?;
1203    group_cli_json(&["create", name])
1204}
1205
1206fn tool_group_add(args: &Value) -> Result<Value, String> {
1207    let group = args
1208        .get("group")
1209        .and_then(Value::as_str)
1210        .ok_or("missing 'group'")?;
1211    let peer = args
1212        .get("peer")
1213        .and_then(Value::as_str)
1214        .ok_or("missing 'peer'")?;
1215    group_cli_json(&["add", group, peer])
1216}
1217
1218fn tool_group_send(args: &Value) -> Result<Value, String> {
1219    let group = args
1220        .get("group")
1221        .and_then(Value::as_str)
1222        .ok_or("missing 'group'")?;
1223    let message = args
1224        .get("message")
1225        .and_then(Value::as_str)
1226        .ok_or("missing 'message'")?;
1227    group_cli_json(&["send", group, message])
1228}
1229
1230fn tool_group_tail(args: &Value) -> Result<Value, String> {
1231    let group = args
1232        .get("group")
1233        .and_then(Value::as_str)
1234        .ok_or("missing 'group'")?;
1235    if let Some(n) = args.get("limit").and_then(Value::as_u64) {
1236        group_cli_json(&["tail", group, "--limit", &n.to_string()])
1237    } else {
1238        group_cli_json(&["tail", group])
1239    }
1240}
1241
1242fn tool_group_list() -> Result<Value, String> {
1243    group_cli_json(&["list"])
1244}
1245
1246fn tool_group_invite(args: &Value) -> Result<Value, String> {
1247    let group = args
1248        .get("group")
1249        .and_then(Value::as_str)
1250        .ok_or("missing 'group'")?;
1251    group_cli_json(&["invite", group])
1252}
1253
1254fn tool_group_join(args: &Value) -> Result<Value, String> {
1255    let code = args
1256        .get("code")
1257        .and_then(Value::as_str)
1258        .ok_or("missing 'code'")?;
1259    group_cli_json(&["join", code])
1260}
1261
1262/// v0.14.2 (#162): daemon + sync-loop health check, MCP-side mirror of
1263/// `wire status`. Specifically engineered to answer the silent-send
1264/// question — "if I call wire_send right now, will the daemon actually
1265/// push it?". Returns the daemon-liveness section + last-sync metadata +
1266/// outbox/inbox depth so callers can branch on a stale or absent sync.
1267///
1268/// Read-only. No initialization gate — runs against an empty home
1269/// (returns `initialized:false` shape mirroring wire_whoami's
1270/// degraded-uninit path from #152).
1271fn tool_status() -> Result<Value, String> {
1272    use crate::config;
1273
1274    let initialized = config::is_initialized().unwrap_or(false);
1275    if !initialized {
1276        return Ok(json!({
1277            "initialized": false,
1278            "daemon_running": false,
1279            "last_sync_age_seconds": Value::Null,
1280        }));
1281    }
1282
1283    let snap = crate::ensure_up::daemon_liveness();
1284    let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1285    let last_sync_record = crate::ensure_up::read_last_sync_record();
1286
1287    let mut daemon = json!({
1288        "running": snap.pidfile_alive,
1289        "pid": snap.pidfile_pid,
1290        "all_running_pids": snap.pgrep_pids,
1291        "orphans": snap.orphan_pids,
1292    });
1293    if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
1294        daemon["version"] = json!(d.version);
1295        daemon["bin_path"] = json!(d.bin_path);
1296        daemon["did"] = json!(d.did);
1297        daemon["relay_url"] = json!(d.relay_url);
1298        daemon["started_at"] = json!(d.started_at);
1299    }
1300
1301    let (last_sync_at, last_sync_push_n, last_sync_pull_n, last_sync_rejected_n) =
1302        match last_sync_record {
1303            Some(rec) => (
1304                Some(rec.ts),
1305                Some(rec.push_n),
1306                Some(rec.pull_n),
1307                Some(rec.rejected_n),
1308            ),
1309            None => (None, None, None, None),
1310        };
1311
1312    let outbox_count = config::outbox_dir()
1313        .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1314        .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1315        .unwrap_or(0);
1316    let inbox_count = config::inbox_dir()
1317        .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1318        .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1319        .unwrap_or(0);
1320
1321    // v0.14.2 (#162 fix #2): total events queued but not yet pushed.
1322    // `pending_push_count > 0` + `stale_sync == true` = the
1323    // silent-send class — events queued, daemon not pushing.
1324    // v0.14.3 (coral dogfood 2026-06-01): also surface a per-peer
1325    // breakdown so MCP-side agents (and the CLI both share the
1326    // same derivation) can see which peer is wedged + at what
1327    // trust tier without re-walking the outbox.
1328    let pending_push_breakdown = config::compute_pending_push_breakdown();
1329    let pending_push_count: u64 = pending_push_breakdown.iter().map(|p| p.count).sum();
1330
1331    // v0.14.2 (#162 fix #7): SSE stream-subscriber state so callers
1332    // can distinguish "stream alive (live monitor will fire on
1333    // inbound)" from "polling-only (daemon up, monitor will wait
1334    // until next poll cycle)". Best-effort read; missing file is
1335    // Value::Null (unknown).
1336    let stream_state = config::read_stream_state();
1337
1338    Ok(json!({
1339        "initialized": true,
1340        "daemon": daemon,
1341        "daemon_running": snap.pidfile_alive,
1342        "last_sync_at": last_sync_at,
1343        "last_sync_age_seconds": last_sync_age,
1344        "last_sync_push_n": last_sync_push_n,
1345        "last_sync_pull_n": last_sync_pull_n,
1346        "last_sync_rejected_n": last_sync_rejected_n,
1347        "stale_sync": config::stale_sync(last_sync_age),
1348        "outbox_count": outbox_count,
1349        "inbox_count": inbox_count,
1350        "pending_push_count": pending_push_count,
1351        "pending_push_breakdown": pending_push_breakdown,
1352        "stream_state": stream_state,
1353    }))
1354}
1355
1356fn tool_send(args: &Value) -> Result<Value, String> {
1357    use crate::config;
1358    use crate::signing::{b64decode, sign_message_v31};
1359
1360    let peer = args
1361        .get("peer")
1362        .and_then(Value::as_str)
1363        .ok_or("missing 'peer'")?;
1364    let peer = crate::agent_card::bare_handle(peer);
1365    let kind = args
1366        .get("kind")
1367        .and_then(Value::as_str)
1368        .ok_or("missing 'kind'")?;
1369    let body = args
1370        .get("body")
1371        .and_then(Value::as_str)
1372        .ok_or("missing 'body'")?;
1373    let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
1374    // v0.14.2 (paul, 2026-06-01): opt back into the legacy outbox →
1375    // daemon-push pipeline. Default is synchronous POST so callers get
1376    // a real `delivered` / `duplicate` / `failed` verdict instead of
1377    // a `queued` lie. `queue: true` writes to outbox like pre-v0.14.2.
1378    let queue = args.get("queue").and_then(Value::as_bool).unwrap_or(false);
1379
1380    if !config::is_initialized().map_err(|e| e.to_string())? {
1381        return Err("not initialized — operator must run `wire init <handle>` first".into());
1382    }
1383    let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
1384    let card = config::read_agent_card().map_err(|e| e.to_string())?;
1385    let did = card
1386        .get("did")
1387        .and_then(Value::as_str)
1388        .unwrap_or("")
1389        .to_string();
1390    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1391    let pk_b64 = card
1392        .get("verify_keys")
1393        .and_then(Value::as_object)
1394        .and_then(|m| m.values().next())
1395        .and_then(|v| v.get("key"))
1396        .and_then(Value::as_str)
1397        .ok_or("agent-card missing verify_keys[*].key")?;
1398    let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1399
1400    // Body parses as JSON if possible, else stays a string.
1401    let body_value: Value =
1402        serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1403    let kind_id = parse_kind(kind);
1404
1405    let now = time::OffsetDateTime::now_utc()
1406        .format(&time::format_description::well_known::Rfc3339)
1407        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1408
1409    // v0.14.2 (#162 fix #4): canonicalize `to:` against the pinned
1410    // peer's full DID via the trust store. Bare-handle
1411    // `to:did:wire:<handle>` misses the long-fingerprint suffix
1412    // (`did:wire:sunlit-aurora-ec6f890d`) that pinned peers actually
1413    // publish — mismatch risks receiver rejection at canonical/cursor
1414    // verification. resolve_peer_did falls back to the bare form when
1415    // the peer isn't pinned yet (pre-pair queue best-effort).
1416    let trust_for_did = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
1417    let to_did = crate::trust::resolve_peer_did(&trust_for_did, peer);
1418    let mut event = json!({
1419        "timestamp": now,
1420        "from": did,
1421        "to": to_did,
1422        "type": kind,
1423        "kind": kind_id,
1424        "body": body_value,
1425    });
1426    if let Some(deadline) = deadline {
1427        event["time_sensitive_until"] =
1428            json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1429    }
1430    let signed =
1431        sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1432    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1433
1434    // v0.14.2 (paul, 2026-06-01): collapse send → outbox → push into
1435    // a synchronous POST by default. `queue: true` opts back into the
1436    // legacy outbox path for offline-buffer / batch / pre-pair queue
1437    // use cases.
1438    if !queue {
1439        let outcome = crate::send::attempt_deliver(peer, &signed).map_err(|e| e.to_string())?;
1440        let mut v = crate::send::delivery_json(&outcome, peer);
1441        // Carry the same daemon-health annotations the caller used to
1442        // get on the legacy `queued` response. With sync delivery
1443        // these are diagnostic-only (the verdict in `status` is the
1444        // authoritative answer), but they're cheap to compute and
1445        // existing consumers may key on them.
1446        let snap = crate::ensure_up::daemon_liveness();
1447        let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1448        if let Some(obj) = v.as_object_mut() {
1449            obj.insert("daemon_seen".into(), json!(snap.pidfile_alive));
1450            obj.insert("last_sync_age_seconds".into(), json!(last_sync_age));
1451            obj.insert(
1452                "stale_sync".into(),
1453                json!(config::stale_sync(last_sync_age)),
1454            );
1455        }
1456        return Ok(v);
1457    }
1458
1459    // Legacy --queue path. Outbox-write, daemon push loop drains.
1460    let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1461    let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1462    let snap = crate::ensure_up::daemon_liveness();
1463    let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1464    // Honesty check mirror of the CLI: if the peer is BOTH
1465    // unpinned in trust AND has no pending pair (outbound or
1466    // inbound), the queued event has nowhere to go and will sit
1467    // in outbox forever. Surface the warning as a structured
1468    // `warning` field so MCP-side agents can branch on it instead
1469    // of treating `status:"queued"` as success.
1470    let peer_pinned_in_trust = trust_for_did
1471        .get("agents")
1472        .and_then(Value::as_object)
1473        .map(|a| a.contains_key(peer))
1474        .unwrap_or(false);
1475    let peer_in_relay_state = config::read_relay_state()
1476        .ok()
1477        .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
1478        .map(|peers| peers.contains_key(peer))
1479        .unwrap_or(false);
1480    let pending_outbound = crate::pending_pair::list_pending()
1481        .ok()
1482        .map(|v| {
1483            v.iter().any(|p| {
1484                p.peer_did
1485                    .as_deref()
1486                    .map(|d| {
1487                        crate::agent_card::display_handle_from_did(d)
1488                            .to_string()
1489                            .eq(peer)
1490                    })
1491                    .unwrap_or(false)
1492            })
1493        })
1494        .unwrap_or(false);
1495    let pending_inbound = crate::pending_inbound_pair::list_pending_inbound()
1496        .ok()
1497        .map(|v| v.iter().any(|p| p.peer_handle == peer))
1498        .unwrap_or(false);
1499    let unpushable =
1500        !peer_pinned_in_trust && !peer_in_relay_state && !pending_outbound && !pending_inbound;
1501    let mut out = json!({
1502        "event_id": event_id,
1503        "status": "queued",
1504        "peer": peer,
1505        "outbox": outbox.to_string_lossy(),
1506        "daemon_seen": snap.pidfile_alive,
1507        "last_sync_age_seconds": last_sync_age,
1508        "stale_sync": config::stale_sync(last_sync_age),
1509    });
1510    if unpushable {
1511        out["warning"] = json!(format!(
1512            "`{peer}` is not pinned and has no pending pair — the event will sit in outbox forever unless you pair first (wire_dial)."
1513        ));
1514    }
1515    Ok(out)
1516}
1517
1518/// v0.14.2 (paul, post-#187): symmetric receive primitive. `wire_send`
1519/// became sync in #187; `wire_pull` is the mirror — trigger an
1520/// immediate relay GET on this agent's slot(s), write new events to
1521/// inbox, advance per-slot cursors, return the verdict. Thin wrapper
1522/// over `cli::run_sync_pull`; same code path the daemon's 5s pull
1523/// loop uses.
1524fn tool_pull() -> Result<Value, String> {
1525    crate::cli::run_sync_pull().map_err(|e| format!("{e:#}"))
1526}
1527
1528fn tool_tail(args: &Value) -> Result<Value, String> {
1529    use crate::config;
1530    use crate::signing::verify_message_v31;
1531
1532    let peer_filter = args.get("peer").and_then(Value::as_str);
1533    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1534    // wire #79: orientation parity with `wire tail` CLI — default newest-N,
1535    // `oldest=true` opts back into FIFO. Agents almost always want the
1536    // freshest inbox slice when re-tailing an established peer, not the
1537    // wire-init handshake noise.
1538    let oldest = args.get("oldest").and_then(Value::as_bool).unwrap_or(false);
1539    let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1540    if !inbox.exists() {
1541        return Ok(json!([]));
1542    }
1543    let trust = config::read_trust().map_err(|e| e.to_string())?;
1544    let entries: Vec<_> = std::fs::read_dir(&inbox)
1545        .map_err(|e| e.to_string())?
1546        .filter_map(|e| e.ok())
1547        .map(|e| e.path())
1548        .filter(|p| {
1549            p.extension().map(|x| x == "jsonl").unwrap_or(false)
1550                && match peer_filter {
1551                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1552                    None => true,
1553                }
1554        })
1555        .collect();
1556
1557    // (timestamp, per-file line index, event with verified meta). Sort key
1558    // mirrors the CLI cmd_tail for cross-tool consistency.
1559    let mut collected: Vec<(String, usize, Value)> = Vec::new();
1560    for path in &entries {
1561        let body = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1562        for (idx, line) in body.lines().enumerate() {
1563            let event: Value = match serde_json::from_str(line) {
1564                Ok(v) => v,
1565                Err(_) => continue,
1566            };
1567            let verified = verify_message_v31(&event, &trust).is_ok();
1568            let mut event_with_meta = event.clone();
1569            if let Some(obj) = event_with_meta.as_object_mut() {
1570                obj.insert("verified".into(), json!(verified));
1571            }
1572            let ts = event
1573                .get("timestamp")
1574                .and_then(Value::as_str)
1575                .unwrap_or("")
1576                .to_string();
1577            collected.push((ts, idx, event_with_meta));
1578        }
1579    }
1580    collected.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
1581
1582    let total = collected.len();
1583    let window: Vec<Value> = if limit == 0 {
1584        collected.into_iter().map(|(_, _, e)| e).collect()
1585    } else if oldest {
1586        collected
1587            .into_iter()
1588            .take(limit)
1589            .map(|(_, _, e)| e)
1590            .collect()
1591    } else {
1592        let start = total.saturating_sub(limit);
1593        collected
1594            .into_iter()
1595            .skip(start)
1596            .map(|(_, _, e)| e)
1597            .collect()
1598    };
1599    Ok(Value::Array(window))
1600}
1601
1602fn tool_verify(args: &Value) -> Result<Value, String> {
1603    use crate::config;
1604    use crate::signing::verify_message_v31;
1605
1606    let event_str = args
1607        .get("event")
1608        .and_then(Value::as_str)
1609        .ok_or("missing 'event'")?;
1610    let event: Value =
1611        serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1612    let trust = config::read_trust().map_err(|e| e.to_string())?;
1613    match verify_message_v31(&event, &trust) {
1614        Ok(()) => Ok(json!({"verified": true})),
1615        Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1616    }
1617}
1618
1619// ---------- pairing tools ----------
1620
1621/// v0.13: bootstrap a freshly-resolved session-keyed identity. Runs once per
1622/// session home (gated on `is_initialized`); no-op under WIRE_MCP_SKIP_AUTO_UP.
1623/// init (one-name) + federation slot via `ensure_self_with_relay`, then a
1624/// best-effort phonebook claim of the DID-derived persona. Network failures
1625/// are swallowed — the identity is still created locally; the claim retries on
1626/// a later start.
1627fn ensure_session_bootstrapped() {
1628    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
1629        return;
1630    }
1631    if crate::config::is_initialized().unwrap_or(false) {
1632        return; // this session home already has an identity
1633    }
1634    let (did, relay_url, slot_id, slot_token) =
1635        match crate::pair_invite::ensure_self_with_relay(None) {
1636            Ok(t) => t,
1637            Err(_) => return, // offline / relay down — init may have happened locally; skip claim
1638        };
1639    if let Ok(card) = crate::config::read_agent_card() {
1640        let persona = crate::agent_card::display_handle_from_did(&did).to_string();
1641        let client = crate::relay_client::RelayClient::new(&relay_url);
1642        let _ = client.handle_claim_v2(&persona, &slot_id, &slot_token, None, &card, None);
1643    }
1644}
1645
1646fn tool_init(args: &Value) -> Result<Value, String> {
1647    let handle = args
1648        .get("handle")
1649        .and_then(Value::as_str)
1650        .ok_or("missing 'handle'")?;
1651    let name = args.get("name").and_then(Value::as_str);
1652    let relay = args.get("relay_url").and_then(Value::as_str);
1653    crate::pair_session::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1654}
1655
1656/// Resolve the relay URL: explicit arg wins, else the relay this agent's
1657/// identity is already bound to (from `wire init --relay` or a previous
1658/// pair_initiate). Errors if neither is set.
1659fn resolve_relay_url(args: &Value) -> Result<String, String> {
1660    if let Some(url) = args.get("relay_url").and_then(Value::as_str) {
1661        return Ok(url.to_string());
1662    }
1663    let state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1664    state["self"]["relay_url"]
1665        .as_str()
1666        .map(str::to_string)
1667        .ok_or_else(|| "no relay_url provided and no relay bound (call wire_init with relay_url, or pass relay_url here)".into())
1668}
1669
1670/// If `handle` is provided and identity isn't yet initialized, call
1671/// `init_self_idempotent` so a single MCP call can do both. If handle is
1672/// missing and not initialized, surface a clear error pointing the agent at
1673/// wire_init. If already initialized under a different handle, the
1674/// idempotent init errors clearly (same as direct wire_init).
1675fn auto_init_if_needed(args: &Value) -> Result<(), String> {
1676    let initialized = crate::config::is_initialized().map_err(|e| e.to_string())?;
1677    if initialized {
1678        return Ok(());
1679    }
1680    let handle = args.get("handle").and_then(Value::as_str).ok_or(
1681        "not initialized — pass `handle` to auto-init, or call wire_init explicitly first",
1682    )?;
1683    let relay = args.get("relay_url").and_then(Value::as_str);
1684    crate::pair_session::init_self_idempotent(handle, None, relay)
1685        .map(|_| ())
1686        .map_err(|e| e.to_string())
1687}
1688
1689fn tool_pair_initiate(args: &Value) -> Result<Value, String> {
1690    use crate::pair_session::{
1691        pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1692    };
1693
1694    store_sweep_expired();
1695    // Auto-init if `handle` arg provided and not yet inited (idempotent).
1696    auto_init_if_needed(args)?;
1697
1698    let relay_url = resolve_relay_url(args)?;
1699    let max_wait = args
1700        .get("max_wait_secs")
1701        .and_then(Value::as_u64)
1702        .unwrap_or(30)
1703        .min(60);
1704
1705    let mut s = pair_session_open("host", &relay_url, None).map_err(|e| e.to_string())?;
1706    let code = s.code.clone();
1707
1708    let sas_opt = if max_wait > 0 {
1709        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1710            .map_err(|e| e.to_string())?
1711    } else {
1712        None
1713    };
1714
1715    let session_id = store_insert(s);
1716
1717    let mut out = json!({
1718        "session_id": session_id,
1719        "code_phrase": code,
1720        "relay_url": relay_url,
1721    });
1722    match sas_opt {
1723        Some(sas) => {
1724            out["state"] = json!("sas_ready");
1725            out["sas"] = json!(sas);
1726            out["next"] = json!(
1727                "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel (voice/text). \
1728                 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1729            );
1730        }
1731        None => {
1732            out["state"] = json!("waiting");
1733            out["next"] = json!(
1734                "Share the code_phrase with the user; ask them to read it to their peer (the peer pastes into wire_pair_join). \
1735                 Poll wire_pair_check(session_id) until state='sas_ready'."
1736            );
1737        }
1738    }
1739    Ok(out)
1740}
1741
1742fn tool_pair_join(args: &Value) -> Result<Value, String> {
1743    use crate::pair_session::{
1744        pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1745    };
1746
1747    store_sweep_expired();
1748    auto_init_if_needed(args)?;
1749
1750    let code = args
1751        .get("code_phrase")
1752        .and_then(Value::as_str)
1753        .ok_or("missing 'code_phrase'")?;
1754    let relay_url = resolve_relay_url(args)?;
1755    let max_wait = args
1756        .get("max_wait_secs")
1757        .and_then(Value::as_u64)
1758        .unwrap_or(30)
1759        .min(60);
1760
1761    let mut s = pair_session_open("guest", &relay_url, Some(code)).map_err(|e| e.to_string())?;
1762
1763    let sas_opt =
1764        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1765            .map_err(|e| e.to_string())?;
1766
1767    let session_id = store_insert(s);
1768
1769    let mut out = json!({
1770        "session_id": session_id,
1771        "relay_url": relay_url,
1772    });
1773    match sas_opt {
1774        Some(sas) => {
1775            out["state"] = json!("sas_ready");
1776            out["sas"] = json!(sas);
1777            out["next"] = json!(
1778                "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel. \
1779                 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1780            );
1781        }
1782        None => {
1783            out["state"] = json!("waiting");
1784            out["next"] = json!("Poll wire_pair_check(session_id).");
1785        }
1786    }
1787    Ok(out)
1788}
1789
1790fn tool_pair_check(args: &Value) -> Result<Value, String> {
1791    use crate::pair_session::{pair_session_wait_for_sas, store_get, store_sweep_expired};
1792
1793    store_sweep_expired();
1794    let session_id = args
1795        .get("session_id")
1796        .and_then(Value::as_str)
1797        .ok_or("missing 'session_id'")?;
1798    let max_wait = args
1799        .get("max_wait_secs")
1800        .and_then(Value::as_u64)
1801        .unwrap_or(8)
1802        .min(60);
1803
1804    let arc = store_get(session_id)
1805        .ok_or_else(|| format!("no such session_id (expired or never opened): {session_id}"))?;
1806    let mut s = arc.lock().map_err(|e| e.to_string())?;
1807
1808    if s.finalized {
1809        return Ok(json!({
1810            "state": "finalized",
1811            "session_id": session_id,
1812            "sas": s.formatted_sas(),
1813        }));
1814    }
1815    if let Some(reason) = s.aborted.clone() {
1816        return Ok(json!({
1817            "state": "aborted",
1818            "session_id": session_id,
1819            "reason": reason,
1820        }));
1821    }
1822
1823    let sas_opt =
1824        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1825            .map_err(|e| e.to_string())?;
1826
1827    Ok(match sas_opt {
1828        Some(sas) => json!({
1829            "state": "sas_ready",
1830            "session_id": session_id,
1831            "sas": sas,
1832            "next": "Have the user TYPE the 6 SAS digits BACK INTO CHAT, then pass to wire_pair_confirm."
1833        }),
1834        None => json!({
1835            "state": "waiting",
1836            "session_id": session_id,
1837        }),
1838    })
1839}
1840
1841fn tool_pair_confirm(args: &Value, state: &McpState) -> Result<Value, String> {
1842    use crate::pair_session::{
1843        pair_session_confirm_sas, pair_session_finalize, store_get, store_remove,
1844    };
1845
1846    let session_id = args
1847        .get("session_id")
1848        .and_then(Value::as_str)
1849        .ok_or("missing 'session_id'")?;
1850    let typed = args
1851        .get("user_typed_digits")
1852        .and_then(Value::as_str)
1853        .ok_or(
1854            "missing 'user_typed_digits' — the user must type the 6 SAS digits back into chat",
1855        )?;
1856
1857    let arc = store_get(session_id).ok_or_else(|| format!("no such session_id: {session_id}"))?;
1858
1859    let confirm_err = {
1860        let mut s = arc.lock().map_err(|e| e.to_string())?;
1861        match pair_session_confirm_sas(&mut s, typed) {
1862            Ok(()) => None,
1863            Err(e) => Some((s.aborted.is_some(), e.to_string())),
1864        }
1865    };
1866    if let Some((aborted, msg)) = confirm_err {
1867        if aborted {
1868            store_remove(session_id);
1869        }
1870        return Err(msg);
1871    }
1872
1873    let mut result = {
1874        let mut s = arc.lock().map_err(|e| e.to_string())?;
1875        pair_session_finalize(&mut s, 30).map_err(|e| e.to_string())?
1876    };
1877    store_remove(session_id);
1878
1879    // ---- Post-pair auto-setup (Goal: zero friction after SAS) ----
1880    // 1. Auto-subscribe to wire://inbox/<peer> so clients that support
1881    //    resources/subscribe get push notifications/resources/updated.
1882    // 2. Spawn `wire daemon` if not already running so push/pull is automatic.
1883    // 3. Spawn `wire notify` if not already running so OS toasts fire on
1884    //    inbox grow (covers MCP hosts that lack resources/subscribe).
1885    // 4. Emit notifications/resources/list_changed via the writer channel so
1886    //    a client that called resources/list before pairing refreshes its view.
1887    let peer_handle = result["peer_handle"].as_str().unwrap_or("").to_string();
1888    let peer_uri = format!("wire://inbox/{peer_handle}");
1889
1890    let mut auto = json!({
1891        "subscribed": false,
1892        "daemon": "unknown",
1893        "notify": "unknown",
1894        "resources_list_changed_emitted": false,
1895    });
1896
1897    if !peer_handle.is_empty()
1898        && let Ok(mut g) = state.subscribed.lock()
1899    {
1900        g.insert(peer_uri.clone());
1901        auto["subscribed"] = json!(true);
1902    }
1903
1904    auto["daemon"] = match crate::ensure_up::ensure_daemon_running() {
1905        Ok(true) => json!("spawned"),
1906        Ok(false) => json!("already_running"),
1907        Err(e) => json!(format!("spawn_error: {e}")),
1908    };
1909    auto["notify"] = match crate::ensure_up::ensure_notify_running() {
1910        Ok(true) => json!("spawned"),
1911        Ok(false) => json!("already_running"),
1912        Err(e) => json!(format!("spawn_error: {e}")),
1913    };
1914
1915    if let Some(tx) = state.notif_tx.lock().ok().and_then(|g| g.clone()) {
1916        let notif = json!({
1917            "jsonrpc": "2.0",
1918            "method": "notifications/resources/list_changed",
1919        });
1920        if tx.send(notif.to_string()).is_ok() {
1921            auto["resources_list_changed_emitted"] = json!(true);
1922        }
1923    }
1924
1925    result["auto"] = auto;
1926    result["next"] = json!(
1927        "Done. Daemon + notify running, subscribed to peer inbox. Use wire_send/wire_tail \
1928         freely; new events arrive via notifications/resources/updated (where supported) and \
1929         OS toasts (always)."
1930    );
1931    Ok(result)
1932}
1933
1934// ---------- detached pair tools (daemon-orchestrated) ----------
1935
1936fn tool_pair_initiate_detached(args: &Value) -> Result<Value, String> {
1937    auto_init_if_needed(args)?;
1938    let relay_url = resolve_relay_url(args)?;
1939    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1940        let _ = crate::ensure_up::ensure_daemon_running();
1941    }
1942    let code = crate::sas::generate_code_phrase();
1943    let code_hash = crate::pair_session::derive_code_hash(&code);
1944    let now = time::OffsetDateTime::now_utc()
1945        .format(&time::format_description::well_known::Rfc3339)
1946        .unwrap_or_default();
1947    let p = crate::pending_pair::PendingPair {
1948        code: code.clone(),
1949        code_hash,
1950        role: "host".to_string(),
1951        relay_url: relay_url.clone(),
1952        status: "request_host".to_string(),
1953        sas: None,
1954        peer_did: None,
1955        created_at: now,
1956        last_error: None,
1957        pair_id: None,
1958        our_slot_id: None,
1959        our_slot_token: None,
1960        spake2_seed_b64: None,
1961    };
1962    crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1963    Ok(json!({
1964        "code_phrase": code,
1965        "relay_url": relay_url,
1966        "state": "queued",
1967        "next": "Share code_phrase with the user. Subscribe to wire://pending-pair/all; when notifications/resources/updated arrives, read the resource and surface the SAS digits to the user once status=sas_ready. Then call wire_pair_confirm_detached with code_phrase + user_typed_digits."
1968    }))
1969}
1970
1971fn tool_pair_join_detached(args: &Value) -> Result<Value, String> {
1972    auto_init_if_needed(args)?;
1973    let relay_url = resolve_relay_url(args)?;
1974    let code_phrase = args
1975        .get("code_phrase")
1976        .and_then(Value::as_str)
1977        .ok_or("missing 'code_phrase'")?;
1978    let code = crate::sas::parse_code_phrase(code_phrase)
1979        .map_err(|e| e.to_string())?
1980        .to_string();
1981    let code_hash = crate::pair_session::derive_code_hash(&code);
1982    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1983        let _ = crate::ensure_up::ensure_daemon_running();
1984    }
1985    let now = time::OffsetDateTime::now_utc()
1986        .format(&time::format_description::well_known::Rfc3339)
1987        .unwrap_or_default();
1988    let p = crate::pending_pair::PendingPair {
1989        code: code.clone(),
1990        code_hash,
1991        role: "guest".to_string(),
1992        relay_url: relay_url.clone(),
1993        status: "request_guest".to_string(),
1994        sas: None,
1995        peer_did: None,
1996        created_at: now,
1997        last_error: None,
1998        pair_id: None,
1999        our_slot_id: None,
2000        our_slot_token: None,
2001        spake2_seed_b64: None,
2002    };
2003    crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
2004    Ok(json!({
2005        "code_phrase": code,
2006        "relay_url": relay_url,
2007        "state": "queued",
2008        "next": "Subscribe to wire://pending-pair/all; on sas_ready notification, surface digits to user and call wire_pair_confirm_detached."
2009    }))
2010}
2011
2012fn tool_pair_list_pending() -> Result<Value, String> {
2013    let items = crate::pending_pair::list_pending().map_err(|e| e.to_string())?;
2014    Ok(json!({"pending": items}))
2015}
2016
2017fn tool_pair_confirm_detached(args: &Value) -> Result<Value, String> {
2018    let code_phrase = args
2019        .get("code_phrase")
2020        .and_then(Value::as_str)
2021        .ok_or("missing 'code_phrase'")?;
2022    let typed = args
2023        .get("user_typed_digits")
2024        .and_then(Value::as_str)
2025        .ok_or("missing 'user_typed_digits'")?;
2026    let code = crate::sas::parse_code_phrase(code_phrase)
2027        .map_err(|e| e.to_string())?
2028        .to_string();
2029    let typed: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
2030    if typed.len() != 6 {
2031        return Err(format!(
2032            "expected 6 digits (got {} after stripping non-digits)",
2033            typed.len()
2034        ));
2035    }
2036    let mut p = crate::pending_pair::read_pending(&code)
2037        .map_err(|e| e.to_string())?
2038        .ok_or_else(|| format!("no pending pair for code {code}"))?;
2039    if p.status != "sas_ready" {
2040        return Err(format!(
2041            "pair {code} not in sas_ready state (current: {})",
2042            p.status
2043        ));
2044    }
2045    let stored = p
2046        .sas
2047        .as_ref()
2048        .ok_or("pending file has status=sas_ready but no sas field")?
2049        .clone();
2050    if stored == typed {
2051        p.status = "confirmed".to_string();
2052        crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
2053        Ok(json!({
2054            "state": "confirmed",
2055            "code_phrase": code,
2056            "next": "Daemon will finalize on its next tick (~1s). Poll wire_peers or watch wire://pending-pair/all for the entry to disappear."
2057        }))
2058    } else {
2059        p.status = "aborted".to_string();
2060        p.last_error = Some(format!(
2061            "SAS digit mismatch (typed {typed}, expected {stored})"
2062        ));
2063        let client = crate::relay_client::RelayClient::new(&p.relay_url);
2064        let _ = client.pair_abandon(&p.code_hash);
2065        let _ = crate::pending_pair::write_pending(&p);
2066        crate::os_notify::toast(
2067            &format!("wire — pair aborted ({code})"),
2068            p.last_error.as_deref().unwrap_or("digits mismatch"),
2069        );
2070        Err(
2071            "digits mismatch — pair aborted. Re-issue with wire_pair_initiate_detached."
2072                .to_string(),
2073        )
2074    }
2075}
2076
2077fn tool_pair_cancel_pending(args: &Value) -> Result<Value, String> {
2078    let code_phrase = args
2079        .get("code_phrase")
2080        .and_then(Value::as_str)
2081        .ok_or("missing 'code_phrase'")?;
2082    let code = crate::sas::parse_code_phrase(code_phrase)
2083        .map_err(|e| e.to_string())?
2084        .to_string();
2085    if let Some(p) = crate::pending_pair::read_pending(&code).map_err(|e| e.to_string())? {
2086        let client = crate::relay_client::RelayClient::new(&p.relay_url);
2087        let _ = client.pair_abandon(&p.code_hash);
2088    }
2089    crate::pending_pair::delete_pending(&code).map_err(|e| e.to_string())?;
2090    Ok(json!({"state": "cancelled", "code_phrase": code}))
2091}
2092
2093// ---------- invite-URL one-paste pair (v0.4.0) ----------
2094
2095fn tool_invite_mint(args: &Value) -> Result<Value, String> {
2096    let relay_url = args.get("relay_url").and_then(Value::as_str);
2097    let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
2098    let uses = args
2099        .get("uses")
2100        .and_then(Value::as_u64)
2101        .map(|u| u as u32)
2102        .unwrap_or(1);
2103    let url =
2104        crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
2105    let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
2106    Ok(json!({
2107        "invite_url": url,
2108        "ttl_secs": ttl_resolved,
2109        "uses": uses,
2110    }))
2111}
2112
2113fn tool_invite_accept(args: &Value) -> Result<Value, String> {
2114    let url = args
2115        .get("url")
2116        .and_then(Value::as_str)
2117        .ok_or("missing 'url'")?;
2118    crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
2119}
2120
2121// ---------- v0.5 — agentic hotline tools ----------
2122
2123/// wire_dial (MCP): mirror the CLI `dial` resolution ladder. The prior
2124/// wiring routed straight to `tool_add`, which reads a required `handle`
2125/// arg — but the wire_dial schema only provides `name`, so every dial
2126/// errored `missing 'handle'`. This reads `name` and routes:
2127///   • `<nick>@<relay>`  -> federation pair (via tool_add).
2128///   • already-pinned     -> no-op success (peer already reachable).
2129///   • otherwise          -> honest error. Bare-nickname / local-sister
2130///     resolution over MCP is not yet wired (CLI `wire dial` does it);
2131///     use `<nick>@<relay>` or `wire_send` (auto-pairs on miss).
2132fn tool_dial(args: &Value) -> Result<Value, String> {
2133    let name = args
2134        .get("name")
2135        .and_then(Value::as_str)
2136        .or_else(|| args.get("handle").and_then(Value::as_str))
2137        .ok_or("missing 'name'")?;
2138
2139    if name.contains('@') {
2140        // Federation path. Present `name` as the `handle` tool_add expects.
2141        let mut a = args.clone();
2142        if let Some(obj) = a.as_object_mut() {
2143            obj.insert("handle".into(), Value::String(name.to_string()));
2144        }
2145        return tool_add(&a);
2146    }
2147
2148    let relay_state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
2149    let pinned = relay_state
2150        .get("peers")
2151        .and_then(Value::as_object)
2152        .map(|m| m.contains_key(name))
2153        .unwrap_or(false);
2154    if pinned {
2155        return Ok(json!({
2156            "name_input": name,
2157            "status": "already_pinned",
2158            "peer_handle": name,
2159        }));
2160    }
2161
2162    Err(format!(
2163        "cannot resolve `{name}` over MCP: bare-nickname / local-sister dialling is not yet \
2164         wired into the MCP surface. Use a federation handle `{name}@<relay>`, or `wire_send` \
2165         (it auto-pairs on miss)."
2166    ))
2167}
2168
2169fn tool_add(args: &Value) -> Result<Value, String> {
2170    let handle = args
2171        .get("handle")
2172        .and_then(Value::as_str)
2173        .ok_or("missing 'handle'")?;
2174    let relay_override = args.get("relay_url").and_then(Value::as_str);
2175
2176    let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
2177
2178    // Ensure self has identity + relay slot (auto-inits if needed).
2179    let (our_did, our_relay, our_slot_id, our_slot_token) =
2180        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
2181
2182    // Resolve peer via .well-known.
2183    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
2184        .map_err(|e| format!("{e:#}"))?;
2185    let peer_card = resolved
2186        .get("card")
2187        .cloned()
2188        .ok_or("resolved missing card")?;
2189    let peer_did = resolved
2190        .get("did")
2191        .and_then(Value::as_str)
2192        .ok_or("resolved missing did")?
2193        .to_string();
2194    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
2195    let peer_slot_id = resolved
2196        .get("slot_id")
2197        .and_then(Value::as_str)
2198        .ok_or("resolved missing slot_id")?
2199        .to_string();
2200    let peer_relay = resolved
2201        .get("relay_url")
2202        .and_then(Value::as_str)
2203        .map(str::to_string)
2204        .or_else(|| relay_override.map(str::to_string))
2205        .unwrap_or_else(|| format!("https://{}", parsed.domain));
2206
2207    // Pin peer in trust + relay-state. slot_token arrives via ack later.
2208    let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
2209    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
2210    crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
2211    let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
2212    let existing_token = relay_state
2213        .get("peers")
2214        .and_then(|p| p.get(&peer_handle))
2215        .and_then(|p| p.get("slot_token"))
2216        .and_then(Value::as_str)
2217        .map(str::to_string)
2218        .unwrap_or_default();
2219    relay_state["peers"][&peer_handle] = json!({
2220        "relay_url": peer_relay,
2221        "slot_id": peer_slot_id,
2222        "slot_token": existing_token,
2223    });
2224    crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
2225
2226    // Build + sign pair_drop event (no nonce — open-mode handle pair).
2227    let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2228    let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
2229    let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
2230    let pk_b64 = our_card
2231        .get("verify_keys")
2232        .and_then(Value::as_object)
2233        .and_then(|m| m.values().next())
2234        .and_then(|v| v.get("key"))
2235        .and_then(Value::as_str)
2236        .ok_or("our card missing verify_keys[*].key")?;
2237    let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
2238    let now = time::OffsetDateTime::now_utc()
2239        .format(&time::format_description::well_known::Rfc3339)
2240        .unwrap_or_default();
2241    let event = json!({
2242        "timestamp": now,
2243        "from": our_did,
2244        "to": peer_did,
2245        "type": "pair_drop",
2246        "kind": 1100u32,
2247        "body": {
2248            "card": our_card,
2249            "relay_url": our_relay,
2250            "slot_id": our_slot_id,
2251            "slot_token": our_slot_token,
2252        },
2253    });
2254    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
2255        .map_err(|e| format!("{e:#}"))?;
2256
2257    let client = crate::relay_client::RelayClient::new(&peer_relay);
2258    let resp = client
2259        .handle_intro(&parsed.nick, &signed)
2260        .map_err(|e| format!("{e:#}"))?;
2261    let event_id = signed
2262        .get("event_id")
2263        .and_then(Value::as_str)
2264        .unwrap_or("")
2265        .to_string();
2266    Ok(json!({
2267        "handle": handle,
2268        "paired_with": peer_did,
2269        "peer_handle": peer_handle,
2270        "event_id": event_id,
2271        "drop_response": resp,
2272        "status": "drop_sent",
2273    }))
2274}
2275
2276/// v0.5.14: MCP `wire_pair_accept` — bilateral completion of a
2277/// pending-inbound pair request. The agent SHOULD have surfaced the
2278/// pending request to the operator before calling this; acceptance
2279/// grants peer authenticated write access to this agent's inbox.
2280fn tool_pair_accept(args: &Value) -> Result<Value, String> {
2281    let peer = args
2282        .get("peer")
2283        .and_then(Value::as_str)
2284        .ok_or("missing 'peer'")?;
2285    let nick = crate::agent_card::bare_handle(peer);
2286    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
2287        .map_err(|e| format!("{e:#}"))?
2288        .ok_or_else(|| {
2289            format!(
2290                "no pending pair request from {nick}. Call wire_pair_list_inbound to enumerate, \
2291                 or wire_add to send a fresh outbound pair request."
2292            )
2293        })?;
2294
2295    // Pin trust with VERIFIED — operator-equivalent consent gesture (the
2296    // agent is acting on the operator's instruction to accept).
2297    let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
2298    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
2299    crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
2300
2301    // Record peer's relay coords + slot_token from the stored drop.
2302    let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
2303    relay_state["peers"][&pending.peer_handle] = json!({
2304        "relay_url": pending.peer_relay_url,
2305        "slot_id": pending.peer_slot_id,
2306        "slot_token": pending.peer_slot_token,
2307    });
2308    crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
2309
2310    // Ship our slot_token via pair_drop_ack — Bug 2 fix: iterate the peer's
2311    // advertised endpoints in priority order, only fail if all are dead. The
2312    // pending record's `peer_endpoints` carries the full advertised list when
2313    // the pair_drop was written by a v0.5.17+ peer; fall back to a one-element
2314    // slice from the legacy triple for older records so we still hit the
2315    // failover helper with a valid input.
2316    let ack_endpoints: Vec<crate::endpoints::Endpoint> = if pending.peer_endpoints.is_empty() {
2317        vec![crate::endpoints::Endpoint::federation(
2318            pending.peer_relay_url.clone(),
2319            pending.peer_slot_id.clone(),
2320            pending.peer_slot_token.clone(),
2321        )]
2322    } else {
2323        pending.peer_endpoints.clone()
2324    };
2325    crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &ack_endpoints).map_err(|e| {
2326        format!(
2327            "pair_drop_ack send to {} (across {} endpoint(s)) failed: {e:#}",
2328            pending.peer_handle,
2329            ack_endpoints.len()
2330        )
2331    })?;
2332
2333    crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2334
2335    Ok(json!({
2336        "status": "bilateral_accepted",
2337        "peer_handle": pending.peer_handle,
2338        "peer_did": pending.peer_did,
2339        "peer_relay_url": pending.peer_relay_url,
2340        "via": "pending_inbound",
2341    }))
2342}
2343
2344/// v0.5.14: MCP `wire_pair_reject` — delete a pending-inbound record
2345/// without pairing. Peer never receives our slot_token. Idempotent.
2346fn tool_pair_reject(args: &Value) -> Result<Value, String> {
2347    let peer = args
2348        .get("peer")
2349        .and_then(Value::as_str)
2350        .ok_or("missing 'peer'")?;
2351    let nick = crate::agent_card::bare_handle(peer);
2352    let existed =
2353        crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2354    crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2355    Ok(json!({
2356        "peer": nick,
2357        "rejected": existed.is_some(),
2358        "had_pending": existed.is_some(),
2359    }))
2360}
2361
2362/// v0.5.14: MCP `wire_pair_list_inbound` — enumerate pending-inbound
2363/// pair requests for operator review. Flat array sorted oldest-first.
2364fn tool_pair_list_inbound() -> Result<Value, String> {
2365    let items =
2366        crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
2367    Ok(json!(items))
2368}
2369
2370fn tool_claim_handle(args: &Value) -> Result<Value, String> {
2371    let typed = args.get("nick").and_then(Value::as_str);
2372    let relay_override = args.get("relay_url").and_then(Value::as_str);
2373    let public_url = args.get("public_url").and_then(Value::as_str);
2374
2375    // Auto-init + ensure slot.
2376    let (_, our_relay, our_slot_id, our_slot_token) =
2377        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
2378    let claim_relay = relay_override.unwrap_or(&our_relay);
2379    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2380
2381    // One-name rule (v0.13.1): the claimed handle is ALWAYS the DID-derived
2382    // persona, so the phonebook entry can never drift from the agent-card
2383    // handle. `nick` is optional + advisory — a value that differs is ignored.
2384    // See cmd_claim for the rationale (closes the claim-path "two names" hole).
2385    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
2386    let canonical = crate::agent_card::display_handle_from_did(did).to_string();
2387    let nick = if canonical.is_empty() {
2388        typed.unwrap_or_default().to_string()
2389    } else {
2390        canonical
2391    };
2392    let typed_nick_ignored = typed.map(|t| t != nick).unwrap_or(false);
2393
2394    let client = crate::relay_client::RelayClient::new(claim_relay);
2395    let resp = client
2396        .handle_claim(&nick, &our_slot_id, &our_slot_token, public_url, &card)
2397        .map_err(|e| format!("{e:#}"))?;
2398    Ok(json!({
2399        "nick": nick,
2400        "relay": claim_relay,
2401        "response": resp,
2402        "one_name": true,
2403        "typed_nick_ignored": typed_nick_ignored,
2404    }))
2405}
2406
2407fn tool_whois(args: &Value) -> Result<Value, String> {
2408    if let Some(handle) = args.get("handle").and_then(Value::as_str) {
2409        // v0.14.x: mirror the CLI's resolution order. Bare nicks (no `@`)
2410        // route through the local resolver first (pinned peers + local
2411        // sister sessions); federation handles fall through to
2412        // `parse_handle` + remote resolution. Previously the MCP
2413        // surface only accepted federation-shaped handles and rejected
2414        // bare nicks with `missing '@' separator`, breaking
2415        // agent-side discovery of paired-but-not-federated peers.
2416        // Mirrors `cli::cmd_whois_local` for the local arms; mirrors
2417        // `cli::cmd_whois` for the federation arm.
2418        if !handle.contains('@')
2419            && let Ok(target) = crate::cli::resolve_name_to_target(handle)
2420        {
2421            return Ok(dial_target_to_whois_json(&target));
2422        }
2423        let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
2424        let relay_override = args.get("relay_url").and_then(Value::as_str);
2425        crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
2426    } else {
2427        // Self. v0.14.x: surface inline op claims so MCP whois stays in
2428        // parity with `wire whoami --json` / CLI self-whois (#114 + #115
2429        // shared the same helper).
2430        let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2431        let mut payload = serde_json::Map::new();
2432        payload.insert(
2433            "did".into(),
2434            card.get("did").cloned().unwrap_or(Value::Null),
2435        );
2436        payload.insert(
2437            "profile".into(),
2438            card.get("profile").cloned().unwrap_or(Value::Null),
2439        );
2440        for (k, v) in crate::cli::op_claims_from_card(&card) {
2441            payload.insert(k, v);
2442        }
2443        Ok(Value::Object(payload))
2444    }
2445}
2446
2447/// Convert a `cli::DialTarget` (the CLI's local-resolver hit) into the
2448/// JSON shape MCP whois callers expect. Mirrors the human-readable arms
2449/// of `cli::cmd_whois_local` but keyed for programmatic consumption.
2450/// Surfaces inline op claims from the peer's pinned card via the same
2451/// `op_claims_from_card` helper used everywhere else in v0.14.x.
2452fn dial_target_to_whois_json(target: &crate::cli::DialTarget) -> Value {
2453    use crate::cli::DialTarget;
2454    match target {
2455        DialTarget::PinnedPeer {
2456            handle,
2457            did,
2458            nickname,
2459            emoji,
2460            tier,
2461        } => {
2462            let op_claims = crate::config::read_trust()
2463                .ok()
2464                .and_then(|t| {
2465                    t.get("agents")
2466                        .and_then(Value::as_object)
2467                        .and_then(|m| m.get(handle))
2468                        .and_then(|a| a.get("card").cloned())
2469                })
2470                .map(|c| crate::cli::op_claims_from_card(&c))
2471                .unwrap_or_default();
2472            let mut payload = serde_json::Map::new();
2473            payload.insert("kind".into(), json!("pinned_peer"));
2474            payload.insert("handle".into(), json!(handle));
2475            payload.insert("did".into(), json!(did));
2476            payload.insert("nickname".into(), json!(nickname));
2477            payload.insert("emoji".into(), json!(emoji));
2478            payload.insert("tier".into(), json!(tier));
2479            for (k, v) in op_claims {
2480                payload.insert(k, v);
2481            }
2482            Value::Object(payload)
2483        }
2484        DialTarget::LocalSister {
2485            session_name,
2486            handle,
2487            did,
2488            nickname,
2489            emoji,
2490        } => json!({
2491            "kind": "local_sister",
2492            "session_name": session_name,
2493            "handle": handle,
2494            "did": did,
2495            "nickname": nickname,
2496            "emoji": emoji,
2497        }),
2498    }
2499}
2500
2501fn tool_profile_set(args: &Value) -> Result<Value, String> {
2502    let field = args
2503        .get("field")
2504        .and_then(Value::as_str)
2505        .ok_or("missing 'field'")?;
2506    let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
2507    // If value is a string that itself parses as JSON (e.g. "[\"rust\"]"),
2508    // unwrap it. Otherwise pass as-is. Lets agents send either typed values
2509    // or stringified JSON.
2510    let value = if let Some(s) = raw_value.as_str() {
2511        serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
2512    } else {
2513        raw_value
2514    };
2515    let new_profile =
2516        crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
2517    Ok(json!({
2518        "field": field,
2519        "profile": new_profile,
2520    }))
2521}
2522
2523fn tool_profile_get() -> Result<Value, String> {
2524    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2525    Ok(json!({
2526        "did": card.get("did").cloned().unwrap_or(Value::Null),
2527        "profile": card.get("profile").cloned().unwrap_or(Value::Null),
2528    }))
2529}
2530
2531// ---------- helpers ----------
2532
2533fn parse_kind(s: &str) -> u32 {
2534    if let Ok(n) = s.parse::<u32>() {
2535        return n;
2536    }
2537    for (id, name) in crate::signing::kinds() {
2538        if *name == s {
2539            return *id;
2540        }
2541    }
2542    1
2543}
2544
2545fn error_response(id: &Value, code: i32, message: &str) -> Value {
2546    json!({
2547        "jsonrpc": "2.0",
2548        "id": id,
2549        "error": {"code": code, "message": message}
2550    })
2551}
2552
2553#[cfg(test)]
2554mod tests {
2555    use super::*;
2556
2557    #[test]
2558    fn unknown_method_returns_jsonrpc_error() {
2559        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
2560        let resp = handle_request(&req, &McpState::default());
2561        assert_eq!(resp["error"]["code"], -32601);
2562    }
2563
2564    #[test]
2565    fn initialize_advertises_tools_capability() {
2566        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
2567        let resp = handle_request(&req, &McpState::default());
2568        assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
2569        assert!(resp["result"]["capabilities"]["tools"].is_object());
2570        assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
2571    }
2572
2573    #[test]
2574    fn tools_list_includes_pairing_and_messaging() {
2575        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2576        let resp = handle_request(&req, &McpState::default());
2577        let names: Vec<&str> = resp["result"]["tools"]
2578            .as_array()
2579            .unwrap()
2580            .iter()
2581            .filter_map(|t| t["name"].as_str())
2582            .collect();
2583        for required in [
2584            "wire_whoami",
2585            "wire_peers",
2586            "wire_send",
2587            "wire_tail",
2588            "wire_verify",
2589            "wire_init",
2590            "wire_pair_initiate",
2591            "wire_pair_join",
2592            "wire_pair_check",
2593            "wire_pair_confirm",
2594        ] {
2595            assert!(
2596                names.contains(&required),
2597                "missing required tool {required}"
2598            );
2599        }
2600        // wire_join (the old direct alias for pair-join, no SAS-typeback) is
2601        // explicitly NOT in the catalog. Calling it returns a deprecation
2602        // pointing to wire_pair_join (test below covers this).
2603        assert!(
2604            !names.contains(&"wire_join"),
2605            "wire_join must not be advertised — superseded by wire_pair_join"
2606        );
2607    }
2608
2609    #[test]
2610    fn legacy_wire_join_call_returns_helpful_error() {
2611        let req = json!({
2612            "jsonrpc": "2.0",
2613            "id": 1,
2614            "method": "tools/call",
2615            "params": {"name": "wire_join", "arguments": {}}
2616        });
2617        let resp = handle_request(&req, &McpState::default());
2618        assert_eq!(resp["result"]["isError"], true);
2619        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2620        assert!(
2621            text.contains("wire_pair_join"),
2622            "expected redirect to wire_pair_join, got: {text}"
2623        );
2624    }
2625
2626    #[test]
2627    fn pair_confirm_missing_session_id_errors_cleanly() {
2628        let req = json!({
2629            "jsonrpc": "2.0",
2630            "id": 1,
2631            "method": "tools/call",
2632            "params": {"name": "wire_pair_confirm", "arguments": {"user_typed_digits": "111111"}}
2633        });
2634        let resp = handle_request(&req, &McpState::default());
2635        assert_eq!(resp["result"]["isError"], true);
2636    }
2637
2638    #[test]
2639    fn pair_confirm_unknown_session_errors_cleanly() {
2640        let req = json!({
2641            "jsonrpc": "2.0",
2642            "id": 1,
2643            "method": "tools/call",
2644            "params": {
2645                "name": "wire_pair_confirm",
2646                "arguments": {"session_id": "definitely-not-real", "user_typed_digits": "111111"}
2647            }
2648        });
2649        let resp = handle_request(&req, &McpState::default());
2650        assert_eq!(resp["result"]["isError"], true);
2651        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2652        assert!(text.contains("no such session_id"), "got: {text}");
2653    }
2654
2655    #[test]
2656    fn initialize_advertises_resources_capability() {
2657        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
2658        let resp = handle_request(&req, &McpState::default());
2659        let caps = &resp["result"]["capabilities"];
2660        assert!(
2661            caps["resources"].is_object(),
2662            "resources capability must be present, got {resp}"
2663        );
2664        assert_eq!(
2665            caps["resources"]["subscribe"], true,
2666            "subscribe shipped in v0.2.1"
2667        );
2668    }
2669
2670    #[test]
2671    fn resources_read_with_bad_uri_errors() {
2672        let req = json!({
2673            "jsonrpc": "2.0",
2674            "id": 1,
2675            "method": "resources/read",
2676            "params": {"uri": "http://example.com/not-a-wire-uri"}
2677        });
2678        let resp = handle_request(&req, &McpState::default());
2679        assert!(resp.get("error").is_some(), "expected error, got {resp}");
2680    }
2681
2682    #[test]
2683    fn parse_inbox_uri_handles_variants() {
2684        assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
2685        assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
2686        assert!(
2687            parse_inbox_uri("wire://inbox/")
2688                .unwrap()
2689                .starts_with("__invalid__"),
2690            "empty peer must be invalid"
2691        );
2692        assert!(
2693            parse_inbox_uri("http://other")
2694                .unwrap()
2695                .starts_with("__invalid__"),
2696            "non-wire scheme must be invalid"
2697        );
2698    }
2699
2700    #[test]
2701    fn ping_returns_empty_result() {
2702        let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2703        let resp = handle_request(&req, &McpState::default());
2704        assert_eq!(resp["id"], 7);
2705        assert!(resp["result"].is_object());
2706    }
2707
2708    #[test]
2709    fn notification_returns_null_no_reply() {
2710        let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2711        let resp = handle_request(&req, &McpState::default());
2712        assert_eq!(resp, Value::Null);
2713    }
2714
2715    /// v0.6.1 regression: `detect_session_wire_home` must return the
2716    /// session's home dir when the cwd is in the registry AND the
2717    /// session dir exists on disk. The original v0.6.1 shipped with
2718    /// only an eprintln "verification" — this test asserts the
2719    /// observable return value so the env-set-but-not-consumed class
2720    /// of bug fails loudly.
2721    #[test]
2722    fn detect_session_wire_home_resolves_registered_cwd() {
2723        crate::config::test_support::with_temp_home(|| {
2724            // Set up sessions/registry.json + sessions/test-alpha/ under
2725            // the temp WIRE_HOME so session::read_registry +
2726            // session::session_dir resolve through it.
2727            let wire_home = std::env::var("WIRE_HOME").unwrap();
2728            let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2729            let session_home = sessions_root.join("test-alpha");
2730            std::fs::create_dir_all(&session_home).unwrap();
2731            let fake_cwd = "/tmp/fake-project-cwd-abc123";
2732            let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2733            std::fs::write(
2734                sessions_root.join("registry.json"),
2735                serde_json::to_vec_pretty(&registry).unwrap(),
2736            )
2737            .unwrap();
2738
2739            // Hit happy path.
2740            let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2741            assert_eq!(
2742                got.as_deref(),
2743                Some(session_home.as_path()),
2744                "registered cwd must resolve to session_home"
2745            );
2746
2747            // Unregistered cwd → None.
2748            let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2749                "/tmp/cwd-not-in-registry-xyz789",
2750            ));
2751            assert!(nope.is_none(), "unregistered cwd must return None");
2752
2753            // Registered cwd but session dir missing → None (defensive:
2754            // stale registry entry pointing at a deleted session).
2755            let stale_cwd = "/tmp/stale-session-cwd";
2756            let stale_registry =
2757                json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2758            std::fs::write(
2759                sessions_root.join("registry.json"),
2760                serde_json::to_vec_pretty(&stale_registry).unwrap(),
2761            )
2762            .unwrap();
2763            let stale_got =
2764                crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2765            assert!(
2766                stale_got.is_none(),
2767                "registered cwd whose session dir is missing must return None"
2768            );
2769        });
2770    }
2771
2772    // v0.14.x: shape tests for `dial_target_to_whois_json`. The MCP whois
2773    // bare-nick fix routes through `cli::resolve_name_to_target` (returns
2774    // a `DialTarget`) and reshapes it for JSON-RPC consumption. These
2775    // tests pin the response shape so a future refactor of either side
2776    // (resolver or wire shape) catches the contract drift.
2777
2778    #[test]
2779    fn dial_target_to_whois_json_pinned_peer_shape() {
2780        let target = crate::cli::DialTarget::PinnedPeer {
2781            handle: "slate-lotus".into(),
2782            did: "did:wire:slate-lotus-88232017".into(),
2783            nickname: Some("slate-lotus".into()),
2784            emoji: Some("🪴".into()),
2785            tier: "VERIFIED".into(),
2786        };
2787        crate::config::test_support::with_temp_home(|| {
2788            let out = dial_target_to_whois_json(&target);
2789            assert_eq!(out.get("kind").and_then(Value::as_str), Some("pinned_peer"));
2790            assert_eq!(
2791                out.get("handle").and_then(Value::as_str),
2792                Some("slate-lotus")
2793            );
2794            assert_eq!(out.get("tier").and_then(Value::as_str), Some("VERIFIED"));
2795            // op claims are absent when trust.json has no row for this
2796            // peer (the helper falls through to an empty map). No
2797            // spurious `null` op_did keys.
2798            assert!(out.get("op_did").is_none());
2799        });
2800    }
2801
2802    #[test]
2803    fn dial_target_to_whois_json_local_sister_shape() {
2804        let target = crate::cli::DialTarget::LocalSister {
2805            session_name: "vesper-valley".into(),
2806            handle: "vesper-valley".into(),
2807            did: Some("did:wire:vesper-valley-deadbeef".into()),
2808            nickname: Some("vesper-valley".into()),
2809            emoji: Some("🦌".into()),
2810        };
2811        let out = dial_target_to_whois_json(&target);
2812        assert_eq!(
2813            out.get("kind").and_then(Value::as_str),
2814            Some("local_sister")
2815        );
2816        assert_eq!(
2817            out.get("session_name").and_then(Value::as_str),
2818            Some("vesper-valley")
2819        );
2820        assert_eq!(
2821            out.get("did").and_then(Value::as_str),
2822            Some("did:wire:vesper-valley-deadbeef")
2823        );
2824        // LocalSister carries no card → no op_claims path. Spot-check
2825        // no leakage from the PinnedPeer arm.
2826        assert!(out.get("tier").is_none());
2827        assert!(out.get("op_did").is_none());
2828    }
2829}