Skip to main content

wire/
mcp.rs

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