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    Ok(json!({
1067        "did": did,
1068        "handle": handle,
1069        "persona": persona,
1070        "fingerprint": fp,
1071        "key_id": key_id,
1072        "public_key_b64": pk_b64,
1073        "capabilities": capabilities,
1074    }))
1075}
1076
1077fn tool_peers() -> Result<Value, String> {
1078    use crate::config;
1079    use crate::trust::get_tier;
1080
1081    let trust = config::read_trust().map_err(|e| e.to_string())?;
1082    let agents = trust
1083        .get("agents")
1084        .and_then(Value::as_object)
1085        .cloned()
1086        .unwrap_or_default();
1087    let mut self_did: Option<String> = None;
1088    if let Ok(card) = config::read_agent_card() {
1089        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
1090    }
1091    let mut peers = Vec::new();
1092    for (handle, agent) in agents.iter() {
1093        let did = agent
1094            .get("did")
1095            .and_then(Value::as_str)
1096            .unwrap_or("")
1097            .to_string();
1098        if Some(did.as_str()) == self_did.as_deref() {
1099            continue;
1100        }
1101        // v0.12: include the persona (respecting the peer's advertised
1102        // override when their card carries one, else DID-derived) so MCP
1103        // callers render the nickname/emoji instead of the raw handle.
1104        let persona = match agent.get("card") {
1105            Some(c) => crate::character::Character::from_card(c),
1106            None => crate::character::Character::from_did(&did),
1107        };
1108        peers.push(json!({
1109            "handle": handle,
1110            "persona": serde_json::to_value(&persona).unwrap_or(Value::Null),
1111            "did": did,
1112            "tier": get_tier(&trust, handle),
1113            "capabilities": agent.get("card").and_then(|c| c.get("capabilities")).cloned().unwrap_or_else(|| json!([])),
1114        }));
1115    }
1116    Ok(json!(peers))
1117}
1118
1119/// Run `wire group <args> --json` by spawning this same binary, inheriting the
1120/// MCP session's WIRE_* env so it resolves the same identity/home. Group ops are
1121/// infrequent, so this reuses the exact, tested CLI logic — including the
1122/// verification-sensitive invite/join paths — rather than duplicating it here.
1123fn group_cli_json(args: &[&str]) -> Result<Value, String> {
1124    let exe = std::env::current_exe().map_err(|e| format!("locating wire binary: {e}"))?;
1125    let out = std::process::Command::new(exe)
1126        .arg("group")
1127        .args(args)
1128        .arg("--json")
1129        .env("WIRE_QUIET_AUTOSESSION", "1") // suppress the adopt-session stderr line
1130        .output()
1131        .map_err(|e| format!("spawning `wire group`: {e}"))?;
1132    if !out.status.success() {
1133        let err = String::from_utf8_lossy(&out.stderr);
1134        return Err(err.trim().to_string());
1135    }
1136    let s = String::from_utf8_lossy(&out.stdout);
1137    // Last JSON object line is the result (any adopt chatter went to stderr).
1138    let line = s
1139        .lines()
1140        .rev()
1141        .find(|l| l.trim_start().starts_with('{'))
1142        .unwrap_or("{}");
1143    serde_json::from_str(line).map_err(|e| format!("parsing `wire group` output: {e}"))
1144}
1145
1146fn tool_group_create(args: &Value) -> Result<Value, String> {
1147    let name = args
1148        .get("name")
1149        .and_then(Value::as_str)
1150        .ok_or("missing 'name'")?;
1151    group_cli_json(&["create", name])
1152}
1153
1154fn tool_group_add(args: &Value) -> Result<Value, String> {
1155    let group = args
1156        .get("group")
1157        .and_then(Value::as_str)
1158        .ok_or("missing 'group'")?;
1159    let peer = args
1160        .get("peer")
1161        .and_then(Value::as_str)
1162        .ok_or("missing 'peer'")?;
1163    group_cli_json(&["add", group, peer])
1164}
1165
1166fn tool_group_send(args: &Value) -> Result<Value, String> {
1167    let group = args
1168        .get("group")
1169        .and_then(Value::as_str)
1170        .ok_or("missing 'group'")?;
1171    let message = args
1172        .get("message")
1173        .and_then(Value::as_str)
1174        .ok_or("missing 'message'")?;
1175    group_cli_json(&["send", group, message])
1176}
1177
1178fn tool_group_tail(args: &Value) -> Result<Value, String> {
1179    let group = args
1180        .get("group")
1181        .and_then(Value::as_str)
1182        .ok_or("missing 'group'")?;
1183    if let Some(n) = args.get("limit").and_then(Value::as_u64) {
1184        group_cli_json(&["tail", group, "--limit", &n.to_string()])
1185    } else {
1186        group_cli_json(&["tail", group])
1187    }
1188}
1189
1190fn tool_group_list() -> Result<Value, String> {
1191    group_cli_json(&["list"])
1192}
1193
1194fn tool_group_invite(args: &Value) -> Result<Value, String> {
1195    let group = args
1196        .get("group")
1197        .and_then(Value::as_str)
1198        .ok_or("missing 'group'")?;
1199    group_cli_json(&["invite", group])
1200}
1201
1202fn tool_group_join(args: &Value) -> Result<Value, String> {
1203    let code = args
1204        .get("code")
1205        .and_then(Value::as_str)
1206        .ok_or("missing 'code'")?;
1207    group_cli_json(&["join", code])
1208}
1209
1210fn tool_send(args: &Value) -> Result<Value, String> {
1211    use crate::config;
1212    use crate::signing::{b64decode, sign_message_v31};
1213
1214    let peer = args
1215        .get("peer")
1216        .and_then(Value::as_str)
1217        .ok_or("missing 'peer'")?;
1218    let peer = crate::agent_card::bare_handle(peer);
1219    let kind = args
1220        .get("kind")
1221        .and_then(Value::as_str)
1222        .ok_or("missing 'kind'")?;
1223    let body = args
1224        .get("body")
1225        .and_then(Value::as_str)
1226        .ok_or("missing 'body'")?;
1227    let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
1228
1229    if !config::is_initialized().map_err(|e| e.to_string())? {
1230        return Err("not initialized — operator must run `wire init <handle>` first".into());
1231    }
1232    let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
1233    let card = config::read_agent_card().map_err(|e| e.to_string())?;
1234    let did = card
1235        .get("did")
1236        .and_then(Value::as_str)
1237        .unwrap_or("")
1238        .to_string();
1239    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1240    let pk_b64 = card
1241        .get("verify_keys")
1242        .and_then(Value::as_object)
1243        .and_then(|m| m.values().next())
1244        .and_then(|v| v.get("key"))
1245        .and_then(Value::as_str)
1246        .ok_or("agent-card missing verify_keys[*].key")?;
1247    let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1248
1249    // Body parses as JSON if possible, else stays a string.
1250    let body_value: Value =
1251        serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1252    let kind_id = parse_kind(kind);
1253
1254    let now = time::OffsetDateTime::now_utc()
1255        .format(&time::format_description::well_known::Rfc3339)
1256        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1257
1258    let mut event = json!({
1259        "timestamp": now,
1260        "from": did,
1261        "to": format!("did:wire:{peer}"),
1262        "type": kind,
1263        "kind": kind_id,
1264        "body": body_value,
1265    });
1266    if let Some(deadline) = deadline {
1267        event["time_sensitive_until"] =
1268            json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1269    }
1270    let signed =
1271        sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1272    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1273
1274    let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1275    let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1276
1277    Ok(json!({
1278        "event_id": event_id,
1279        "status": "queued",
1280        "peer": peer,
1281        "outbox": outbox.to_string_lossy(),
1282    }))
1283}
1284
1285fn tool_tail(args: &Value) -> Result<Value, String> {
1286    use crate::config;
1287    use crate::signing::verify_message_v31;
1288
1289    let peer_filter = args.get("peer").and_then(Value::as_str);
1290    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1291    // wire #79: orientation parity with `wire tail` CLI — default newest-N,
1292    // `oldest=true` opts back into FIFO. Agents almost always want the
1293    // freshest inbox slice when re-tailing an established peer, not the
1294    // wire-init handshake noise.
1295    let oldest = args.get("oldest").and_then(Value::as_bool).unwrap_or(false);
1296    let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1297    if !inbox.exists() {
1298        return Ok(json!([]));
1299    }
1300    let trust = config::read_trust().map_err(|e| e.to_string())?;
1301    let entries: Vec<_> = std::fs::read_dir(&inbox)
1302        .map_err(|e| e.to_string())?
1303        .filter_map(|e| e.ok())
1304        .map(|e| e.path())
1305        .filter(|p| {
1306            p.extension().map(|x| x == "jsonl").unwrap_or(false)
1307                && match peer_filter {
1308                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1309                    None => true,
1310                }
1311        })
1312        .collect();
1313
1314    // (timestamp, per-file line index, event with verified meta). Sort key
1315    // mirrors the CLI cmd_tail for cross-tool consistency.
1316    let mut collected: Vec<(String, usize, Value)> = Vec::new();
1317    for path in &entries {
1318        let body = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1319        for (idx, line) in body.lines().enumerate() {
1320            let event: Value = match serde_json::from_str(line) {
1321                Ok(v) => v,
1322                Err(_) => continue,
1323            };
1324            let verified = verify_message_v31(&event, &trust).is_ok();
1325            let mut event_with_meta = event.clone();
1326            if let Some(obj) = event_with_meta.as_object_mut() {
1327                obj.insert("verified".into(), json!(verified));
1328            }
1329            let ts = event
1330                .get("timestamp")
1331                .and_then(Value::as_str)
1332                .unwrap_or("")
1333                .to_string();
1334            collected.push((ts, idx, event_with_meta));
1335        }
1336    }
1337    collected.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
1338
1339    let total = collected.len();
1340    let window: Vec<Value> = if limit == 0 {
1341        collected.into_iter().map(|(_, _, e)| e).collect()
1342    } else if oldest {
1343        collected
1344            .into_iter()
1345            .take(limit)
1346            .map(|(_, _, e)| e)
1347            .collect()
1348    } else {
1349        let start = total.saturating_sub(limit);
1350        collected
1351            .into_iter()
1352            .skip(start)
1353            .map(|(_, _, e)| e)
1354            .collect()
1355    };
1356    Ok(Value::Array(window))
1357}
1358
1359fn tool_verify(args: &Value) -> Result<Value, String> {
1360    use crate::config;
1361    use crate::signing::verify_message_v31;
1362
1363    let event_str = args
1364        .get("event")
1365        .and_then(Value::as_str)
1366        .ok_or("missing 'event'")?;
1367    let event: Value =
1368        serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1369    let trust = config::read_trust().map_err(|e| e.to_string())?;
1370    match verify_message_v31(&event, &trust) {
1371        Ok(()) => Ok(json!({"verified": true})),
1372        Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1373    }
1374}
1375
1376// ---------- pairing tools ----------
1377
1378/// v0.13: bootstrap a freshly-resolved session-keyed identity. Runs once per
1379/// session home (gated on `is_initialized`); no-op under WIRE_MCP_SKIP_AUTO_UP.
1380/// init (one-name) + federation slot via `ensure_self_with_relay`, then a
1381/// best-effort phonebook claim of the DID-derived persona. Network failures
1382/// are swallowed — the identity is still created locally; the claim retries on
1383/// a later start.
1384fn ensure_session_bootstrapped() {
1385    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
1386        return;
1387    }
1388    if crate::config::is_initialized().unwrap_or(false) {
1389        return; // this session home already has an identity
1390    }
1391    let (did, relay_url, slot_id, slot_token) =
1392        match crate::pair_invite::ensure_self_with_relay(None) {
1393            Ok(t) => t,
1394            Err(_) => return, // offline / relay down — init may have happened locally; skip claim
1395        };
1396    if let Ok(card) = crate::config::read_agent_card() {
1397        let persona = crate::agent_card::display_handle_from_did(&did).to_string();
1398        let client = crate::relay_client::RelayClient::new(&relay_url);
1399        let _ = client.handle_claim_v2(&persona, &slot_id, &slot_token, None, &card, None);
1400    }
1401}
1402
1403fn tool_init(args: &Value) -> Result<Value, String> {
1404    let handle = args
1405        .get("handle")
1406        .and_then(Value::as_str)
1407        .ok_or("missing 'handle'")?;
1408    let name = args.get("name").and_then(Value::as_str);
1409    let relay = args.get("relay_url").and_then(Value::as_str);
1410    crate::pair_session::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1411}
1412
1413/// Resolve the relay URL: explicit arg wins, else the relay this agent's
1414/// identity is already bound to (from `wire init --relay` or a previous
1415/// pair_initiate). Errors if neither is set.
1416fn resolve_relay_url(args: &Value) -> Result<String, String> {
1417    if let Some(url) = args.get("relay_url").and_then(Value::as_str) {
1418        return Ok(url.to_string());
1419    }
1420    let state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1421    state["self"]["relay_url"]
1422        .as_str()
1423        .map(str::to_string)
1424        .ok_or_else(|| "no relay_url provided and no relay bound (call wire_init with relay_url, or pass relay_url here)".into())
1425}
1426
1427/// If `handle` is provided and identity isn't yet initialized, call
1428/// `init_self_idempotent` so a single MCP call can do both. If handle is
1429/// missing and not initialized, surface a clear error pointing the agent at
1430/// wire_init. If already initialized under a different handle, the
1431/// idempotent init errors clearly (same as direct wire_init).
1432fn auto_init_if_needed(args: &Value) -> Result<(), String> {
1433    let initialized = crate::config::is_initialized().map_err(|e| e.to_string())?;
1434    if initialized {
1435        return Ok(());
1436    }
1437    let handle = args.get("handle").and_then(Value::as_str).ok_or(
1438        "not initialized — pass `handle` to auto-init, or call wire_init explicitly first",
1439    )?;
1440    let relay = args.get("relay_url").and_then(Value::as_str);
1441    crate::pair_session::init_self_idempotent(handle, None, relay)
1442        .map(|_| ())
1443        .map_err(|e| e.to_string())
1444}
1445
1446fn tool_pair_initiate(args: &Value) -> Result<Value, String> {
1447    use crate::pair_session::{
1448        pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1449    };
1450
1451    store_sweep_expired();
1452    // Auto-init if `handle` arg provided and not yet inited (idempotent).
1453    auto_init_if_needed(args)?;
1454
1455    let relay_url = resolve_relay_url(args)?;
1456    let max_wait = args
1457        .get("max_wait_secs")
1458        .and_then(Value::as_u64)
1459        .unwrap_or(30)
1460        .min(60);
1461
1462    let mut s = pair_session_open("host", &relay_url, None).map_err(|e| e.to_string())?;
1463    let code = s.code.clone();
1464
1465    let sas_opt = if max_wait > 0 {
1466        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1467            .map_err(|e| e.to_string())?
1468    } else {
1469        None
1470    };
1471
1472    let session_id = store_insert(s);
1473
1474    let mut out = json!({
1475        "session_id": session_id,
1476        "code_phrase": code,
1477        "relay_url": relay_url,
1478    });
1479    match sas_opt {
1480        Some(sas) => {
1481            out["state"] = json!("sas_ready");
1482            out["sas"] = json!(sas);
1483            out["next"] = json!(
1484                "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel (voice/text). \
1485                 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1486            );
1487        }
1488        None => {
1489            out["state"] = json!("waiting");
1490            out["next"] = json!(
1491                "Share the code_phrase with the user; ask them to read it to their peer (the peer pastes into wire_pair_join). \
1492                 Poll wire_pair_check(session_id) until state='sas_ready'."
1493            );
1494        }
1495    }
1496    Ok(out)
1497}
1498
1499fn tool_pair_join(args: &Value) -> Result<Value, String> {
1500    use crate::pair_session::{
1501        pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1502    };
1503
1504    store_sweep_expired();
1505    auto_init_if_needed(args)?;
1506
1507    let code = args
1508        .get("code_phrase")
1509        .and_then(Value::as_str)
1510        .ok_or("missing 'code_phrase'")?;
1511    let relay_url = resolve_relay_url(args)?;
1512    let max_wait = args
1513        .get("max_wait_secs")
1514        .and_then(Value::as_u64)
1515        .unwrap_or(30)
1516        .min(60);
1517
1518    let mut s = pair_session_open("guest", &relay_url, Some(code)).map_err(|e| e.to_string())?;
1519
1520    let sas_opt =
1521        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1522            .map_err(|e| e.to_string())?;
1523
1524    let session_id = store_insert(s);
1525
1526    let mut out = json!({
1527        "session_id": session_id,
1528        "relay_url": relay_url,
1529    });
1530    match sas_opt {
1531        Some(sas) => {
1532            out["state"] = json!("sas_ready");
1533            out["sas"] = json!(sas);
1534            out["next"] = json!(
1535                "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel. \
1536                 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1537            );
1538        }
1539        None => {
1540            out["state"] = json!("waiting");
1541            out["next"] = json!("Poll wire_pair_check(session_id).");
1542        }
1543    }
1544    Ok(out)
1545}
1546
1547fn tool_pair_check(args: &Value) -> Result<Value, String> {
1548    use crate::pair_session::{pair_session_wait_for_sas, store_get, store_sweep_expired};
1549
1550    store_sweep_expired();
1551    let session_id = args
1552        .get("session_id")
1553        .and_then(Value::as_str)
1554        .ok_or("missing 'session_id'")?;
1555    let max_wait = args
1556        .get("max_wait_secs")
1557        .and_then(Value::as_u64)
1558        .unwrap_or(8)
1559        .min(60);
1560
1561    let arc = store_get(session_id)
1562        .ok_or_else(|| format!("no such session_id (expired or never opened): {session_id}"))?;
1563    let mut s = arc.lock().map_err(|e| e.to_string())?;
1564
1565    if s.finalized {
1566        return Ok(json!({
1567            "state": "finalized",
1568            "session_id": session_id,
1569            "sas": s.formatted_sas(),
1570        }));
1571    }
1572    if let Some(reason) = s.aborted.clone() {
1573        return Ok(json!({
1574            "state": "aborted",
1575            "session_id": session_id,
1576            "reason": reason,
1577        }));
1578    }
1579
1580    let sas_opt =
1581        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1582            .map_err(|e| e.to_string())?;
1583
1584    Ok(match sas_opt {
1585        Some(sas) => json!({
1586            "state": "sas_ready",
1587            "session_id": session_id,
1588            "sas": sas,
1589            "next": "Have the user TYPE the 6 SAS digits BACK INTO CHAT, then pass to wire_pair_confirm."
1590        }),
1591        None => json!({
1592            "state": "waiting",
1593            "session_id": session_id,
1594        }),
1595    })
1596}
1597
1598fn tool_pair_confirm(args: &Value, state: &McpState) -> Result<Value, String> {
1599    use crate::pair_session::{
1600        pair_session_confirm_sas, pair_session_finalize, store_get, store_remove,
1601    };
1602
1603    let session_id = args
1604        .get("session_id")
1605        .and_then(Value::as_str)
1606        .ok_or("missing 'session_id'")?;
1607    let typed = args
1608        .get("user_typed_digits")
1609        .and_then(Value::as_str)
1610        .ok_or(
1611            "missing 'user_typed_digits' — the user must type the 6 SAS digits back into chat",
1612        )?;
1613
1614    let arc = store_get(session_id).ok_or_else(|| format!("no such session_id: {session_id}"))?;
1615
1616    let confirm_err = {
1617        let mut s = arc.lock().map_err(|e| e.to_string())?;
1618        match pair_session_confirm_sas(&mut s, typed) {
1619            Ok(()) => None,
1620            Err(e) => Some((s.aborted.is_some(), e.to_string())),
1621        }
1622    };
1623    if let Some((aborted, msg)) = confirm_err {
1624        if aborted {
1625            store_remove(session_id);
1626        }
1627        return Err(msg);
1628    }
1629
1630    let mut result = {
1631        let mut s = arc.lock().map_err(|e| e.to_string())?;
1632        pair_session_finalize(&mut s, 30).map_err(|e| e.to_string())?
1633    };
1634    store_remove(session_id);
1635
1636    // ---- Post-pair auto-setup (Goal: zero friction after SAS) ----
1637    // 1. Auto-subscribe to wire://inbox/<peer> so clients that support
1638    //    resources/subscribe get push notifications/resources/updated.
1639    // 2. Spawn `wire daemon` if not already running so push/pull is automatic.
1640    // 3. Spawn `wire notify` if not already running so OS toasts fire on
1641    //    inbox grow (covers MCP hosts that lack resources/subscribe).
1642    // 4. Emit notifications/resources/list_changed via the writer channel so
1643    //    a client that called resources/list before pairing refreshes its view.
1644    let peer_handle = result["peer_handle"].as_str().unwrap_or("").to_string();
1645    let peer_uri = format!("wire://inbox/{peer_handle}");
1646
1647    let mut auto = json!({
1648        "subscribed": false,
1649        "daemon": "unknown",
1650        "notify": "unknown",
1651        "resources_list_changed_emitted": false,
1652    });
1653
1654    if !peer_handle.is_empty()
1655        && let Ok(mut g) = state.subscribed.lock()
1656    {
1657        g.insert(peer_uri.clone());
1658        auto["subscribed"] = json!(true);
1659    }
1660
1661    auto["daemon"] = match crate::ensure_up::ensure_daemon_running() {
1662        Ok(true) => json!("spawned"),
1663        Ok(false) => json!("already_running"),
1664        Err(e) => json!(format!("spawn_error: {e}")),
1665    };
1666    auto["notify"] = match crate::ensure_up::ensure_notify_running() {
1667        Ok(true) => json!("spawned"),
1668        Ok(false) => json!("already_running"),
1669        Err(e) => json!(format!("spawn_error: {e}")),
1670    };
1671
1672    if let Some(tx) = state.notif_tx.lock().ok().and_then(|g| g.clone()) {
1673        let notif = json!({
1674            "jsonrpc": "2.0",
1675            "method": "notifications/resources/list_changed",
1676        });
1677        if tx.send(notif.to_string()).is_ok() {
1678            auto["resources_list_changed_emitted"] = json!(true);
1679        }
1680    }
1681
1682    result["auto"] = auto;
1683    result["next"] = json!(
1684        "Done. Daemon + notify running, subscribed to peer inbox. Use wire_send/wire_tail \
1685         freely; new events arrive via notifications/resources/updated (where supported) and \
1686         OS toasts (always)."
1687    );
1688    Ok(result)
1689}
1690
1691// ---------- detached pair tools (daemon-orchestrated) ----------
1692
1693fn tool_pair_initiate_detached(args: &Value) -> Result<Value, String> {
1694    auto_init_if_needed(args)?;
1695    let relay_url = resolve_relay_url(args)?;
1696    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1697        let _ = crate::ensure_up::ensure_daemon_running();
1698    }
1699    let code = crate::sas::generate_code_phrase();
1700    let code_hash = crate::pair_session::derive_code_hash(&code);
1701    let now = time::OffsetDateTime::now_utc()
1702        .format(&time::format_description::well_known::Rfc3339)
1703        .unwrap_or_default();
1704    let p = crate::pending_pair::PendingPair {
1705        code: code.clone(),
1706        code_hash,
1707        role: "host".to_string(),
1708        relay_url: relay_url.clone(),
1709        status: "request_host".to_string(),
1710        sas: None,
1711        peer_did: None,
1712        created_at: now,
1713        last_error: None,
1714        pair_id: None,
1715        our_slot_id: None,
1716        our_slot_token: None,
1717        spake2_seed_b64: None,
1718    };
1719    crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1720    Ok(json!({
1721        "code_phrase": code,
1722        "relay_url": relay_url,
1723        "state": "queued",
1724        "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."
1725    }))
1726}
1727
1728fn tool_pair_join_detached(args: &Value) -> Result<Value, String> {
1729    auto_init_if_needed(args)?;
1730    let relay_url = resolve_relay_url(args)?;
1731    let code_phrase = args
1732        .get("code_phrase")
1733        .and_then(Value::as_str)
1734        .ok_or("missing 'code_phrase'")?;
1735    let code = crate::sas::parse_code_phrase(code_phrase)
1736        .map_err(|e| e.to_string())?
1737        .to_string();
1738    let code_hash = crate::pair_session::derive_code_hash(&code);
1739    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1740        let _ = crate::ensure_up::ensure_daemon_running();
1741    }
1742    let now = time::OffsetDateTime::now_utc()
1743        .format(&time::format_description::well_known::Rfc3339)
1744        .unwrap_or_default();
1745    let p = crate::pending_pair::PendingPair {
1746        code: code.clone(),
1747        code_hash,
1748        role: "guest".to_string(),
1749        relay_url: relay_url.clone(),
1750        status: "request_guest".to_string(),
1751        sas: None,
1752        peer_did: None,
1753        created_at: now,
1754        last_error: None,
1755        pair_id: None,
1756        our_slot_id: None,
1757        our_slot_token: None,
1758        spake2_seed_b64: None,
1759    };
1760    crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1761    Ok(json!({
1762        "code_phrase": code,
1763        "relay_url": relay_url,
1764        "state": "queued",
1765        "next": "Subscribe to wire://pending-pair/all; on sas_ready notification, surface digits to user and call wire_pair_confirm_detached."
1766    }))
1767}
1768
1769fn tool_pair_list_pending() -> Result<Value, String> {
1770    let items = crate::pending_pair::list_pending().map_err(|e| e.to_string())?;
1771    Ok(json!({"pending": items}))
1772}
1773
1774fn tool_pair_confirm_detached(args: &Value) -> Result<Value, String> {
1775    let code_phrase = args
1776        .get("code_phrase")
1777        .and_then(Value::as_str)
1778        .ok_or("missing 'code_phrase'")?;
1779    let typed = args
1780        .get("user_typed_digits")
1781        .and_then(Value::as_str)
1782        .ok_or("missing 'user_typed_digits'")?;
1783    let code = crate::sas::parse_code_phrase(code_phrase)
1784        .map_err(|e| e.to_string())?
1785        .to_string();
1786    let typed: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
1787    if typed.len() != 6 {
1788        return Err(format!(
1789            "expected 6 digits (got {} after stripping non-digits)",
1790            typed.len()
1791        ));
1792    }
1793    let mut p = crate::pending_pair::read_pending(&code)
1794        .map_err(|e| e.to_string())?
1795        .ok_or_else(|| format!("no pending pair for code {code}"))?;
1796    if p.status != "sas_ready" {
1797        return Err(format!(
1798            "pair {code} not in sas_ready state (current: {})",
1799            p.status
1800        ));
1801    }
1802    let stored = p
1803        .sas
1804        .as_ref()
1805        .ok_or("pending file has status=sas_ready but no sas field")?
1806        .clone();
1807    if stored == typed {
1808        p.status = "confirmed".to_string();
1809        crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1810        Ok(json!({
1811            "state": "confirmed",
1812            "code_phrase": code,
1813            "next": "Daemon will finalize on its next tick (~1s). Poll wire_peers or watch wire://pending-pair/all for the entry to disappear."
1814        }))
1815    } else {
1816        p.status = "aborted".to_string();
1817        p.last_error = Some(format!(
1818            "SAS digit mismatch (typed {typed}, expected {stored})"
1819        ));
1820        let client = crate::relay_client::RelayClient::new(&p.relay_url);
1821        let _ = client.pair_abandon(&p.code_hash);
1822        let _ = crate::pending_pair::write_pending(&p);
1823        crate::os_notify::toast(
1824            &format!("wire — pair aborted ({code})"),
1825            p.last_error.as_deref().unwrap_or("digits mismatch"),
1826        );
1827        Err(
1828            "digits mismatch — pair aborted. Re-issue with wire_pair_initiate_detached."
1829                .to_string(),
1830        )
1831    }
1832}
1833
1834fn tool_pair_cancel_pending(args: &Value) -> Result<Value, String> {
1835    let code_phrase = args
1836        .get("code_phrase")
1837        .and_then(Value::as_str)
1838        .ok_or("missing 'code_phrase'")?;
1839    let code = crate::sas::parse_code_phrase(code_phrase)
1840        .map_err(|e| e.to_string())?
1841        .to_string();
1842    if let Some(p) = crate::pending_pair::read_pending(&code).map_err(|e| e.to_string())? {
1843        let client = crate::relay_client::RelayClient::new(&p.relay_url);
1844        let _ = client.pair_abandon(&p.code_hash);
1845    }
1846    crate::pending_pair::delete_pending(&code).map_err(|e| e.to_string())?;
1847    Ok(json!({"state": "cancelled", "code_phrase": code}))
1848}
1849
1850// ---------- invite-URL one-paste pair (v0.4.0) ----------
1851
1852fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1853    let relay_url = args.get("relay_url").and_then(Value::as_str);
1854    let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1855    let uses = args
1856        .get("uses")
1857        .and_then(Value::as_u64)
1858        .map(|u| u as u32)
1859        .unwrap_or(1);
1860    let url =
1861        crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1862    let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1863    Ok(json!({
1864        "invite_url": url,
1865        "ttl_secs": ttl_resolved,
1866        "uses": uses,
1867    }))
1868}
1869
1870fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1871    let url = args
1872        .get("url")
1873        .and_then(Value::as_str)
1874        .ok_or("missing 'url'")?;
1875    crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1876}
1877
1878// ---------- v0.5 — agentic hotline tools ----------
1879
1880/// wire_dial (MCP): mirror the CLI `dial` resolution ladder. The prior
1881/// wiring routed straight to `tool_add`, which reads a required `handle`
1882/// arg — but the wire_dial schema only provides `name`, so every dial
1883/// errored `missing 'handle'`. This reads `name` and routes:
1884///   • `<nick>@<relay>`  -> federation pair (via tool_add).
1885///   • already-pinned     -> no-op success (peer already reachable).
1886///   • otherwise          -> honest error. Bare-nickname / local-sister
1887///     resolution over MCP is not yet wired (CLI `wire dial` does it);
1888///     use `<nick>@<relay>` or `wire_send` (auto-pairs on miss).
1889fn tool_dial(args: &Value) -> Result<Value, String> {
1890    let name = args
1891        .get("name")
1892        .and_then(Value::as_str)
1893        .or_else(|| args.get("handle").and_then(Value::as_str))
1894        .ok_or("missing 'name'")?;
1895
1896    if name.contains('@') {
1897        // Federation path. Present `name` as the `handle` tool_add expects.
1898        let mut a = args.clone();
1899        if let Some(obj) = a.as_object_mut() {
1900            obj.insert("handle".into(), Value::String(name.to_string()));
1901        }
1902        return tool_add(&a);
1903    }
1904
1905    let relay_state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1906    let pinned = relay_state
1907        .get("peers")
1908        .and_then(Value::as_object)
1909        .map(|m| m.contains_key(name))
1910        .unwrap_or(false);
1911    if pinned {
1912        return Ok(json!({
1913            "name_input": name,
1914            "status": "already_pinned",
1915            "peer_handle": name,
1916        }));
1917    }
1918
1919    Err(format!(
1920        "cannot resolve `{name}` over MCP: bare-nickname / local-sister dialling is not yet \
1921         wired into the MCP surface. Use a federation handle `{name}@<relay>`, or `wire_send` \
1922         (it auto-pairs on miss)."
1923    ))
1924}
1925
1926fn tool_add(args: &Value) -> Result<Value, String> {
1927    let handle = args
1928        .get("handle")
1929        .and_then(Value::as_str)
1930        .ok_or("missing 'handle'")?;
1931    let relay_override = args.get("relay_url").and_then(Value::as_str);
1932
1933    let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1934
1935    // Ensure self has identity + relay slot (auto-inits if needed).
1936    let (our_did, our_relay, our_slot_id, our_slot_token) =
1937        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1938
1939    // Resolve peer via .well-known.
1940    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1941        .map_err(|e| format!("{e:#}"))?;
1942    let peer_card = resolved
1943        .get("card")
1944        .cloned()
1945        .ok_or("resolved missing card")?;
1946    let peer_did = resolved
1947        .get("did")
1948        .and_then(Value::as_str)
1949        .ok_or("resolved missing did")?
1950        .to_string();
1951    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1952    let peer_slot_id = resolved
1953        .get("slot_id")
1954        .and_then(Value::as_str)
1955        .ok_or("resolved missing slot_id")?
1956        .to_string();
1957    let peer_relay = resolved
1958        .get("relay_url")
1959        .and_then(Value::as_str)
1960        .map(str::to_string)
1961        .or_else(|| relay_override.map(str::to_string))
1962        .unwrap_or_else(|| format!("https://{}", parsed.domain));
1963
1964    // Pin peer in trust + relay-state. slot_token arrives via ack later.
1965    let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1966    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1967    crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1968    let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1969    let existing_token = relay_state
1970        .get("peers")
1971        .and_then(|p| p.get(&peer_handle))
1972        .and_then(|p| p.get("slot_token"))
1973        .and_then(Value::as_str)
1974        .map(str::to_string)
1975        .unwrap_or_default();
1976    relay_state["peers"][&peer_handle] = json!({
1977        "relay_url": peer_relay,
1978        "slot_id": peer_slot_id,
1979        "slot_token": existing_token,
1980    });
1981    crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1982
1983    // Build + sign pair_drop event (no nonce — open-mode handle pair).
1984    let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1985    let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1986    let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1987    let pk_b64 = our_card
1988        .get("verify_keys")
1989        .and_then(Value::as_object)
1990        .and_then(|m| m.values().next())
1991        .and_then(|v| v.get("key"))
1992        .and_then(Value::as_str)
1993        .ok_or("our card missing verify_keys[*].key")?;
1994    let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1995    let now = time::OffsetDateTime::now_utc()
1996        .format(&time::format_description::well_known::Rfc3339)
1997        .unwrap_or_default();
1998    let event = json!({
1999        "timestamp": now,
2000        "from": our_did,
2001        "to": peer_did,
2002        "type": "pair_drop",
2003        "kind": 1100u32,
2004        "body": {
2005            "card": our_card,
2006            "relay_url": our_relay,
2007            "slot_id": our_slot_id,
2008            "slot_token": our_slot_token,
2009        },
2010    });
2011    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
2012        .map_err(|e| format!("{e:#}"))?;
2013
2014    let client = crate::relay_client::RelayClient::new(&peer_relay);
2015    let resp = client
2016        .handle_intro(&parsed.nick, &signed)
2017        .map_err(|e| format!("{e:#}"))?;
2018    let event_id = signed
2019        .get("event_id")
2020        .and_then(Value::as_str)
2021        .unwrap_or("")
2022        .to_string();
2023    Ok(json!({
2024        "handle": handle,
2025        "paired_with": peer_did,
2026        "peer_handle": peer_handle,
2027        "event_id": event_id,
2028        "drop_response": resp,
2029        "status": "drop_sent",
2030    }))
2031}
2032
2033/// v0.5.14: MCP `wire_pair_accept` — bilateral completion of a
2034/// pending-inbound pair request. The agent SHOULD have surfaced the
2035/// pending request to the operator before calling this; acceptance
2036/// grants peer authenticated write access to this agent's inbox.
2037fn tool_pair_accept(args: &Value) -> Result<Value, String> {
2038    let peer = args
2039        .get("peer")
2040        .and_then(Value::as_str)
2041        .ok_or("missing 'peer'")?;
2042    let nick = crate::agent_card::bare_handle(peer);
2043    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
2044        .map_err(|e| format!("{e:#}"))?
2045        .ok_or_else(|| {
2046            format!(
2047                "no pending pair request from {nick}. Call wire_pair_list_inbound to enumerate, \
2048                 or wire_add to send a fresh outbound pair request."
2049            )
2050        })?;
2051
2052    // Pin trust with VERIFIED — operator-equivalent consent gesture (the
2053    // agent is acting on the operator's instruction to accept).
2054    let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
2055    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
2056    crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
2057
2058    // Record peer's relay coords + slot_token from the stored drop.
2059    let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
2060    relay_state["peers"][&pending.peer_handle] = json!({
2061        "relay_url": pending.peer_relay_url,
2062        "slot_id": pending.peer_slot_id,
2063        "slot_token": pending.peer_slot_token,
2064    });
2065    crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
2066
2067    // Ship our slot_token via pair_drop_ack — Bug 2 fix: iterate the peer's
2068    // advertised endpoints in priority order, only fail if all are dead. The
2069    // pending record's `peer_endpoints` carries the full advertised list when
2070    // the pair_drop was written by a v0.5.17+ peer; fall back to a one-element
2071    // slice from the legacy triple for older records so we still hit the
2072    // failover helper with a valid input.
2073    let ack_endpoints: Vec<crate::endpoints::Endpoint> = if pending.peer_endpoints.is_empty() {
2074        vec![crate::endpoints::Endpoint::federation(
2075            pending.peer_relay_url.clone(),
2076            pending.peer_slot_id.clone(),
2077            pending.peer_slot_token.clone(),
2078        )]
2079    } else {
2080        pending.peer_endpoints.clone()
2081    };
2082    crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &ack_endpoints).map_err(|e| {
2083        format!(
2084            "pair_drop_ack send to {} (across {} endpoint(s)) failed: {e:#}",
2085            pending.peer_handle,
2086            ack_endpoints.len()
2087        )
2088    })?;
2089
2090    crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2091
2092    Ok(json!({
2093        "status": "bilateral_accepted",
2094        "peer_handle": pending.peer_handle,
2095        "peer_did": pending.peer_did,
2096        "peer_relay_url": pending.peer_relay_url,
2097        "via": "pending_inbound",
2098    }))
2099}
2100
2101/// v0.5.14: MCP `wire_pair_reject` — delete a pending-inbound record
2102/// without pairing. Peer never receives our slot_token. Idempotent.
2103fn tool_pair_reject(args: &Value) -> Result<Value, String> {
2104    let peer = args
2105        .get("peer")
2106        .and_then(Value::as_str)
2107        .ok_or("missing 'peer'")?;
2108    let nick = crate::agent_card::bare_handle(peer);
2109    let existed =
2110        crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2111    crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2112    Ok(json!({
2113        "peer": nick,
2114        "rejected": existed.is_some(),
2115        "had_pending": existed.is_some(),
2116    }))
2117}
2118
2119/// v0.5.14: MCP `wire_pair_list_inbound` — enumerate pending-inbound
2120/// pair requests for operator review. Flat array sorted oldest-first.
2121fn tool_pair_list_inbound() -> Result<Value, String> {
2122    let items =
2123        crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
2124    Ok(json!(items))
2125}
2126
2127fn tool_claim_handle(args: &Value) -> Result<Value, String> {
2128    let typed = args.get("nick").and_then(Value::as_str);
2129    let relay_override = args.get("relay_url").and_then(Value::as_str);
2130    let public_url = args.get("public_url").and_then(Value::as_str);
2131
2132    // Auto-init + ensure slot.
2133    let (_, our_relay, our_slot_id, our_slot_token) =
2134        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
2135    let claim_relay = relay_override.unwrap_or(&our_relay);
2136    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2137
2138    // One-name rule (v0.13.1): the claimed handle is ALWAYS the DID-derived
2139    // persona, so the phonebook entry can never drift from the agent-card
2140    // handle. `nick` is optional + advisory — a value that differs is ignored.
2141    // See cmd_claim for the rationale (closes the claim-path "two names" hole).
2142    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
2143    let canonical = crate::agent_card::display_handle_from_did(did).to_string();
2144    let nick = if canonical.is_empty() {
2145        typed.unwrap_or_default().to_string()
2146    } else {
2147        canonical
2148    };
2149    let typed_nick_ignored = typed.map(|t| t != nick).unwrap_or(false);
2150
2151    let client = crate::relay_client::RelayClient::new(claim_relay);
2152    let resp = client
2153        .handle_claim(&nick, &our_slot_id, &our_slot_token, public_url, &card)
2154        .map_err(|e| format!("{e:#}"))?;
2155    Ok(json!({
2156        "nick": nick,
2157        "relay": claim_relay,
2158        "response": resp,
2159        "one_name": true,
2160        "typed_nick_ignored": typed_nick_ignored,
2161    }))
2162}
2163
2164fn tool_whois(args: &Value) -> Result<Value, String> {
2165    if let Some(handle) = args.get("handle").and_then(Value::as_str) {
2166        let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
2167        let relay_override = args.get("relay_url").and_then(Value::as_str);
2168        crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
2169    } else {
2170        // Self.
2171        let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2172        Ok(json!({
2173            "did": card.get("did").cloned().unwrap_or(Value::Null),
2174            "profile": card.get("profile").cloned().unwrap_or(Value::Null),
2175        }))
2176    }
2177}
2178
2179fn tool_profile_set(args: &Value) -> Result<Value, String> {
2180    let field = args
2181        .get("field")
2182        .and_then(Value::as_str)
2183        .ok_or("missing 'field'")?;
2184    let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
2185    // If value is a string that itself parses as JSON (e.g. "[\"rust\"]"),
2186    // unwrap it. Otherwise pass as-is. Lets agents send either typed values
2187    // or stringified JSON.
2188    let value = if let Some(s) = raw_value.as_str() {
2189        serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
2190    } else {
2191        raw_value
2192    };
2193    let new_profile =
2194        crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
2195    Ok(json!({
2196        "field": field,
2197        "profile": new_profile,
2198    }))
2199}
2200
2201fn tool_profile_get() -> Result<Value, String> {
2202    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2203    Ok(json!({
2204        "did": card.get("did").cloned().unwrap_or(Value::Null),
2205        "profile": card.get("profile").cloned().unwrap_or(Value::Null),
2206    }))
2207}
2208
2209// ---------- helpers ----------
2210
2211fn parse_kind(s: &str) -> u32 {
2212    if let Ok(n) = s.parse::<u32>() {
2213        return n;
2214    }
2215    for (id, name) in crate::signing::kinds() {
2216        if *name == s {
2217            return *id;
2218        }
2219    }
2220    1
2221}
2222
2223fn error_response(id: &Value, code: i32, message: &str) -> Value {
2224    json!({
2225        "jsonrpc": "2.0",
2226        "id": id,
2227        "error": {"code": code, "message": message}
2228    })
2229}
2230
2231#[cfg(test)]
2232mod tests {
2233    use super::*;
2234
2235    #[test]
2236    fn unknown_method_returns_jsonrpc_error() {
2237        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
2238        let resp = handle_request(&req, &McpState::default());
2239        assert_eq!(resp["error"]["code"], -32601);
2240    }
2241
2242    #[test]
2243    fn initialize_advertises_tools_capability() {
2244        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
2245        let resp = handle_request(&req, &McpState::default());
2246        assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
2247        assert!(resp["result"]["capabilities"]["tools"].is_object());
2248        assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
2249    }
2250
2251    #[test]
2252    fn tools_list_includes_pairing_and_messaging() {
2253        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2254        let resp = handle_request(&req, &McpState::default());
2255        let names: Vec<&str> = resp["result"]["tools"]
2256            .as_array()
2257            .unwrap()
2258            .iter()
2259            .filter_map(|t| t["name"].as_str())
2260            .collect();
2261        for required in [
2262            "wire_whoami",
2263            "wire_peers",
2264            "wire_send",
2265            "wire_tail",
2266            "wire_verify",
2267            "wire_init",
2268            "wire_pair_initiate",
2269            "wire_pair_join",
2270            "wire_pair_check",
2271            "wire_pair_confirm",
2272        ] {
2273            assert!(
2274                names.contains(&required),
2275                "missing required tool {required}"
2276            );
2277        }
2278        // wire_join (the old direct alias for pair-join, no SAS-typeback) is
2279        // explicitly NOT in the catalog. Calling it returns a deprecation
2280        // pointing to wire_pair_join (test below covers this).
2281        assert!(
2282            !names.contains(&"wire_join"),
2283            "wire_join must not be advertised — superseded by wire_pair_join"
2284        );
2285    }
2286
2287    #[test]
2288    fn legacy_wire_join_call_returns_helpful_error() {
2289        let req = json!({
2290            "jsonrpc": "2.0",
2291            "id": 1,
2292            "method": "tools/call",
2293            "params": {"name": "wire_join", "arguments": {}}
2294        });
2295        let resp = handle_request(&req, &McpState::default());
2296        assert_eq!(resp["result"]["isError"], true);
2297        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2298        assert!(
2299            text.contains("wire_pair_join"),
2300            "expected redirect to wire_pair_join, got: {text}"
2301        );
2302    }
2303
2304    #[test]
2305    fn pair_confirm_missing_session_id_errors_cleanly() {
2306        let req = json!({
2307            "jsonrpc": "2.0",
2308            "id": 1,
2309            "method": "tools/call",
2310            "params": {"name": "wire_pair_confirm", "arguments": {"user_typed_digits": "111111"}}
2311        });
2312        let resp = handle_request(&req, &McpState::default());
2313        assert_eq!(resp["result"]["isError"], true);
2314    }
2315
2316    #[test]
2317    fn pair_confirm_unknown_session_errors_cleanly() {
2318        let req = json!({
2319            "jsonrpc": "2.0",
2320            "id": 1,
2321            "method": "tools/call",
2322            "params": {
2323                "name": "wire_pair_confirm",
2324                "arguments": {"session_id": "definitely-not-real", "user_typed_digits": "111111"}
2325            }
2326        });
2327        let resp = handle_request(&req, &McpState::default());
2328        assert_eq!(resp["result"]["isError"], true);
2329        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2330        assert!(text.contains("no such session_id"), "got: {text}");
2331    }
2332
2333    #[test]
2334    fn initialize_advertises_resources_capability() {
2335        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
2336        let resp = handle_request(&req, &McpState::default());
2337        let caps = &resp["result"]["capabilities"];
2338        assert!(
2339            caps["resources"].is_object(),
2340            "resources capability must be present, got {resp}"
2341        );
2342        assert_eq!(
2343            caps["resources"]["subscribe"], true,
2344            "subscribe shipped in v0.2.1"
2345        );
2346    }
2347
2348    #[test]
2349    fn resources_read_with_bad_uri_errors() {
2350        let req = json!({
2351            "jsonrpc": "2.0",
2352            "id": 1,
2353            "method": "resources/read",
2354            "params": {"uri": "http://example.com/not-a-wire-uri"}
2355        });
2356        let resp = handle_request(&req, &McpState::default());
2357        assert!(resp.get("error").is_some(), "expected error, got {resp}");
2358    }
2359
2360    #[test]
2361    fn parse_inbox_uri_handles_variants() {
2362        assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
2363        assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
2364        assert!(
2365            parse_inbox_uri("wire://inbox/")
2366                .unwrap()
2367                .starts_with("__invalid__"),
2368            "empty peer must be invalid"
2369        );
2370        assert!(
2371            parse_inbox_uri("http://other")
2372                .unwrap()
2373                .starts_with("__invalid__"),
2374            "non-wire scheme must be invalid"
2375        );
2376    }
2377
2378    #[test]
2379    fn ping_returns_empty_result() {
2380        let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2381        let resp = handle_request(&req, &McpState::default());
2382        assert_eq!(resp["id"], 7);
2383        assert!(resp["result"].is_object());
2384    }
2385
2386    #[test]
2387    fn notification_returns_null_no_reply() {
2388        let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2389        let resp = handle_request(&req, &McpState::default());
2390        assert_eq!(resp, Value::Null);
2391    }
2392
2393    /// v0.6.1 regression: `detect_session_wire_home` must return the
2394    /// session's home dir when the cwd is in the registry AND the
2395    /// session dir exists on disk. The original v0.6.1 shipped with
2396    /// only an eprintln "verification" — this test asserts the
2397    /// observable return value so the env-set-but-not-consumed class
2398    /// of bug fails loudly.
2399    #[test]
2400    fn detect_session_wire_home_resolves_registered_cwd() {
2401        crate::config::test_support::with_temp_home(|| {
2402            // Set up sessions/registry.json + sessions/test-alpha/ under
2403            // the temp WIRE_HOME so session::read_registry +
2404            // session::session_dir resolve through it.
2405            let wire_home = std::env::var("WIRE_HOME").unwrap();
2406            let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2407            let session_home = sessions_root.join("test-alpha");
2408            std::fs::create_dir_all(&session_home).unwrap();
2409            let fake_cwd = "/tmp/fake-project-cwd-abc123";
2410            let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2411            std::fs::write(
2412                sessions_root.join("registry.json"),
2413                serde_json::to_vec_pretty(&registry).unwrap(),
2414            )
2415            .unwrap();
2416
2417            // Hit happy path.
2418            let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2419            assert_eq!(
2420                got.as_deref(),
2421                Some(session_home.as_path()),
2422                "registered cwd must resolve to session_home"
2423            );
2424
2425            // Unregistered cwd → None.
2426            let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2427                "/tmp/cwd-not-in-registry-xyz789",
2428            ));
2429            assert!(nope.is_none(), "unregistered cwd must return None");
2430
2431            // Registered cwd but session dir missing → None (defensive:
2432            // stale registry entry pointing at a deleted session).
2433            let stale_cwd = "/tmp/stale-session-cwd";
2434            let stale_registry =
2435                json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2436            std::fs::write(
2437                sessions_root.join("registry.json"),
2438                serde_json::to_vec_pretty(&stale_registry).unwrap(),
2439            )
2440            .unwrap();
2441            let stale_got =
2442                crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2443            assert!(
2444                stale_got.is_none(),
2445                "registered cwd whose session dir is missing must return None"
2446            );
2447        });
2448    }
2449}