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());
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). 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): 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 instead of on next manual poll. 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.) 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_send",
577            "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.",
578            "inputSchema": {
579                "type": "object",
580                "properties": {
581                    "peer": {"type": "string", "description": "Peer handle (without did:wire: prefix). Must be a pinned peer; check wire_peers first."},
582                    "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."},
583                    "body": {"type": "string", "description": "Event body. Plain text becomes a JSON string; valid JSON is parsed and embedded structurally."},
584                    "time_sensitive_until": {"type": "string", "description": "Optional advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp."}
585                },
586                "required": ["peer", "kind", "body"]
587            }
588        }),
589        json!({
590            "name": "wire_tail",
591            "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.",
592            "inputSchema": {
593                "type": "object",
594                "properties": {
595                    "peer": {"type": "string", "description": "Optional peer handle to filter inbox by."},
596                    "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Max events to return."}
597                },
598                "required": []
599            }
600        }),
601        json!({
602            "name": "wire_verify",
603            "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).",
604            "inputSchema": {
605                "type": "object",
606                "properties": {
607                    "event": {"type": "string", "description": "JSON-encoded signed event."}
608                },
609                "required": ["event"]
610            }
611        }),
612        json!({
613            "name": "wire_init",
614            "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.",
615            "inputSchema": {
616                "type": "object",
617                "properties": {
618                    "handle": {"type": "string", "description": "Short handle (becomes did:wire:<handle>). ASCII alphanumeric / '-' / '_' only."},
619                    "name": {"type": "string", "description": "Optional display name (defaults to capitalized handle)."},
620                    "relay_url": {"type": "string", "description": "Optional relay URL — if set, also binds a relay slot."}
621                },
622                "required": ["handle"]
623            }
624        }),
625        json!({
626            "name": "wire_pair_initiate",
627            "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).",
628            "inputSchema": {
629                "type": "object",
630                "properties": {
631                    "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
632                    "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
633                    "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."}
634                },
635                "required": []
636            }
637        }),
638        json!({
639            "name": "wire_pair_join",
640            "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.",
641            "inputSchema": {
642                "type": "object",
643                "properties": {
644                    "code_phrase": {"type": "string", "description": "Code phrase from the host (e.g. '73-2QXC4P')."},
645                    "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
646                    "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
647                    "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 30, "description": "How long to block waiting for SPAKE2 exchange to complete."}
648                },
649                "required": ["code_phrase"]
650            }
651        }),
652        json!({
653            "name": "wire_pair_check",
654            "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.",
655            "inputSchema": {
656                "type": "object",
657                "properties": {
658                    "session_id": {"type": "string"},
659                    "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 8}
660                },
661                "required": ["session_id"]
662            }
663        }),
664        json!({
665            "name": "wire_pair_confirm",
666            "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').",
667            "inputSchema": {
668                "type": "object",
669                "properties": {
670                    "session_id": {"type": "string"},
671                    "user_typed_digits": {"type": "string", "description": "The 6 SAS digits the user typed back, e.g. '384217' or '384-217'."}
672                },
673                "required": ["session_id", "user_typed_digits"]
674            }
675        }),
676        json!({
677            "name": "wire_pair_initiate_detached",
678            "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.",
679            "inputSchema": {
680                "type": "object",
681                "properties": {
682                    "handle": {"type": "string", "description": "Optional handle for auto-init (idempotent)."},
683                    "relay_url": {"type": "string"}
684                }
685            }
686        }),
687        json!({
688            "name": "wire_pair_join_detached",
689            "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.",
690            "inputSchema": {
691                "type": "object",
692                "properties": {
693                    "handle": {"type": "string"},
694                    "code_phrase": {"type": "string"},
695                    "relay_url": {"type": "string"}
696                },
697                "required": ["code_phrase"]
698            }
699        }),
700        json!({
701            "name": "wire_pair_list_pending",
702            "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.",
703            "inputSchema": {"type": "object", "properties": {}}
704        }),
705        json!({
706            "name": "wire_pair_confirm_detached",
707            "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.",
708            "inputSchema": {
709                "type": "object",
710                "properties": {
711                    "code_phrase": {"type": "string"},
712                    "user_typed_digits": {"type": "string"}
713                },
714                "required": ["code_phrase", "user_typed_digits"]
715            }
716        }),
717        json!({
718            "name": "wire_pair_cancel_pending",
719            "description": "Cancel a pending detached pair. Releases the relay slot and removes the local pending file. Safe to call regardless of current status (idempotent).",
720            "inputSchema": {
721                "type": "object",
722                "properties": {"code_phrase": {"type": "string"}},
723                "required": ["code_phrase"]
724            }
725        }),
726        json!({
727            "name": "wire_invite_mint",
728            "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}.",
729            "inputSchema": {
730                "type": "object",
731                "properties": {
732                    "relay_url": {"type": "string", "description": "Override relay for first-time auto-allocate."},
733                    "ttl_secs": {"type": "integer", "description": "Invite lifetime in seconds (default 86400)."},
734                    "uses": {"type": "integer", "description": "Number of distinct peers that can accept before consumption (default 1)."}
735                }
736            }
737        }),
738        json!({
739            "name": "wire_invite_accept",
740            "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}.",
741            "inputSchema": {
742                "type": "object",
743                "properties": {
744                    "url": {"type": "string", "description": "Full wire://pair?v=1&inv=... URL."}
745                },
746                "required": ["url"]
747            }
748        }),
749        // v0.5 — agentic hotline.
750        json!({
751            "name": "wire_add",
752            "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.",
753            "inputSchema": {
754                "type": "object",
755                "properties": {
756                    "handle": {"type": "string", "description": "Peer handle like `nick@domain`."},
757                    "relay_url": {"type": "string", "description": "Override resolver URL (default: `https://<domain>`)."}
758                },
759                "required": ["handle"]
760            }
761        }),
762        json!({
763            "name": "wire_pair_accept",
764            "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.",
765            "inputSchema": {
766                "type": "object",
767                "properties": {
768                    "peer": {"type": "string", "description": "Bare peer handle (without `@<relay>`). Match exactly what `wire_pair_list_inbound` returned in `peer_handle`."}
769                },
770                "required": ["peer"]
771            }
772        }),
773        json!({
774            "name": "wire_pair_reject",
775            "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.",
776            "inputSchema": {
777                "type": "object",
778                "properties": {
779                    "peer": {"type": "string", "description": "Bare peer handle (without `@<relay>`)."}
780                },
781                "required": ["peer"]
782            }
783        }),
784        json!({
785            "name": "wire_pair_list_inbound",
786            "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.",
787            "inputSchema": {"type": "object", "properties": {}}
788        }),
789        // v0.10.1: canonical MCP names mirroring the operator-facing
790        // verbs (wire dial / accept / reject / pending). Old wire_pair_*
791        // names stay callable as aliases (see dispatch); these new
792        // entries are what appears in tools/list for new clients.
793        json!({
794            "name": "wire_dial",
795            "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.",
796            "inputSchema": {
797                "type": "object",
798                "properties": {
799                    "name": {"type": "string", "description": "Peer name — character nickname / session / handle / DID / `<handle>@<relay>`."}
800                },
801                "required": ["name"]
802            }
803        }),
804        json!({
805            "name": "wire_accept",
806            "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.",
807            "inputSchema": {
808                "type": "object",
809                "properties": {
810                    "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle, from wire_pending)."}
811                },
812                "required": ["peer"]
813            }
814        }),
815        json!({
816            "name": "wire_reject",
817            "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.",
818            "inputSchema": {
819                "type": "object",
820                "properties": {
821                    "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle)."}
822                },
823                "required": ["peer"]
824            }
825        }),
826        json!({
827            "name": "wire_pending",
828            "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.",
829            "inputSchema": {"type": "object", "properties": {}}
830        }),
831        json!({
832            "name": "wire_claim",
833            "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).",
834            "inputSchema": {
835                "type": "object",
836                "properties": {
837                    "nick": {"type": "string", "description": "Optional + advisory. Ignored if it differs from your DID-derived persona (one-name rule)."},
838                    "relay_url": {"type": "string", "description": "Relay to claim on. Default = our relay."},
839                    "public_url": {"type": "string", "description": "Public URL the relay should advertise to resolvers."}
840                }
841            }
842        }),
843        json!({
844            "name": "wire_whois",
845            "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.",
846            "inputSchema": {
847                "type": "object",
848                "properties": {
849                    "handle": {"type": "string", "description": "Optional `nick@domain`. Omit for self."},
850                    "relay_url": {"type": "string", "description": "Override resolver URL."}
851                }
852            }
853        }),
854        json!({
855            "name": "wire_profile_set",
856            "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.",
857            "inputSchema": {
858                "type": "object",
859                "properties": {
860                    "field": {"type": "string", "description": "One of: display_name, emoji, motto, vibe, pronouns, avatar_url, handle, now."},
861                    "value": {"description": "String for most fields; array for vibe; object for now. Pass JSON null to clear a field."}
862                },
863                "required": ["field", "value"]
864            }
865        }),
866        json!({
867            "name": "wire_profile_get",
868            "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.",
869            "inputSchema": {"type": "object", "properties": {}}
870        }),
871        // ---- group chat (v0.13.4): a group is a shared relay-room slot; the
872        // creator-signed roster carries member keys so members verify each
873        // other without pairing. GroupTier (creator/member/introduced) is a
874        // SEPARATE axis from bilateral peer trust. ----
875        json!({
876            "name": "wire_group_create",
877            "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.",
878            "inputSchema": {
879                "type": "object",
880                "properties": {"name": {"type": "string", "description": "Human label for the group."}},
881                "required": ["name"]
882            }
883        }),
884        json!({
885            "name": "wire_group_add",
886            "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.",
887            "inputSchema": {
888                "type": "object",
889                "properties": {
890                    "group": {"type": "string", "description": "Group id or name."},
891                    "peer": {"type": "string", "description": "Handle of a VERIFIED pinned peer."}
892                },
893                "required": ["group", "peer"]
894            }
895        }),
896        json!({
897            "name": "wire_group_send",
898            "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).",
899            "inputSchema": {
900                "type": "object",
901                "properties": {
902                    "group": {"type": "string", "description": "Group id or name."},
903                    "message": {"type": "string", "description": "Message text."}
904                },
905                "required": ["group", "message"]
906            }
907        }),
908        json!({
909            "name": "wire_group_tail",
910            "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.",
911            "inputSchema": {
912                "type": "object",
913                "properties": {
914                    "group": {"type": "string", "description": "Group id or name."},
915                    "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 20, "description": "Max timeline entries to return."}
916                },
917                "required": ["group"]
918            }
919        }),
920        json!({
921            "name": "wire_group_list",
922            "description": "List the groups this agent is in, with each group's members and their GroupTiers (creator/member/introduced). Read-only, local.",
923            "inputSchema": {"type": "object", "properties": {}, "required": []}
924        }),
925        json!({
926            "name": "wire_group_invite",
927            "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.",
928            "inputSchema": {
929                "type": "object",
930                "properties": {"group": {"type": "string", "description": "Group id or name."}},
931                "required": ["group"]
932            }
933        }),
934        json!({
935            "name": "wire_group_join",
936            "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.",
937            "inputSchema": {
938                "type": "object",
939                "properties": {"code": {"type": "string", "description": "The `wire-group:` join code."}},
940                "required": ["code"]
941            }
942        }),
943    ]
944}
945
946fn handle_tools_call(id: &Value, params: &Value, state: &McpState) -> Value {
947    let name = match params.get("name").and_then(Value::as_str) {
948        Some(n) => n,
949        None => return error_response(id, -32602, "missing tool name"),
950    };
951    let args = params
952        .get("arguments")
953        .cloned()
954        .unwrap_or_else(|| json!({}));
955
956    let result = match name {
957        "wire_whoami" => tool_whoami(),
958        "wire_peers" => tool_peers(),
959        "wire_send" => tool_send(&args),
960        "wire_tail" => tool_tail(&args),
961        "wire_verify" => tool_verify(&args),
962        "wire_init" => tool_init(&args),
963        "wire_pair_initiate" => tool_pair_initiate(&args),
964        "wire_pair_join" => tool_pair_join(&args),
965        "wire_pair_check" => tool_pair_check(&args),
966        "wire_pair_confirm" => tool_pair_confirm(&args, state),
967        "wire_pair_initiate_detached" => tool_pair_initiate_detached(&args),
968        "wire_pair_join_detached" => tool_pair_join_detached(&args),
969        "wire_pair_list_pending" => tool_pair_list_pending(),
970        "wire_pair_confirm_detached" => tool_pair_confirm_detached(&args),
971        "wire_pair_cancel_pending" => tool_pair_cancel_pending(&args),
972        "wire_invite_mint" => tool_invite_mint(&args),
973        "wire_invite_accept" => tool_invite_accept(&args),
974        // v0.5 — agentic hotline (handle + profile + zero-paste discovery).
975        "wire_add" => tool_add(&args),
976        // v0.5.14 — bilateral-required pair: inbound queue management.
977        // v0.10.1: canonical names introduced (wire_accept, wire_reject,
978        // wire_pending, wire_dial); legacy wire_pair_* names stay as
979        // aliases for back-compat. Both surface in tools/list with
980        // legacy descriptions tagged DEPRECATED.
981        "wire_pair_accept" | "wire_accept" => tool_pair_accept(&args),
982        "wire_pair_reject" | "wire_reject" => tool_pair_reject(&args),
983        "wire_pair_list_inbound" | "wire_pending" => tool_pair_list_inbound(),
984        "wire_dial" => tool_dial(&args),
985        "wire_claim" => tool_claim_handle(&args),
986        "wire_whois" => tool_whois(&args),
987        "wire_profile_set" => tool_profile_set(&args),
988        "wire_profile_get" => tool_profile_get(),
989        // v0.13.4 — group chat (shared-room slot + introduce-on-vouch).
990        "wire_group_create" => tool_group_create(&args),
991        "wire_group_add" => tool_group_add(&args),
992        "wire_group_send" => tool_group_send(&args),
993        "wire_group_tail" => tool_group_tail(&args),
994        "wire_group_list" => tool_group_list(),
995        "wire_group_invite" => tool_group_invite(&args),
996        "wire_group_join" => tool_group_join(&args),
997        // Legacy alias kept for older agent prompts that reference `wire_join`.
998        // Surfaces the operator-friendly error pointing to wire_pair_join.
999        "wire_join" => Err(
1000            "wire_join was renamed to wire_pair_join (use code_phrase argument). \
1001             See docs/AGENT_INTEGRATION.md."
1002                .into(),
1003        ),
1004        other => Err(format!("unknown tool: {other}")),
1005    };
1006
1007    match result {
1008        Ok(value) => json!({
1009            "jsonrpc": "2.0",
1010            "id": id,
1011            "result": {
1012                "content": [{
1013                    "type": "text",
1014                    "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
1015                }],
1016                "isError": false
1017            }
1018        }),
1019        Err(message) => json!({
1020            "jsonrpc": "2.0",
1021            "id": id,
1022            "result": {
1023                "content": [{"type": "text", "text": message}],
1024                "isError": true
1025            }
1026        }),
1027    }
1028}
1029
1030// ---------- tool implementations ----------
1031
1032fn tool_whoami() -> Result<Value, String> {
1033    use crate::config;
1034    use crate::signing::{b64decode, fingerprint, make_key_id};
1035
1036    if !config::is_initialized().map_err(|e| e.to_string())? {
1037        return Err("not initialized — operator must run `wire init <handle>` first".into());
1038    }
1039    let card = config::read_agent_card().map_err(|e| e.to_string())?;
1040    let did = card
1041        .get("did")
1042        .and_then(Value::as_str)
1043        .unwrap_or("")
1044        .to_string();
1045    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1046    let pk_b64 = card
1047        .get("verify_keys")
1048        .and_then(Value::as_object)
1049        .and_then(|m| m.values().next())
1050        .and_then(|v| v.get("key"))
1051        .and_then(Value::as_str)
1052        .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
1053    let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1054    let fp = fingerprint(&pk_bytes);
1055    let key_id = make_key_id(&handle, &pk_bytes);
1056    let capabilities = card
1057        .get("capabilities")
1058        .cloned()
1059        .unwrap_or_else(|| json!(["wire/v3.1"]));
1060    // v0.12: surface the DID-derived persona (nickname + emoji + palette)
1061    // that the CLI `wire whoami`/`here` already emit, so agents and toasts
1062    // see the persona, not just the raw handle.
1063    let persona =
1064        serde_json::to_value(crate::character::Character::from_card(&card)).unwrap_or(Value::Null);
1065    Ok(json!({
1066        "did": did,
1067        "handle": handle,
1068        "persona": persona,
1069        "fingerprint": fp,
1070        "key_id": key_id,
1071        "public_key_b64": pk_b64,
1072        "capabilities": capabilities,
1073    }))
1074}
1075
1076fn tool_peers() -> Result<Value, String> {
1077    use crate::config;
1078    use crate::trust::get_tier;
1079
1080    let trust = config::read_trust().map_err(|e| e.to_string())?;
1081    let agents = trust
1082        .get("agents")
1083        .and_then(Value::as_object)
1084        .cloned()
1085        .unwrap_or_default();
1086    let mut self_did: Option<String> = None;
1087    if let Ok(card) = config::read_agent_card() {
1088        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
1089    }
1090    let mut peers = Vec::new();
1091    for (handle, agent) in agents.iter() {
1092        let did = agent
1093            .get("did")
1094            .and_then(Value::as_str)
1095            .unwrap_or("")
1096            .to_string();
1097        if Some(did.as_str()) == self_did.as_deref() {
1098            continue;
1099        }
1100        // v0.12: include the persona (respecting the peer's advertised
1101        // override when their card carries one, else DID-derived) so MCP
1102        // callers render the nickname/emoji instead of the raw handle.
1103        let persona = match agent.get("card") {
1104            Some(c) => crate::character::Character::from_card(c),
1105            None => crate::character::Character::from_did(&did),
1106        };
1107        peers.push(json!({
1108            "handle": handle,
1109            "persona": serde_json::to_value(&persona).unwrap_or(Value::Null),
1110            "did": did,
1111            "tier": get_tier(&trust, handle),
1112            "capabilities": agent.get("card").and_then(|c| c.get("capabilities")).cloned().unwrap_or_else(|| json!([])),
1113        }));
1114    }
1115    Ok(json!(peers))
1116}
1117
1118/// Run `wire group <args> --json` by spawning this same binary, inheriting the
1119/// MCP session's WIRE_* env so it resolves the same identity/home. Group ops are
1120/// infrequent, so this reuses the exact, tested CLI logic — including the
1121/// verification-sensitive invite/join paths — rather than duplicating it here.
1122fn group_cli_json(args: &[&str]) -> Result<Value, String> {
1123    let exe = std::env::current_exe().map_err(|e| format!("locating wire binary: {e}"))?;
1124    let out = std::process::Command::new(exe)
1125        .arg("group")
1126        .args(args)
1127        .arg("--json")
1128        .env("WIRE_QUIET_AUTOSESSION", "1") // suppress the adopt-session stderr line
1129        .output()
1130        .map_err(|e| format!("spawning `wire group`: {e}"))?;
1131    if !out.status.success() {
1132        let err = String::from_utf8_lossy(&out.stderr);
1133        return Err(err.trim().to_string());
1134    }
1135    let s = String::from_utf8_lossy(&out.stdout);
1136    // Last JSON object line is the result (any adopt chatter went to stderr).
1137    let line = s
1138        .lines()
1139        .rev()
1140        .find(|l| l.trim_start().starts_with('{'))
1141        .unwrap_or("{}");
1142    serde_json::from_str(line).map_err(|e| format!("parsing `wire group` output: {e}"))
1143}
1144
1145fn tool_group_create(args: &Value) -> Result<Value, String> {
1146    let name = args
1147        .get("name")
1148        .and_then(Value::as_str)
1149        .ok_or("missing 'name'")?;
1150    group_cli_json(&["create", name])
1151}
1152
1153fn tool_group_add(args: &Value) -> Result<Value, String> {
1154    let group = args
1155        .get("group")
1156        .and_then(Value::as_str)
1157        .ok_or("missing 'group'")?;
1158    let peer = args
1159        .get("peer")
1160        .and_then(Value::as_str)
1161        .ok_or("missing 'peer'")?;
1162    group_cli_json(&["add", group, peer])
1163}
1164
1165fn tool_group_send(args: &Value) -> Result<Value, String> {
1166    let group = args
1167        .get("group")
1168        .and_then(Value::as_str)
1169        .ok_or("missing 'group'")?;
1170    let message = args
1171        .get("message")
1172        .and_then(Value::as_str)
1173        .ok_or("missing 'message'")?;
1174    group_cli_json(&["send", group, message])
1175}
1176
1177fn tool_group_tail(args: &Value) -> Result<Value, String> {
1178    let group = args
1179        .get("group")
1180        .and_then(Value::as_str)
1181        .ok_or("missing 'group'")?;
1182    if let Some(n) = args.get("limit").and_then(Value::as_u64) {
1183        group_cli_json(&["tail", group, "--limit", &n.to_string()])
1184    } else {
1185        group_cli_json(&["tail", group])
1186    }
1187}
1188
1189fn tool_group_list() -> Result<Value, String> {
1190    group_cli_json(&["list"])
1191}
1192
1193fn tool_group_invite(args: &Value) -> Result<Value, String> {
1194    let group = args
1195        .get("group")
1196        .and_then(Value::as_str)
1197        .ok_or("missing 'group'")?;
1198    group_cli_json(&["invite", group])
1199}
1200
1201fn tool_group_join(args: &Value) -> Result<Value, String> {
1202    let code = args
1203        .get("code")
1204        .and_then(Value::as_str)
1205        .ok_or("missing 'code'")?;
1206    group_cli_json(&["join", code])
1207}
1208
1209fn tool_send(args: &Value) -> Result<Value, String> {
1210    use crate::config;
1211    use crate::signing::{b64decode, sign_message_v31};
1212
1213    let peer = args
1214        .get("peer")
1215        .and_then(Value::as_str)
1216        .ok_or("missing 'peer'")?;
1217    let peer = crate::agent_card::bare_handle(peer);
1218    let kind = args
1219        .get("kind")
1220        .and_then(Value::as_str)
1221        .ok_or("missing 'kind'")?;
1222    let body = args
1223        .get("body")
1224        .and_then(Value::as_str)
1225        .ok_or("missing 'body'")?;
1226    let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
1227
1228    if !config::is_initialized().map_err(|e| e.to_string())? {
1229        return Err("not initialized — operator must run `wire init <handle>` first".into());
1230    }
1231    let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
1232    let card = config::read_agent_card().map_err(|e| e.to_string())?;
1233    let did = card
1234        .get("did")
1235        .and_then(Value::as_str)
1236        .unwrap_or("")
1237        .to_string();
1238    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1239    let pk_b64 = card
1240        .get("verify_keys")
1241        .and_then(Value::as_object)
1242        .and_then(|m| m.values().next())
1243        .and_then(|v| v.get("key"))
1244        .and_then(Value::as_str)
1245        .ok_or("agent-card missing verify_keys[*].key")?;
1246    let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1247
1248    // Body parses as JSON if possible, else stays a string.
1249    let body_value: Value =
1250        serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1251    let kind_id = parse_kind(kind);
1252
1253    let now = time::OffsetDateTime::now_utc()
1254        .format(&time::format_description::well_known::Rfc3339)
1255        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1256
1257    let mut event = json!({
1258        "timestamp": now,
1259        "from": did,
1260        "to": format!("did:wire:{peer}"),
1261        "type": kind,
1262        "kind": kind_id,
1263        "body": body_value,
1264    });
1265    if let Some(deadline) = deadline {
1266        event["time_sensitive_until"] =
1267            json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1268    }
1269    let signed =
1270        sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1271    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1272
1273    let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1274    let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1275
1276    Ok(json!({
1277        "event_id": event_id,
1278        "status": "queued",
1279        "peer": peer,
1280        "outbox": outbox.to_string_lossy(),
1281    }))
1282}
1283
1284fn tool_tail(args: &Value) -> Result<Value, String> {
1285    use crate::config;
1286    use crate::signing::verify_message_v31;
1287
1288    let peer_filter = args.get("peer").and_then(Value::as_str);
1289    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1290    let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1291    if !inbox.exists() {
1292        return Ok(json!([]));
1293    }
1294    let trust = config::read_trust().map_err(|e| e.to_string())?;
1295    let mut events = Vec::new();
1296    let entries: Vec<_> = std::fs::read_dir(&inbox)
1297        .map_err(|e| e.to_string())?
1298        .filter_map(|e| e.ok())
1299        .map(|e| e.path())
1300        .filter(|p| {
1301            p.extension().map(|x| x == "jsonl").unwrap_or(false)
1302                && match peer_filter {
1303                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1304                    None => true,
1305                }
1306        })
1307        .collect();
1308    for path in entries {
1309        let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
1310        for line in body.lines() {
1311            let event: Value = match serde_json::from_str(line) {
1312                Ok(v) => v,
1313                Err(_) => continue,
1314            };
1315            let verified = verify_message_v31(&event, &trust).is_ok();
1316            let mut event_with_meta = event.clone();
1317            if let Some(obj) = event_with_meta.as_object_mut() {
1318                obj.insert("verified".into(), json!(verified));
1319            }
1320            events.push(event_with_meta);
1321            if events.len() >= limit {
1322                return Ok(Value::Array(events));
1323            }
1324        }
1325    }
1326    Ok(Value::Array(events))
1327}
1328
1329fn tool_verify(args: &Value) -> Result<Value, String> {
1330    use crate::config;
1331    use crate::signing::verify_message_v31;
1332
1333    let event_str = args
1334        .get("event")
1335        .and_then(Value::as_str)
1336        .ok_or("missing 'event'")?;
1337    let event: Value =
1338        serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1339    let trust = config::read_trust().map_err(|e| e.to_string())?;
1340    match verify_message_v31(&event, &trust) {
1341        Ok(()) => Ok(json!({"verified": true})),
1342        Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1343    }
1344}
1345
1346// ---------- pairing tools ----------
1347
1348/// v0.13: bootstrap a freshly-resolved session-keyed identity. Runs once per
1349/// session home (gated on `is_initialized`); no-op under WIRE_MCP_SKIP_AUTO_UP.
1350/// init (one-name) + federation slot via `ensure_self_with_relay`, then a
1351/// best-effort phonebook claim of the DID-derived persona. Network failures
1352/// are swallowed — the identity is still created locally; the claim retries on
1353/// a later start.
1354fn ensure_session_bootstrapped() {
1355    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
1356        return;
1357    }
1358    if crate::config::is_initialized().unwrap_or(false) {
1359        return; // this session home already has an identity
1360    }
1361    let (did, relay_url, slot_id, slot_token) =
1362        match crate::pair_invite::ensure_self_with_relay(None) {
1363            Ok(t) => t,
1364            Err(_) => return, // offline / relay down — init may have happened locally; skip claim
1365        };
1366    if let Ok(card) = crate::config::read_agent_card() {
1367        let persona = crate::agent_card::display_handle_from_did(&did).to_string();
1368        let client = crate::relay_client::RelayClient::new(&relay_url);
1369        let _ = client.handle_claim_v2(&persona, &slot_id, &slot_token, None, &card, None);
1370    }
1371}
1372
1373fn tool_init(args: &Value) -> Result<Value, String> {
1374    let handle = args
1375        .get("handle")
1376        .and_then(Value::as_str)
1377        .ok_or("missing 'handle'")?;
1378    let name = args.get("name").and_then(Value::as_str);
1379    let relay = args.get("relay_url").and_then(Value::as_str);
1380    crate::pair_session::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1381}
1382
1383/// Resolve the relay URL: explicit arg wins, else the relay this agent's
1384/// identity is already bound to (from `wire init --relay` or a previous
1385/// pair_initiate). Errors if neither is set.
1386fn resolve_relay_url(args: &Value) -> Result<String, String> {
1387    if let Some(url) = args.get("relay_url").and_then(Value::as_str) {
1388        return Ok(url.to_string());
1389    }
1390    let state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1391    state["self"]["relay_url"]
1392        .as_str()
1393        .map(str::to_string)
1394        .ok_or_else(|| "no relay_url provided and no relay bound (call wire_init with relay_url, or pass relay_url here)".into())
1395}
1396
1397/// If `handle` is provided and identity isn't yet initialized, call
1398/// `init_self_idempotent` so a single MCP call can do both. If handle is
1399/// missing and not initialized, surface a clear error pointing the agent at
1400/// wire_init. If already initialized under a different handle, the
1401/// idempotent init errors clearly (same as direct wire_init).
1402fn auto_init_if_needed(args: &Value) -> Result<(), String> {
1403    let initialized = crate::config::is_initialized().map_err(|e| e.to_string())?;
1404    if initialized {
1405        return Ok(());
1406    }
1407    let handle = args.get("handle").and_then(Value::as_str).ok_or(
1408        "not initialized — pass `handle` to auto-init, or call wire_init explicitly first",
1409    )?;
1410    let relay = args.get("relay_url").and_then(Value::as_str);
1411    crate::pair_session::init_self_idempotent(handle, None, relay)
1412        .map(|_| ())
1413        .map_err(|e| e.to_string())
1414}
1415
1416fn tool_pair_initiate(args: &Value) -> Result<Value, String> {
1417    use crate::pair_session::{
1418        pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1419    };
1420
1421    store_sweep_expired();
1422    // Auto-init if `handle` arg provided and not yet inited (idempotent).
1423    auto_init_if_needed(args)?;
1424
1425    let relay_url = resolve_relay_url(args)?;
1426    let max_wait = args
1427        .get("max_wait_secs")
1428        .and_then(Value::as_u64)
1429        .unwrap_or(30)
1430        .min(60);
1431
1432    let mut s = pair_session_open("host", &relay_url, None).map_err(|e| e.to_string())?;
1433    let code = s.code.clone();
1434
1435    let sas_opt = if max_wait > 0 {
1436        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1437            .map_err(|e| e.to_string())?
1438    } else {
1439        None
1440    };
1441
1442    let session_id = store_insert(s);
1443
1444    let mut out = json!({
1445        "session_id": session_id,
1446        "code_phrase": code,
1447        "relay_url": relay_url,
1448    });
1449    match sas_opt {
1450        Some(sas) => {
1451            out["state"] = json!("sas_ready");
1452            out["sas"] = json!(sas);
1453            out["next"] = json!(
1454                "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel (voice/text). \
1455                 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1456            );
1457        }
1458        None => {
1459            out["state"] = json!("waiting");
1460            out["next"] = json!(
1461                "Share the code_phrase with the user; ask them to read it to their peer (the peer pastes into wire_pair_join). \
1462                 Poll wire_pair_check(session_id) until state='sas_ready'."
1463            );
1464        }
1465    }
1466    Ok(out)
1467}
1468
1469fn tool_pair_join(args: &Value) -> Result<Value, String> {
1470    use crate::pair_session::{
1471        pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1472    };
1473
1474    store_sweep_expired();
1475    auto_init_if_needed(args)?;
1476
1477    let code = args
1478        .get("code_phrase")
1479        .and_then(Value::as_str)
1480        .ok_or("missing 'code_phrase'")?;
1481    let relay_url = resolve_relay_url(args)?;
1482    let max_wait = args
1483        .get("max_wait_secs")
1484        .and_then(Value::as_u64)
1485        .unwrap_or(30)
1486        .min(60);
1487
1488    let mut s = pair_session_open("guest", &relay_url, Some(code)).map_err(|e| e.to_string())?;
1489
1490    let sas_opt =
1491        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1492            .map_err(|e| e.to_string())?;
1493
1494    let session_id = store_insert(s);
1495
1496    let mut out = json!({
1497        "session_id": session_id,
1498        "relay_url": relay_url,
1499    });
1500    match sas_opt {
1501        Some(sas) => {
1502            out["state"] = json!("sas_ready");
1503            out["sas"] = json!(sas);
1504            out["next"] = json!(
1505                "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel. \
1506                 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1507            );
1508        }
1509        None => {
1510            out["state"] = json!("waiting");
1511            out["next"] = json!("Poll wire_pair_check(session_id).");
1512        }
1513    }
1514    Ok(out)
1515}
1516
1517fn tool_pair_check(args: &Value) -> Result<Value, String> {
1518    use crate::pair_session::{pair_session_wait_for_sas, store_get, store_sweep_expired};
1519
1520    store_sweep_expired();
1521    let session_id = args
1522        .get("session_id")
1523        .and_then(Value::as_str)
1524        .ok_or("missing 'session_id'")?;
1525    let max_wait = args
1526        .get("max_wait_secs")
1527        .and_then(Value::as_u64)
1528        .unwrap_or(8)
1529        .min(60);
1530
1531    let arc = store_get(session_id)
1532        .ok_or_else(|| format!("no such session_id (expired or never opened): {session_id}"))?;
1533    let mut s = arc.lock().map_err(|e| e.to_string())?;
1534
1535    if s.finalized {
1536        return Ok(json!({
1537            "state": "finalized",
1538            "session_id": session_id,
1539            "sas": s.formatted_sas(),
1540        }));
1541    }
1542    if let Some(reason) = s.aborted.clone() {
1543        return Ok(json!({
1544            "state": "aborted",
1545            "session_id": session_id,
1546            "reason": reason,
1547        }));
1548    }
1549
1550    let sas_opt =
1551        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1552            .map_err(|e| e.to_string())?;
1553
1554    Ok(match sas_opt {
1555        Some(sas) => json!({
1556            "state": "sas_ready",
1557            "session_id": session_id,
1558            "sas": sas,
1559            "next": "Have the user TYPE the 6 SAS digits BACK INTO CHAT, then pass to wire_pair_confirm."
1560        }),
1561        None => json!({
1562            "state": "waiting",
1563            "session_id": session_id,
1564        }),
1565    })
1566}
1567
1568fn tool_pair_confirm(args: &Value, state: &McpState) -> Result<Value, String> {
1569    use crate::pair_session::{
1570        pair_session_confirm_sas, pair_session_finalize, store_get, store_remove,
1571    };
1572
1573    let session_id = args
1574        .get("session_id")
1575        .and_then(Value::as_str)
1576        .ok_or("missing 'session_id'")?;
1577    let typed = args
1578        .get("user_typed_digits")
1579        .and_then(Value::as_str)
1580        .ok_or(
1581            "missing 'user_typed_digits' — the user must type the 6 SAS digits back into chat",
1582        )?;
1583
1584    let arc = store_get(session_id).ok_or_else(|| format!("no such session_id: {session_id}"))?;
1585
1586    let confirm_err = {
1587        let mut s = arc.lock().map_err(|e| e.to_string())?;
1588        match pair_session_confirm_sas(&mut s, typed) {
1589            Ok(()) => None,
1590            Err(e) => Some((s.aborted.is_some(), e.to_string())),
1591        }
1592    };
1593    if let Some((aborted, msg)) = confirm_err {
1594        if aborted {
1595            store_remove(session_id);
1596        }
1597        return Err(msg);
1598    }
1599
1600    let mut result = {
1601        let mut s = arc.lock().map_err(|e| e.to_string())?;
1602        pair_session_finalize(&mut s, 30).map_err(|e| e.to_string())?
1603    };
1604    store_remove(session_id);
1605
1606    // ---- Post-pair auto-setup (Goal: zero friction after SAS) ----
1607    // 1. Auto-subscribe to wire://inbox/<peer> so clients that support
1608    //    resources/subscribe get push notifications/resources/updated.
1609    // 2. Spawn `wire daemon` if not already running so push/pull is automatic.
1610    // 3. Spawn `wire notify` if not already running so OS toasts fire on
1611    //    inbox grow (covers MCP hosts that lack resources/subscribe).
1612    // 4. Emit notifications/resources/list_changed via the writer channel so
1613    //    a client that called resources/list before pairing refreshes its view.
1614    let peer_handle = result["peer_handle"].as_str().unwrap_or("").to_string();
1615    let peer_uri = format!("wire://inbox/{peer_handle}");
1616
1617    let mut auto = json!({
1618        "subscribed": false,
1619        "daemon": "unknown",
1620        "notify": "unknown",
1621        "resources_list_changed_emitted": false,
1622    });
1623
1624    if !peer_handle.is_empty()
1625        && let Ok(mut g) = state.subscribed.lock()
1626    {
1627        g.insert(peer_uri.clone());
1628        auto["subscribed"] = json!(true);
1629    }
1630
1631    auto["daemon"] = match crate::ensure_up::ensure_daemon_running() {
1632        Ok(true) => json!("spawned"),
1633        Ok(false) => json!("already_running"),
1634        Err(e) => json!(format!("spawn_error: {e}")),
1635    };
1636    auto["notify"] = match crate::ensure_up::ensure_notify_running() {
1637        Ok(true) => json!("spawned"),
1638        Ok(false) => json!("already_running"),
1639        Err(e) => json!(format!("spawn_error: {e}")),
1640    };
1641
1642    if let Some(tx) = state.notif_tx.lock().ok().and_then(|g| g.clone()) {
1643        let notif = json!({
1644            "jsonrpc": "2.0",
1645            "method": "notifications/resources/list_changed",
1646        });
1647        if tx.send(notif.to_string()).is_ok() {
1648            auto["resources_list_changed_emitted"] = json!(true);
1649        }
1650    }
1651
1652    result["auto"] = auto;
1653    result["next"] = json!(
1654        "Done. Daemon + notify running, subscribed to peer inbox. Use wire_send/wire_tail \
1655         freely; new events arrive via notifications/resources/updated (where supported) and \
1656         OS toasts (always)."
1657    );
1658    Ok(result)
1659}
1660
1661// ---------- detached pair tools (daemon-orchestrated) ----------
1662
1663fn tool_pair_initiate_detached(args: &Value) -> Result<Value, String> {
1664    auto_init_if_needed(args)?;
1665    let relay_url = resolve_relay_url(args)?;
1666    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1667        let _ = crate::ensure_up::ensure_daemon_running();
1668    }
1669    let code = crate::sas::generate_code_phrase();
1670    let code_hash = crate::pair_session::derive_code_hash(&code);
1671    let now = time::OffsetDateTime::now_utc()
1672        .format(&time::format_description::well_known::Rfc3339)
1673        .unwrap_or_default();
1674    let p = crate::pending_pair::PendingPair {
1675        code: code.clone(),
1676        code_hash,
1677        role: "host".to_string(),
1678        relay_url: relay_url.clone(),
1679        status: "request_host".to_string(),
1680        sas: None,
1681        peer_did: None,
1682        created_at: now,
1683        last_error: None,
1684        pair_id: None,
1685        our_slot_id: None,
1686        our_slot_token: None,
1687        spake2_seed_b64: None,
1688    };
1689    crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1690    Ok(json!({
1691        "code_phrase": code,
1692        "relay_url": relay_url,
1693        "state": "queued",
1694        "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."
1695    }))
1696}
1697
1698fn tool_pair_join_detached(args: &Value) -> Result<Value, String> {
1699    auto_init_if_needed(args)?;
1700    let relay_url = resolve_relay_url(args)?;
1701    let code_phrase = args
1702        .get("code_phrase")
1703        .and_then(Value::as_str)
1704        .ok_or("missing 'code_phrase'")?;
1705    let code = crate::sas::parse_code_phrase(code_phrase)
1706        .map_err(|e| e.to_string())?
1707        .to_string();
1708    let code_hash = crate::pair_session::derive_code_hash(&code);
1709    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1710        let _ = crate::ensure_up::ensure_daemon_running();
1711    }
1712    let now = time::OffsetDateTime::now_utc()
1713        .format(&time::format_description::well_known::Rfc3339)
1714        .unwrap_or_default();
1715    let p = crate::pending_pair::PendingPair {
1716        code: code.clone(),
1717        code_hash,
1718        role: "guest".to_string(),
1719        relay_url: relay_url.clone(),
1720        status: "request_guest".to_string(),
1721        sas: None,
1722        peer_did: None,
1723        created_at: now,
1724        last_error: None,
1725        pair_id: None,
1726        our_slot_id: None,
1727        our_slot_token: None,
1728        spake2_seed_b64: None,
1729    };
1730    crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1731    Ok(json!({
1732        "code_phrase": code,
1733        "relay_url": relay_url,
1734        "state": "queued",
1735        "next": "Subscribe to wire://pending-pair/all; on sas_ready notification, surface digits to user and call wire_pair_confirm_detached."
1736    }))
1737}
1738
1739fn tool_pair_list_pending() -> Result<Value, String> {
1740    let items = crate::pending_pair::list_pending().map_err(|e| e.to_string())?;
1741    Ok(json!({"pending": items}))
1742}
1743
1744fn tool_pair_confirm_detached(args: &Value) -> Result<Value, String> {
1745    let code_phrase = args
1746        .get("code_phrase")
1747        .and_then(Value::as_str)
1748        .ok_or("missing 'code_phrase'")?;
1749    let typed = args
1750        .get("user_typed_digits")
1751        .and_then(Value::as_str)
1752        .ok_or("missing 'user_typed_digits'")?;
1753    let code = crate::sas::parse_code_phrase(code_phrase)
1754        .map_err(|e| e.to_string())?
1755        .to_string();
1756    let typed: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
1757    if typed.len() != 6 {
1758        return Err(format!(
1759            "expected 6 digits (got {} after stripping non-digits)",
1760            typed.len()
1761        ));
1762    }
1763    let mut p = crate::pending_pair::read_pending(&code)
1764        .map_err(|e| e.to_string())?
1765        .ok_or_else(|| format!("no pending pair for code {code}"))?;
1766    if p.status != "sas_ready" {
1767        return Err(format!(
1768            "pair {code} not in sas_ready state (current: {})",
1769            p.status
1770        ));
1771    }
1772    let stored = p
1773        .sas
1774        .as_ref()
1775        .ok_or("pending file has status=sas_ready but no sas field")?
1776        .clone();
1777    if stored == typed {
1778        p.status = "confirmed".to_string();
1779        crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1780        Ok(json!({
1781            "state": "confirmed",
1782            "code_phrase": code,
1783            "next": "Daemon will finalize on its next tick (~1s). Poll wire_peers or watch wire://pending-pair/all for the entry to disappear."
1784        }))
1785    } else {
1786        p.status = "aborted".to_string();
1787        p.last_error = Some(format!(
1788            "SAS digit mismatch (typed {typed}, expected {stored})"
1789        ));
1790        let client = crate::relay_client::RelayClient::new(&p.relay_url);
1791        let _ = client.pair_abandon(&p.code_hash);
1792        let _ = crate::pending_pair::write_pending(&p);
1793        crate::os_notify::toast(
1794            &format!("wire — pair aborted ({code})"),
1795            p.last_error.as_deref().unwrap_or("digits mismatch"),
1796        );
1797        Err(
1798            "digits mismatch — pair aborted. Re-issue with wire_pair_initiate_detached."
1799                .to_string(),
1800        )
1801    }
1802}
1803
1804fn tool_pair_cancel_pending(args: &Value) -> Result<Value, String> {
1805    let code_phrase = args
1806        .get("code_phrase")
1807        .and_then(Value::as_str)
1808        .ok_or("missing 'code_phrase'")?;
1809    let code = crate::sas::parse_code_phrase(code_phrase)
1810        .map_err(|e| e.to_string())?
1811        .to_string();
1812    if let Some(p) = crate::pending_pair::read_pending(&code).map_err(|e| e.to_string())? {
1813        let client = crate::relay_client::RelayClient::new(&p.relay_url);
1814        let _ = client.pair_abandon(&p.code_hash);
1815    }
1816    crate::pending_pair::delete_pending(&code).map_err(|e| e.to_string())?;
1817    Ok(json!({"state": "cancelled", "code_phrase": code}))
1818}
1819
1820// ---------- invite-URL one-paste pair (v0.4.0) ----------
1821
1822fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1823    let relay_url = args.get("relay_url").and_then(Value::as_str);
1824    let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1825    let uses = args
1826        .get("uses")
1827        .and_then(Value::as_u64)
1828        .map(|u| u as u32)
1829        .unwrap_or(1);
1830    let url =
1831        crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1832    let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1833    Ok(json!({
1834        "invite_url": url,
1835        "ttl_secs": ttl_resolved,
1836        "uses": uses,
1837    }))
1838}
1839
1840fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1841    let url = args
1842        .get("url")
1843        .and_then(Value::as_str)
1844        .ok_or("missing 'url'")?;
1845    crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1846}
1847
1848// ---------- v0.5 — agentic hotline tools ----------
1849
1850/// wire_dial (MCP): mirror the CLI `dial` resolution ladder. The prior
1851/// wiring routed straight to `tool_add`, which reads a required `handle`
1852/// arg — but the wire_dial schema only provides `name`, so every dial
1853/// errored `missing 'handle'`. This reads `name` and routes:
1854///   • `<nick>@<relay>`  -> federation pair (via tool_add).
1855///   • already-pinned     -> no-op success (peer already reachable).
1856///   • otherwise          -> honest error. Bare-nickname / local-sister
1857///     resolution over MCP is not yet wired (CLI `wire dial` does it);
1858///     use `<nick>@<relay>` or `wire_send` (auto-pairs on miss).
1859fn tool_dial(args: &Value) -> Result<Value, String> {
1860    let name = args
1861        .get("name")
1862        .and_then(Value::as_str)
1863        .or_else(|| args.get("handle").and_then(Value::as_str))
1864        .ok_or("missing 'name'")?;
1865
1866    if name.contains('@') {
1867        // Federation path. Present `name` as the `handle` tool_add expects.
1868        let mut a = args.clone();
1869        if let Some(obj) = a.as_object_mut() {
1870            obj.insert("handle".into(), Value::String(name.to_string()));
1871        }
1872        return tool_add(&a);
1873    }
1874
1875    let relay_state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1876    let pinned = relay_state
1877        .get("peers")
1878        .and_then(Value::as_object)
1879        .map(|m| m.contains_key(name))
1880        .unwrap_or(false);
1881    if pinned {
1882        return Ok(json!({
1883            "name_input": name,
1884            "status": "already_pinned",
1885            "peer_handle": name,
1886        }));
1887    }
1888
1889    Err(format!(
1890        "cannot resolve `{name}` over MCP: bare-nickname / local-sister dialling is not yet \
1891         wired into the MCP surface. Use a federation handle `{name}@<relay>`, or `wire_send` \
1892         (it auto-pairs on miss)."
1893    ))
1894}
1895
1896fn tool_add(args: &Value) -> Result<Value, String> {
1897    let handle = args
1898        .get("handle")
1899        .and_then(Value::as_str)
1900        .ok_or("missing 'handle'")?;
1901    let relay_override = args.get("relay_url").and_then(Value::as_str);
1902
1903    let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1904
1905    // Ensure self has identity + relay slot (auto-inits if needed).
1906    let (our_did, our_relay, our_slot_id, our_slot_token) =
1907        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1908
1909    // Resolve peer via .well-known.
1910    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1911        .map_err(|e| format!("{e:#}"))?;
1912    let peer_card = resolved
1913        .get("card")
1914        .cloned()
1915        .ok_or("resolved missing card")?;
1916    let peer_did = resolved
1917        .get("did")
1918        .and_then(Value::as_str)
1919        .ok_or("resolved missing did")?
1920        .to_string();
1921    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1922    let peer_slot_id = resolved
1923        .get("slot_id")
1924        .and_then(Value::as_str)
1925        .ok_or("resolved missing slot_id")?
1926        .to_string();
1927    let peer_relay = resolved
1928        .get("relay_url")
1929        .and_then(Value::as_str)
1930        .map(str::to_string)
1931        .or_else(|| relay_override.map(str::to_string))
1932        .unwrap_or_else(|| format!("https://{}", parsed.domain));
1933
1934    // Pin peer in trust + relay-state. slot_token arrives via ack later.
1935    let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1936    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1937    crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1938    let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1939    let existing_token = relay_state
1940        .get("peers")
1941        .and_then(|p| p.get(&peer_handle))
1942        .and_then(|p| p.get("slot_token"))
1943        .and_then(Value::as_str)
1944        .map(str::to_string)
1945        .unwrap_or_default();
1946    relay_state["peers"][&peer_handle] = json!({
1947        "relay_url": peer_relay,
1948        "slot_id": peer_slot_id,
1949        "slot_token": existing_token,
1950    });
1951    crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1952
1953    // Build + sign pair_drop event (no nonce — open-mode handle pair).
1954    let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1955    let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1956    let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1957    let pk_b64 = our_card
1958        .get("verify_keys")
1959        .and_then(Value::as_object)
1960        .and_then(|m| m.values().next())
1961        .and_then(|v| v.get("key"))
1962        .and_then(Value::as_str)
1963        .ok_or("our card missing verify_keys[*].key")?;
1964    let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1965    let now = time::OffsetDateTime::now_utc()
1966        .format(&time::format_description::well_known::Rfc3339)
1967        .unwrap_or_default();
1968    let event = json!({
1969        "timestamp": now,
1970        "from": our_did,
1971        "to": peer_did,
1972        "type": "pair_drop",
1973        "kind": 1100u32,
1974        "body": {
1975            "card": our_card,
1976            "relay_url": our_relay,
1977            "slot_id": our_slot_id,
1978            "slot_token": our_slot_token,
1979        },
1980    });
1981    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
1982        .map_err(|e| format!("{e:#}"))?;
1983
1984    let client = crate::relay_client::RelayClient::new(&peer_relay);
1985    let resp = client
1986        .handle_intro(&parsed.nick, &signed)
1987        .map_err(|e| format!("{e:#}"))?;
1988    let event_id = signed
1989        .get("event_id")
1990        .and_then(Value::as_str)
1991        .unwrap_or("")
1992        .to_string();
1993    Ok(json!({
1994        "handle": handle,
1995        "paired_with": peer_did,
1996        "peer_handle": peer_handle,
1997        "event_id": event_id,
1998        "drop_response": resp,
1999        "status": "drop_sent",
2000    }))
2001}
2002
2003/// v0.5.14: MCP `wire_pair_accept` — bilateral completion of a
2004/// pending-inbound pair request. The agent SHOULD have surfaced the
2005/// pending request to the operator before calling this; acceptance
2006/// grants peer authenticated write access to this agent's inbox.
2007fn tool_pair_accept(args: &Value) -> Result<Value, String> {
2008    let peer = args
2009        .get("peer")
2010        .and_then(Value::as_str)
2011        .ok_or("missing 'peer'")?;
2012    let nick = crate::agent_card::bare_handle(peer);
2013    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
2014        .map_err(|e| format!("{e:#}"))?
2015        .ok_or_else(|| {
2016            format!(
2017                "no pending pair request from {nick}. Call wire_pair_list_inbound to enumerate, \
2018                 or wire_add to send a fresh outbound pair request."
2019            )
2020        })?;
2021
2022    // Pin trust with VERIFIED — operator-equivalent consent gesture (the
2023    // agent is acting on the operator's instruction to accept).
2024    let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
2025    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
2026    crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
2027
2028    // Record peer's relay coords + slot_token from the stored drop.
2029    let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
2030    relay_state["peers"][&pending.peer_handle] = json!({
2031        "relay_url": pending.peer_relay_url,
2032        "slot_id": pending.peer_slot_id,
2033        "slot_token": pending.peer_slot_token,
2034    });
2035    crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
2036
2037    // Ship our slot_token via pair_drop_ack.
2038    crate::pair_invite::send_pair_drop_ack(
2039        &pending.peer_handle,
2040        &pending.peer_relay_url,
2041        &pending.peer_slot_id,
2042        &pending.peer_slot_token,
2043    )
2044    .map_err(|e| {
2045        format!(
2046            "pair_drop_ack send to {} @ {} slot {} failed: {e:#}",
2047            pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
2048        )
2049    })?;
2050
2051    crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2052
2053    Ok(json!({
2054        "status": "bilateral_accepted",
2055        "peer_handle": pending.peer_handle,
2056        "peer_did": pending.peer_did,
2057        "peer_relay_url": pending.peer_relay_url,
2058        "via": "pending_inbound",
2059    }))
2060}
2061
2062/// v0.5.14: MCP `wire_pair_reject` — delete a pending-inbound record
2063/// without pairing. Peer never receives our slot_token. Idempotent.
2064fn tool_pair_reject(args: &Value) -> Result<Value, String> {
2065    let peer = args
2066        .get("peer")
2067        .and_then(Value::as_str)
2068        .ok_or("missing 'peer'")?;
2069    let nick = crate::agent_card::bare_handle(peer);
2070    let existed =
2071        crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2072    crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2073    Ok(json!({
2074        "peer": nick,
2075        "rejected": existed.is_some(),
2076        "had_pending": existed.is_some(),
2077    }))
2078}
2079
2080/// v0.5.14: MCP `wire_pair_list_inbound` — enumerate pending-inbound
2081/// pair requests for operator review. Flat array sorted oldest-first.
2082fn tool_pair_list_inbound() -> Result<Value, String> {
2083    let items =
2084        crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
2085    Ok(json!(items))
2086}
2087
2088fn tool_claim_handle(args: &Value) -> Result<Value, String> {
2089    let typed = args.get("nick").and_then(Value::as_str);
2090    let relay_override = args.get("relay_url").and_then(Value::as_str);
2091    let public_url = args.get("public_url").and_then(Value::as_str);
2092
2093    // Auto-init + ensure slot.
2094    let (_, our_relay, our_slot_id, our_slot_token) =
2095        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
2096    let claim_relay = relay_override.unwrap_or(&our_relay);
2097    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2098
2099    // One-name rule (v0.13.1): the claimed handle is ALWAYS the DID-derived
2100    // persona, so the phonebook entry can never drift from the agent-card
2101    // handle. `nick` is optional + advisory — a value that differs is ignored.
2102    // See cmd_claim for the rationale (closes the claim-path "two names" hole).
2103    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
2104    let canonical = crate::agent_card::display_handle_from_did(did).to_string();
2105    let nick = if canonical.is_empty() {
2106        typed.unwrap_or_default().to_string()
2107    } else {
2108        canonical
2109    };
2110    let typed_nick_ignored = typed.map(|t| t != nick).unwrap_or(false);
2111
2112    let client = crate::relay_client::RelayClient::new(claim_relay);
2113    let resp = client
2114        .handle_claim(&nick, &our_slot_id, &our_slot_token, public_url, &card)
2115        .map_err(|e| format!("{e:#}"))?;
2116    Ok(json!({
2117        "nick": nick,
2118        "relay": claim_relay,
2119        "response": resp,
2120        "one_name": true,
2121        "typed_nick_ignored": typed_nick_ignored,
2122    }))
2123}
2124
2125fn tool_whois(args: &Value) -> Result<Value, String> {
2126    if let Some(handle) = args.get("handle").and_then(Value::as_str) {
2127        let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
2128        let relay_override = args.get("relay_url").and_then(Value::as_str);
2129        crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
2130    } else {
2131        // Self.
2132        let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2133        Ok(json!({
2134            "did": card.get("did").cloned().unwrap_or(Value::Null),
2135            "profile": card.get("profile").cloned().unwrap_or(Value::Null),
2136        }))
2137    }
2138}
2139
2140fn tool_profile_set(args: &Value) -> Result<Value, String> {
2141    let field = args
2142        .get("field")
2143        .and_then(Value::as_str)
2144        .ok_or("missing 'field'")?;
2145    let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
2146    // If value is a string that itself parses as JSON (e.g. "[\"rust\"]"),
2147    // unwrap it. Otherwise pass as-is. Lets agents send either typed values
2148    // or stringified JSON.
2149    let value = if let Some(s) = raw_value.as_str() {
2150        serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
2151    } else {
2152        raw_value
2153    };
2154    let new_profile =
2155        crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
2156    Ok(json!({
2157        "field": field,
2158        "profile": new_profile,
2159    }))
2160}
2161
2162fn tool_profile_get() -> Result<Value, String> {
2163    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2164    Ok(json!({
2165        "did": card.get("did").cloned().unwrap_or(Value::Null),
2166        "profile": card.get("profile").cloned().unwrap_or(Value::Null),
2167    }))
2168}
2169
2170// ---------- helpers ----------
2171
2172fn parse_kind(s: &str) -> u32 {
2173    if let Ok(n) = s.parse::<u32>() {
2174        return n;
2175    }
2176    for (id, name) in crate::signing::kinds() {
2177        if *name == s {
2178            return *id;
2179        }
2180    }
2181    1
2182}
2183
2184fn error_response(id: &Value, code: i32, message: &str) -> Value {
2185    json!({
2186        "jsonrpc": "2.0",
2187        "id": id,
2188        "error": {"code": code, "message": message}
2189    })
2190}
2191
2192#[cfg(test)]
2193mod tests {
2194    use super::*;
2195
2196    #[test]
2197    fn unknown_method_returns_jsonrpc_error() {
2198        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
2199        let resp = handle_request(&req, &McpState::default());
2200        assert_eq!(resp["error"]["code"], -32601);
2201    }
2202
2203    #[test]
2204    fn initialize_advertises_tools_capability() {
2205        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
2206        let resp = handle_request(&req, &McpState::default());
2207        assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
2208        assert!(resp["result"]["capabilities"]["tools"].is_object());
2209        assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
2210    }
2211
2212    #[test]
2213    fn tools_list_includes_pairing_and_messaging() {
2214        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2215        let resp = handle_request(&req, &McpState::default());
2216        let names: Vec<&str> = resp["result"]["tools"]
2217            .as_array()
2218            .unwrap()
2219            .iter()
2220            .filter_map(|t| t["name"].as_str())
2221            .collect();
2222        for required in [
2223            "wire_whoami",
2224            "wire_peers",
2225            "wire_send",
2226            "wire_tail",
2227            "wire_verify",
2228            "wire_init",
2229            "wire_pair_initiate",
2230            "wire_pair_join",
2231            "wire_pair_check",
2232            "wire_pair_confirm",
2233        ] {
2234            assert!(
2235                names.contains(&required),
2236                "missing required tool {required}"
2237            );
2238        }
2239        // wire_join (the old direct alias for pair-join, no SAS-typeback) is
2240        // explicitly NOT in the catalog. Calling it returns a deprecation
2241        // pointing to wire_pair_join (test below covers this).
2242        assert!(
2243            !names.contains(&"wire_join"),
2244            "wire_join must not be advertised — superseded by wire_pair_join"
2245        );
2246    }
2247
2248    #[test]
2249    fn legacy_wire_join_call_returns_helpful_error() {
2250        let req = json!({
2251            "jsonrpc": "2.0",
2252            "id": 1,
2253            "method": "tools/call",
2254            "params": {"name": "wire_join", "arguments": {}}
2255        });
2256        let resp = handle_request(&req, &McpState::default());
2257        assert_eq!(resp["result"]["isError"], true);
2258        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2259        assert!(
2260            text.contains("wire_pair_join"),
2261            "expected redirect to wire_pair_join, got: {text}"
2262        );
2263    }
2264
2265    #[test]
2266    fn pair_confirm_missing_session_id_errors_cleanly() {
2267        let req = json!({
2268            "jsonrpc": "2.0",
2269            "id": 1,
2270            "method": "tools/call",
2271            "params": {"name": "wire_pair_confirm", "arguments": {"user_typed_digits": "111111"}}
2272        });
2273        let resp = handle_request(&req, &McpState::default());
2274        assert_eq!(resp["result"]["isError"], true);
2275    }
2276
2277    #[test]
2278    fn pair_confirm_unknown_session_errors_cleanly() {
2279        let req = json!({
2280            "jsonrpc": "2.0",
2281            "id": 1,
2282            "method": "tools/call",
2283            "params": {
2284                "name": "wire_pair_confirm",
2285                "arguments": {"session_id": "definitely-not-real", "user_typed_digits": "111111"}
2286            }
2287        });
2288        let resp = handle_request(&req, &McpState::default());
2289        assert_eq!(resp["result"]["isError"], true);
2290        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2291        assert!(text.contains("no such session_id"), "got: {text}");
2292    }
2293
2294    #[test]
2295    fn initialize_advertises_resources_capability() {
2296        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
2297        let resp = handle_request(&req, &McpState::default());
2298        let caps = &resp["result"]["capabilities"];
2299        assert!(
2300            caps["resources"].is_object(),
2301            "resources capability must be present, got {resp}"
2302        );
2303        assert_eq!(
2304            caps["resources"]["subscribe"], true,
2305            "subscribe shipped in v0.2.1"
2306        );
2307    }
2308
2309    #[test]
2310    fn resources_read_with_bad_uri_errors() {
2311        let req = json!({
2312            "jsonrpc": "2.0",
2313            "id": 1,
2314            "method": "resources/read",
2315            "params": {"uri": "http://example.com/not-a-wire-uri"}
2316        });
2317        let resp = handle_request(&req, &McpState::default());
2318        assert!(resp.get("error").is_some(), "expected error, got {resp}");
2319    }
2320
2321    #[test]
2322    fn parse_inbox_uri_handles_variants() {
2323        assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
2324        assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
2325        assert!(
2326            parse_inbox_uri("wire://inbox/")
2327                .unwrap()
2328                .starts_with("__invalid__"),
2329            "empty peer must be invalid"
2330        );
2331        assert!(
2332            parse_inbox_uri("http://other")
2333                .unwrap()
2334                .starts_with("__invalid__"),
2335            "non-wire scheme must be invalid"
2336        );
2337    }
2338
2339    #[test]
2340    fn ping_returns_empty_result() {
2341        let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2342        let resp = handle_request(&req, &McpState::default());
2343        assert_eq!(resp["id"], 7);
2344        assert!(resp["result"].is_object());
2345    }
2346
2347    #[test]
2348    fn notification_returns_null_no_reply() {
2349        let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2350        let resp = handle_request(&req, &McpState::default());
2351        assert_eq!(resp, Value::Null);
2352    }
2353
2354    /// v0.6.1 regression: `detect_session_wire_home` must return the
2355    /// session's home dir when the cwd is in the registry AND the
2356    /// session dir exists on disk. The original v0.6.1 shipped with
2357    /// only an eprintln "verification" — this test asserts the
2358    /// observable return value so the env-set-but-not-consumed class
2359    /// of bug fails loudly.
2360    #[test]
2361    fn detect_session_wire_home_resolves_registered_cwd() {
2362        crate::config::test_support::with_temp_home(|| {
2363            // Set up sessions/registry.json + sessions/test-alpha/ under
2364            // the temp WIRE_HOME so session::read_registry +
2365            // session::session_dir resolve through it.
2366            let wire_home = std::env::var("WIRE_HOME").unwrap();
2367            let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2368            let session_home = sessions_root.join("test-alpha");
2369            std::fs::create_dir_all(&session_home).unwrap();
2370            let fake_cwd = "/tmp/fake-project-cwd-abc123";
2371            let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2372            std::fs::write(
2373                sessions_root.join("registry.json"),
2374                serde_json::to_vec_pretty(&registry).unwrap(),
2375            )
2376            .unwrap();
2377
2378            // Hit happy path.
2379            let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2380            assert_eq!(
2381                got.as_deref(),
2382                Some(session_home.as_path()),
2383                "registered cwd must resolve to session_home"
2384            );
2385
2386            // Unregistered cwd → None.
2387            let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2388                "/tmp/cwd-not-in-registry-xyz789",
2389            ));
2390            assert!(nope.is_none(), "unregistered cwd must return None");
2391
2392            // Registered cwd but session dir missing → None (defensive:
2393            // stale registry entry pointing at a deleted session).
2394            let stale_cwd = "/tmp/stale-session-cwd";
2395            let stale_registry =
2396                json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2397            std::fs::write(
2398                sessions_root.join("registry.json"),
2399                serde_json::to_vec_pretty(&stale_registry).unwrap(),
2400            )
2401            .unwrap();
2402            let stale_got =
2403                crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2404            assert!(
2405                stale_got.is_none(),
2406                "registered cwd whose session dir is missing must return None"
2407            );
2408        });
2409    }
2410}