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.6.10: surface multi-agent identity collisions explicitly.
113    // Two Claudes (or any MCP-host pair) launched in the same cwd
114    // auto-detect into the same wire session and silently share an
115    // inbox cursor. v0.6.7 made this invisible by design ("just adopt
116    // the cwd's session"); operators hit it as "they look identical"
117    // and burn hours debugging. The warning gives them a clear
118    // remediation path the first time they see it.
119    crate::session::warn_on_identity_collision(std::process::id());
120
121    let state = McpState::default();
122    let shutdown = Arc::new(AtomicBool::new(false));
123
124    let (tx, rx) = mpsc::channel::<String>();
125
126    // Expose the tx clone via state so tool handlers can push unsolicited
127    // notifications (notifications/resources/list_changed after a pair pin).
128    if let Ok(mut g) = state.notif_tx.lock() {
129        *g = Some(tx.clone());
130    }
131
132    // Writer thread — single owner of stdout. Exits when all senders drop.
133    let writer_handle = std::thread::spawn(move || {
134        let stdout = std::io::stdout();
135        let mut w = stdout.lock();
136        while let Ok(line) = rx.recv() {
137            if writeln!(w, "{line}").is_err() {
138                break;
139            }
140            if w.flush().is_err() {
141                break;
142            }
143        }
144    });
145
146    // Watcher thread — polls inbox every 2s and emits
147    // notifications/resources/updated on grow. Observes `shutdown` so we
148    // can exit cleanly on stdin EOF (otherwise its tx_w clone keeps the
149    // writer thread blocked on rx.recv forever).
150    let subs_w = state.subscribed.clone();
151    let tx_w = tx.clone();
152    let shutdown_w = shutdown.clone();
153    let watcher_handle = std::thread::spawn(move || {
154        let mut watcher = match crate::inbox_watch::InboxWatcher::from_head() {
155            Ok(w) => w,
156            Err(_) => return,
157        };
158        // Per-code fingerprint (status string) of the last seen pending-pair
159        // snapshot. Used to detect transitions so we emit at most one
160        // notification per actual change (not per poll).
161        let mut prev_pending: std::collections::HashMap<String, String> =
162            std::collections::HashMap::new();
163        let poll_interval = Duration::from_secs(2);
164        let mut next_poll = Instant::now() + poll_interval;
165        loop {
166            if shutdown_w.load(Ordering::SeqCst) {
167                return;
168            }
169            std::thread::sleep(Duration::from_millis(100));
170            if Instant::now() < next_poll {
171                continue;
172            }
173            next_poll = Instant::now() + poll_interval;
174            let subs_snapshot = match subs_w.lock() {
175                Ok(g) => g.clone(),
176                Err(_) => return,
177            };
178
179            let mut affected: HashSet<String> = HashSet::new();
180
181            // ---- inbox events ----
182            if !subs_snapshot.is_empty()
183                && let Ok(events) = watcher.poll()
184            {
185                for ev in &events {
186                    if subs_snapshot.contains("wire://inbox/all") {
187                        affected.insert("wire://inbox/all".to_string());
188                    }
189                    let peer_uri = format!("wire://inbox/{}", ev.peer);
190                    if subs_snapshot.contains(&peer_uri) {
191                        affected.insert(peer_uri);
192                    }
193                }
194            }
195
196            // ---- pending-pair state changes ----
197            // Always poll (cheap dir read); only emit if subscribed.
198            if let Ok(items) = crate::pending_pair::list_pending() {
199                let mut cur: std::collections::HashMap<String, String> =
200                    std::collections::HashMap::new();
201                for p in &items {
202                    cur.insert(p.code.clone(), p.status.clone());
203                }
204                // Detect any change vs. prev_pending: new code, removed code,
205                // or status flip on existing code.
206                let changed = cur.len() != prev_pending.len()
207                    || cur.iter().any(|(k, v)| prev_pending.get(k) != Some(v))
208                    || prev_pending.keys().any(|k| !cur.contains_key(k));
209                if changed && subs_snapshot.contains("wire://pending-pair/all") {
210                    affected.insert("wire://pending-pair/all".to_string());
211                }
212                prev_pending = cur;
213            }
214
215            for uri in affected {
216                let notif = json!({
217                    "jsonrpc": "2.0",
218                    "method": "notifications/resources/updated",
219                    "params": {"uri": uri}
220                });
221                if tx_w.send(notif.to_string()).is_err() {
222                    return;
223                }
224            }
225        }
226    });
227
228    let stdin = std::io::stdin();
229    let mut reader = BufReader::new(stdin.lock());
230    let mut line = String::new();
231    loop {
232        line.clear();
233        let n = reader.read_line(&mut line)?;
234        if n == 0 {
235            // EOF — signal watcher to exit; clear the notif_tx Sender clone
236            // that state holds (otherwise writer's rx.recv() never sees
237            // all-senders-dropped); drop main tx; wait for worker threads.
238            shutdown.store(true, Ordering::SeqCst);
239            if let Ok(mut g) = state.notif_tx.lock() {
240                *g = None;
241            }
242            drop(tx);
243            let _ = watcher_handle.join();
244            let _ = writer_handle.join();
245            return Ok(());
246        }
247        let trimmed = line.trim();
248        if trimmed.is_empty() {
249            continue;
250        }
251        let request: Value = match serde_json::from_str(trimmed) {
252            Ok(v) => v,
253            Err(e) => {
254                let err = error_response(&Value::Null, -32700, &format!("parse error: {e}"));
255                let _ = tx.send(err.to_string());
256                continue;
257            }
258        };
259        let response = handle_request(&request, &state);
260        // Notifications (no `id`) get no response.
261        if response.get("id").is_some() || response.get("error").is_some() {
262            let _ = tx.send(response.to_string());
263        }
264    }
265}
266
267fn handle_request(req: &Value, state: &McpState) -> Value {
268    let id = req.get("id").cloned().unwrap_or(Value::Null);
269    let method = match req.get("method").and_then(Value::as_str) {
270        Some(m) => m,
271        None => return error_response(&id, -32600, "missing method"),
272    };
273    match method {
274        "initialize" => handle_initialize(&id),
275        "notifications/initialized" => Value::Null, // notification — no reply
276        "tools/list" => handle_tools_list(&id),
277        "tools/call" => handle_tools_call(&id, req.get("params").unwrap_or(&Value::Null), state),
278        "resources/list" => handle_resources_list(&id),
279        "resources/read" => handle_resources_read(&id, req.get("params").unwrap_or(&Value::Null)),
280        "resources/subscribe" => {
281            handle_resources_subscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
282        }
283        "resources/unsubscribe" => {
284            handle_resources_unsubscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
285        }
286        "ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}),
287        other => error_response(&id, -32601, &format!("method not found: {other}")),
288    }
289}
290
291// ---------- resources (Goal 2) ----------
292//
293// MCP resources expose semi-static state for agents that want a "read this
294// when relevant" surface instead of polling tools. v0.2 ships read-only;
295// subscribe (push-notify on inbox grow) is v0.2.1 — requires a background
296// watcher thread + async stdout writer.
297//
298// Resource URI scheme:
299//   wire://inbox/<peer>    last 50 verified events for that pinned peer
300//   wire://inbox/all       last 50 events across all peers, newest first
301
302fn handle_resources_list(id: &Value) -> Value {
303    let mut resources = vec![
304        json!({
305            "uri": "wire://inbox/all",
306            "name": "wire inbox (all peers)",
307            "description": "Most recent verified events from all pinned peers, JSONL.",
308            "mimeType": "application/x-ndjson"
309        }),
310        json!({
311            "uri": "wire://pending-pair/all",
312            "name": "wire pending pair sessions",
313            "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).",
314            "mimeType": "application/json"
315        }),
316    ];
317
318    if let Ok(trust) = crate::config::read_trust() {
319        let agents = trust
320            .get("agents")
321            .and_then(Value::as_object)
322            .cloned()
323            .unwrap_or_default();
324        let self_did = crate::config::read_agent_card()
325            .ok()
326            .and_then(|c| c.get("did").and_then(Value::as_str).map(str::to_string));
327        for (handle, agent) in agents.iter() {
328            let did = agent
329                .get("did")
330                .and_then(Value::as_str)
331                .unwrap_or("")
332                .to_string();
333            if Some(did.as_str()) == self_did.as_deref() {
334                continue;
335            }
336            resources.push(json!({
337                "uri": format!("wire://inbox/{handle}"),
338                "name": format!("inbox from {handle}"),
339                "description": format!("Recent verified events from did:wire:{handle}."),
340                "mimeType": "application/x-ndjson"
341            }));
342        }
343    }
344
345    json!({
346        "jsonrpc": "2.0",
347        "id": id,
348        "result": {
349            "resources": resources
350        }
351    })
352}
353
354fn handle_resources_subscribe(id: &Value, params: &Value, state: &McpState) -> Value {
355    let uri = match params.get("uri").and_then(Value::as_str) {
356        Some(u) => u.to_string(),
357        None => return error_response(id, -32602, "missing 'uri'"),
358    };
359    // Validate the URI shape. Accept wire://inbox/<peer>, wire://inbox/all,
360    // wire://pending-pair/all. Anything else is rejected so we don't pile up
361    // dead subscriptions.
362    let inbox_peer = parse_inbox_uri(&uri);
363    let is_pending = uri == "wire://pending-pair/all";
364    if let Some(ref p) = inbox_peer
365        && p.starts_with("__invalid__")
366        && !is_pending
367    {
368        return error_response(
369            id,
370            -32602,
371            "subscribe URI must be wire://inbox/<peer>, wire://inbox/all, or wire://pending-pair/all",
372        );
373    }
374    if let Ok(mut g) = state.subscribed.lock() {
375        g.insert(uri);
376    }
377    json!({"jsonrpc": "2.0", "id": id, "result": {}})
378}
379
380fn handle_resources_unsubscribe(id: &Value, params: &Value, state: &McpState) -> Value {
381    let uri = match params.get("uri").and_then(Value::as_str) {
382        Some(u) => u.to_string(),
383        None => return error_response(id, -32602, "missing 'uri'"),
384    };
385    if let Ok(mut g) = state.subscribed.lock() {
386        g.remove(&uri);
387    }
388    json!({"jsonrpc": "2.0", "id": id, "result": {}})
389}
390
391fn handle_resources_read(id: &Value, params: &Value) -> Value {
392    let uri = match params.get("uri").and_then(Value::as_str) {
393        Some(u) => u,
394        None => return error_response(id, -32602, "missing 'uri'"),
395    };
396    // pending-pair takes priority over inbox parsing.
397    if uri == "wire://pending-pair/all" {
398        return match crate::pending_pair::list_pending() {
399            Ok(items) => {
400                let body = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string());
401                json!({
402                    "jsonrpc": "2.0",
403                    "id": id,
404                    "result": {
405                        "contents": [{
406                            "uri": uri,
407                            "mimeType": "application/json",
408                            "text": body,
409                        }]
410                    }
411                })
412            }
413            Err(e) => error_response(id, -32603, &e.to_string()),
414        };
415    }
416    let peer_opt = parse_inbox_uri(uri);
417    match read_inbox_resource(peer_opt) {
418        Ok(payload) => json!({
419            "jsonrpc": "2.0",
420            "id": id,
421            "result": {
422                "contents": [{
423                    "uri": uri,
424                    "mimeType": "application/x-ndjson",
425                    "text": payload,
426                }]
427            }
428        }),
429        Err(e) => error_response(id, -32603, &e.to_string()),
430    }
431}
432
433/// Parse `wire://inbox/<peer>` → Some(peer). `wire://inbox/all` → None.
434/// Anything else → returns a marker that triggers "unknown URI" on read.
435fn parse_inbox_uri(uri: &str) -> Option<String> {
436    if let Some(rest) = uri.strip_prefix("wire://inbox/") {
437        if rest == "all" {
438            return None;
439        }
440        if !rest.is_empty() {
441            return Some(rest.to_string());
442        }
443    }
444    Some(format!("__invalid__{uri}"))
445}
446
447fn read_inbox_resource(peer_opt: Option<String>) -> Result<String, String> {
448    const LIMIT: usize = 50;
449    // Validate URI shape FIRST — an invalid URI is an error regardless of
450    // whether the inbox dir exists yet.
451    if let Some(ref p) = peer_opt
452        && p.starts_with("__invalid__")
453    {
454        return Err(
455            "unknown resource URI (must be wire://inbox/<peer> or wire://inbox/all)".into(),
456        );
457    }
458    let inbox = crate::config::inbox_dir().map_err(|e| e.to_string())?;
459    if !inbox.exists() {
460        return Ok(String::new());
461    }
462    let trust = crate::config::read_trust().map_err(|e| e.to_string())?;
463
464    let paths: Vec<std::path::PathBuf> = match peer_opt {
465        Some(p) => {
466            let path = inbox.join(format!("{p}.jsonl"));
467            if !path.exists() {
468                return Ok(String::new());
469            }
470            vec![path]
471        }
472        None => std::fs::read_dir(&inbox)
473            .map_err(|e| e.to_string())?
474            .flatten()
475            .map(|e| e.path())
476            .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
477            .collect(),
478    };
479
480    let mut events: Vec<(String, bool, Value)> = Vec::new();
481    for path in paths {
482        let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
483        let peer = path
484            .file_stem()
485            .and_then(|s| s.to_str())
486            .unwrap_or("")
487            .to_string();
488        for line in body.lines() {
489            let event: Value = match serde_json::from_str(line) {
490                Ok(v) => v,
491                Err(_) => continue,
492            };
493            let verified = crate::signing::verify_message_v31(&event, &trust).is_ok();
494            events.push((peer.clone(), verified, event));
495        }
496    }
497    // Newest last (JSONL append order is chronological); take tail LIMIT.
498    let take_from = events.len().saturating_sub(LIMIT);
499    let tail = &events[take_from..];
500
501    let mut out = String::new();
502    for (_peer, verified, mut event) in tail.iter().cloned() {
503        if let Some(obj) = event.as_object_mut() {
504            obj.insert("verified".into(), json!(verified));
505        }
506        out.push_str(&serde_json::to_string(&event).map_err(|e| e.to_string())?);
507        out.push('\n');
508    }
509    Ok(out)
510}
511
512fn handle_initialize(id: &Value) -> Value {
513    json!({
514        "jsonrpc": "2.0",
515        "id": id,
516        "result": {
517            "protocolVersion": PROTOCOL_VERSION,
518            "capabilities": {
519                "tools": {"listChanged": false},
520                "resources": {
521                    "listChanged": false,
522                    // Goal 2.1 (v0.2.1): subscribe shipped. A background watcher
523                    // thread polls the inbox every 2s and pushes
524                    // notifications/resources/updated via a writer-thread channel
525                    // for any subscribed URI.
526                    "subscribe": true
527                }
528            },
529            "serverInfo": {
530                "name": SERVER_NAME,
531                "version": SERVER_VERSION,
532            },
533            "instructions": "wire — magic-wormhole for AI agents. Agents drive pairing via wire_pair_initiate/join/check; the user types the 6-digit SAS back into chat for wire_pair_confirm — this is the only human-in-loop step. v0.5.14 (zero-paste, bilateral-required): for `nick@domain` handles use wire_add; the peer MUST also run wire_add (or wire_pair_accept) on their side before capability flows. INBOUND pair requests from strangers land in pending-inbound: call wire_pair_list_inbound to enumerate, surface to operator, then wire_pair_accept or wire_pair_reject. Never auto-accept inbound pair requests without operator consent. Resources: 'wire://inbox/<peer>' exposes each pinned peer's verified inbox (JSONL). RECOMMENDED ON SESSION START: arm a persistent stream-watcher on `wire monitor` (or `wire monitor --json`) so peer messages surface mid-session instead of on next manual poll. In Claude Code that's the Monitor tool with persistent:true; in other harnesses background the process. Default filter strips pair_drop/pair_drop_ack/heartbeat noise — one stdout line per real event. See docs/AGENT_INTEGRATION.md for the full monitor recipe and THREAT_MODEL.md (T10/T14)."
534        }
535    })
536}
537
538fn handle_tools_list(id: &Value) -> Value {
539    json!({
540        "jsonrpc": "2.0",
541        "id": id,
542        "result": {
543            "tools": tool_defs(),
544        }
545    })
546}
547
548fn tool_defs() -> Vec<Value> {
549    vec![
550        json!({
551            "name": "wire_whoami",
552            "description": "Return this agent's DID, fingerprint, key_id, public key, and capabilities. Read-only.",
553            "inputSchema": {"type": "object", "properties": {}, "required": []}
554        }),
555        json!({
556            "name": "wire_peers",
557            "description": "List pinned peers with their tier (UNTRUSTED/VERIFIED/ATTESTED) and advertised capabilities. Read-only.",
558            "inputSchema": {"type": "object", "properties": {}, "required": []}
559        }),
560        json!({
561            "name": "wire_send",
562            "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.",
563            "inputSchema": {
564                "type": "object",
565                "properties": {
566                    "peer": {"type": "string", "description": "Peer handle (without did:wire: prefix). Must be a pinned peer; check wire_peers first."},
567                    "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."},
568                    "body": {"type": "string", "description": "Event body. Plain text becomes a JSON string; valid JSON is parsed and embedded structurally."},
569                    "time_sensitive_until": {"type": "string", "description": "Optional advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp."}
570                },
571                "required": ["peer", "kind", "body"]
572            }
573        }),
574        json!({
575            "name": "wire_tail",
576            "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.",
577            "inputSchema": {
578                "type": "object",
579                "properties": {
580                    "peer": {"type": "string", "description": "Optional peer handle to filter inbox by."},
581                    "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Max events to return."}
582                },
583                "required": []
584            }
585        }),
586        json!({
587            "name": "wire_verify",
588            "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).",
589            "inputSchema": {
590                "type": "object",
591                "properties": {
592                    "event": {"type": "string", "description": "JSON-encoded signed event."}
593                },
594                "required": ["event"]
595            }
596        }),
597        json!({
598            "name": "wire_init",
599            "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.",
600            "inputSchema": {
601                "type": "object",
602                "properties": {
603                    "handle": {"type": "string", "description": "Short handle (becomes did:wire:<handle>). ASCII alphanumeric / '-' / '_' only."},
604                    "name": {"type": "string", "description": "Optional display name (defaults to capitalized handle)."},
605                    "relay_url": {"type": "string", "description": "Optional relay URL — if set, also binds a relay slot."}
606                },
607                "required": ["handle"]
608            }
609        }),
610        json!({
611            "name": "wire_pair_initiate",
612            "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).",
613            "inputSchema": {
614                "type": "object",
615                "properties": {
616                    "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
617                    "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
618                    "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."}
619                },
620                "required": []
621            }
622        }),
623        json!({
624            "name": "wire_pair_join",
625            "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.",
626            "inputSchema": {
627                "type": "object",
628                "properties": {
629                    "code_phrase": {"type": "string", "description": "Code phrase from the host (e.g. '73-2QXC4P')."},
630                    "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
631                    "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
632                    "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 30, "description": "How long to block waiting for SPAKE2 exchange to complete."}
633                },
634                "required": ["code_phrase"]
635            }
636        }),
637        json!({
638            "name": "wire_pair_check",
639            "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.",
640            "inputSchema": {
641                "type": "object",
642                "properties": {
643                    "session_id": {"type": "string"},
644                    "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 8}
645                },
646                "required": ["session_id"]
647            }
648        }),
649        json!({
650            "name": "wire_pair_confirm",
651            "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').",
652            "inputSchema": {
653                "type": "object",
654                "properties": {
655                    "session_id": {"type": "string"},
656                    "user_typed_digits": {"type": "string", "description": "The 6 SAS digits the user typed back, e.g. '384217' or '384-217'."}
657                },
658                "required": ["session_id", "user_typed_digits"]
659            }
660        }),
661        json!({
662            "name": "wire_pair_initiate_detached",
663            "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.",
664            "inputSchema": {
665                "type": "object",
666                "properties": {
667                    "handle": {"type": "string", "description": "Optional handle for auto-init (idempotent)."},
668                    "relay_url": {"type": "string"}
669                }
670            }
671        }),
672        json!({
673            "name": "wire_pair_join_detached",
674            "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.",
675            "inputSchema": {
676                "type": "object",
677                "properties": {
678                    "handle": {"type": "string"},
679                    "code_phrase": {"type": "string"},
680                    "relay_url": {"type": "string"}
681                },
682                "required": ["code_phrase"]
683            }
684        }),
685        json!({
686            "name": "wire_pair_list_pending",
687            "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.",
688            "inputSchema": {"type": "object", "properties": {}}
689        }),
690        json!({
691            "name": "wire_pair_confirm_detached",
692            "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.",
693            "inputSchema": {
694                "type": "object",
695                "properties": {
696                    "code_phrase": {"type": "string"},
697                    "user_typed_digits": {"type": "string"}
698                },
699                "required": ["code_phrase", "user_typed_digits"]
700            }
701        }),
702        json!({
703            "name": "wire_pair_cancel_pending",
704            "description": "Cancel a pending detached pair. Releases the relay slot and removes the local pending file. Safe to call regardless of current status (idempotent).",
705            "inputSchema": {
706                "type": "object",
707                "properties": {"code_phrase": {"type": "string"}},
708                "required": ["code_phrase"]
709            }
710        }),
711        json!({
712            "name": "wire_invite_mint",
713            "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}.",
714            "inputSchema": {
715                "type": "object",
716                "properties": {
717                    "relay_url": {"type": "string", "description": "Override relay for first-time auto-allocate."},
718                    "ttl_secs": {"type": "integer", "description": "Invite lifetime in seconds (default 86400)."},
719                    "uses": {"type": "integer", "description": "Number of distinct peers that can accept before consumption (default 1)."}
720                }
721            }
722        }),
723        json!({
724            "name": "wire_invite_accept",
725            "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}.",
726            "inputSchema": {
727                "type": "object",
728                "properties": {
729                    "url": {"type": "string", "description": "Full wire://pair?v=1&inv=... URL."}
730                },
731                "required": ["url"]
732            }
733        }),
734        // v0.5 — agentic hotline.
735        json!({
736            "name": "wire_add",
737            "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.",
738            "inputSchema": {
739                "type": "object",
740                "properties": {
741                    "handle": {"type": "string", "description": "Peer handle like `nick@domain`."},
742                    "relay_url": {"type": "string", "description": "Override resolver URL (default: `https://<domain>`)."}
743                },
744                "required": ["handle"]
745            }
746        }),
747        json!({
748            "name": "wire_pair_accept",
749            "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.",
750            "inputSchema": {
751                "type": "object",
752                "properties": {
753                    "peer": {"type": "string", "description": "Bare peer handle (without `@<relay>`). Match exactly what `wire_pair_list_inbound` returned in `peer_handle`."}
754                },
755                "required": ["peer"]
756            }
757        }),
758        json!({
759            "name": "wire_pair_reject",
760            "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.",
761            "inputSchema": {
762                "type": "object",
763                "properties": {
764                    "peer": {"type": "string", "description": "Bare peer handle (without `@<relay>`)."}
765                },
766                "required": ["peer"]
767            }
768        }),
769        json!({
770            "name": "wire_pair_list_inbound",
771            "description": "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. Each entry is a stranger who has run `wire add` against this agent's handle but hasn't been accepted yet. Use this on session start (or in response to a `wire — pair request from X` OS toast) to surface pending requests to the operator for accept/reject decisions.",
772            "inputSchema": {"type": "object", "properties": {}}
773        }),
774        json!({
775            "name": "wire_claim",
776            "description": "Claim a nick on a relay's handle directory so other agents can reach this agent by `<nick>@<relay-domain>`. Auto-inits + auto-allocates a relay slot if needed. FCFS — same-DID re-claims allowed (used for profile/slot updates).",
777            "inputSchema": {
778                "type": "object",
779                "properties": {
780                    "nick": {"type": "string", "description": "2-32 chars, [a-z0-9_-], not in the reserved set."},
781                    "relay_url": {"type": "string", "description": "Relay to claim on. Default = our relay."},
782                    "public_url": {"type": "string", "description": "Public URL the relay should advertise to resolvers."}
783                },
784                "required": ["nick"]
785            }
786        }),
787        json!({
788            "name": "wire_whois",
789            "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.",
790            "inputSchema": {
791                "type": "object",
792                "properties": {
793                    "handle": {"type": "string", "description": "Optional `nick@domain`. Omit for self."},
794                    "relay_url": {"type": "string", "description": "Override resolver URL."}
795                }
796            }
797        }),
798        json!({
799            "name": "wire_profile_set",
800            "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.",
801            "inputSchema": {
802                "type": "object",
803                "properties": {
804                    "field": {"type": "string", "description": "One of: display_name, emoji, motto, vibe, pronouns, avatar_url, handle, now."},
805                    "value": {"description": "String for most fields; array for vibe; object for now. Pass JSON null to clear a field."}
806                },
807                "required": ["field", "value"]
808            }
809        }),
810        json!({
811            "name": "wire_profile_get",
812            "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.",
813            "inputSchema": {"type": "object", "properties": {}}
814        }),
815    ]
816}
817
818fn handle_tools_call(id: &Value, params: &Value, state: &McpState) -> Value {
819    let name = match params.get("name").and_then(Value::as_str) {
820        Some(n) => n,
821        None => return error_response(id, -32602, "missing tool name"),
822    };
823    let args = params
824        .get("arguments")
825        .cloned()
826        .unwrap_or_else(|| json!({}));
827
828    let result = match name {
829        "wire_whoami" => tool_whoami(),
830        "wire_peers" => tool_peers(),
831        "wire_send" => tool_send(&args),
832        "wire_tail" => tool_tail(&args),
833        "wire_verify" => tool_verify(&args),
834        "wire_init" => tool_init(&args),
835        "wire_pair_initiate" => tool_pair_initiate(&args),
836        "wire_pair_join" => tool_pair_join(&args),
837        "wire_pair_check" => tool_pair_check(&args),
838        "wire_pair_confirm" => tool_pair_confirm(&args, state),
839        "wire_pair_initiate_detached" => tool_pair_initiate_detached(&args),
840        "wire_pair_join_detached" => tool_pair_join_detached(&args),
841        "wire_pair_list_pending" => tool_pair_list_pending(),
842        "wire_pair_confirm_detached" => tool_pair_confirm_detached(&args),
843        "wire_pair_cancel_pending" => tool_pair_cancel_pending(&args),
844        "wire_invite_mint" => tool_invite_mint(&args),
845        "wire_invite_accept" => tool_invite_accept(&args),
846        // v0.5 — agentic hotline (handle + profile + zero-paste discovery).
847        "wire_add" => tool_add(&args),
848        // v0.5.14 — bilateral-required pair: inbound queue management.
849        "wire_pair_accept" => tool_pair_accept(&args),
850        "wire_pair_reject" => tool_pair_reject(&args),
851        "wire_pair_list_inbound" => tool_pair_list_inbound(),
852        "wire_claim" => tool_claim_handle(&args),
853        "wire_whois" => tool_whois(&args),
854        "wire_profile_set" => tool_profile_set(&args),
855        "wire_profile_get" => tool_profile_get(),
856        // Legacy alias kept for older agent prompts that reference `wire_join`.
857        // Surfaces the operator-friendly error pointing to wire_pair_join.
858        "wire_join" => Err(
859            "wire_join was renamed to wire_pair_join (use code_phrase argument). \
860             See docs/AGENT_INTEGRATION.md."
861                .into(),
862        ),
863        other => Err(format!("unknown tool: {other}")),
864    };
865
866    match result {
867        Ok(value) => json!({
868            "jsonrpc": "2.0",
869            "id": id,
870            "result": {
871                "content": [{
872                    "type": "text",
873                    "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
874                }],
875                "isError": false
876            }
877        }),
878        Err(message) => json!({
879            "jsonrpc": "2.0",
880            "id": id,
881            "result": {
882                "content": [{"type": "text", "text": message}],
883                "isError": true
884            }
885        }),
886    }
887}
888
889// ---------- tool implementations ----------
890
891fn tool_whoami() -> Result<Value, String> {
892    use crate::config;
893    use crate::signing::{b64decode, fingerprint, make_key_id};
894
895    if !config::is_initialized().map_err(|e| e.to_string())? {
896        return Err("not initialized — operator must run `wire init <handle>` first".into());
897    }
898    let card = config::read_agent_card().map_err(|e| e.to_string())?;
899    let did = card
900        .get("did")
901        .and_then(Value::as_str)
902        .unwrap_or("")
903        .to_string();
904    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
905    let pk_b64 = card
906        .get("verify_keys")
907        .and_then(Value::as_object)
908        .and_then(|m| m.values().next())
909        .and_then(|v| v.get("key"))
910        .and_then(Value::as_str)
911        .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
912    let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
913    let fp = fingerprint(&pk_bytes);
914    let key_id = make_key_id(&handle, &pk_bytes);
915    let capabilities = card
916        .get("capabilities")
917        .cloned()
918        .unwrap_or_else(|| json!(["wire/v3.1"]));
919    Ok(json!({
920        "did": did,
921        "handle": handle,
922        "fingerprint": fp,
923        "key_id": key_id,
924        "public_key_b64": pk_b64,
925        "capabilities": capabilities,
926    }))
927}
928
929fn tool_peers() -> Result<Value, String> {
930    use crate::config;
931    use crate::trust::get_tier;
932
933    let trust = config::read_trust().map_err(|e| e.to_string())?;
934    let agents = trust
935        .get("agents")
936        .and_then(Value::as_object)
937        .cloned()
938        .unwrap_or_default();
939    let mut self_did: Option<String> = None;
940    if let Ok(card) = config::read_agent_card() {
941        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
942    }
943    let mut peers = Vec::new();
944    for (handle, agent) in agents.iter() {
945        let did = agent
946            .get("did")
947            .and_then(Value::as_str)
948            .unwrap_or("")
949            .to_string();
950        if Some(did.as_str()) == self_did.as_deref() {
951            continue;
952        }
953        peers.push(json!({
954            "handle": handle,
955            "did": did,
956            "tier": get_tier(&trust, handle),
957            "capabilities": agent.get("card").and_then(|c| c.get("capabilities")).cloned().unwrap_or_else(|| json!([])),
958        }));
959    }
960    Ok(json!(peers))
961}
962
963fn tool_send(args: &Value) -> Result<Value, String> {
964    use crate::config;
965    use crate::signing::{b64decode, sign_message_v31};
966
967    let peer = args
968        .get("peer")
969        .and_then(Value::as_str)
970        .ok_or("missing 'peer'")?;
971    let peer = crate::agent_card::bare_handle(peer);
972    let kind = args
973        .get("kind")
974        .and_then(Value::as_str)
975        .ok_or("missing 'kind'")?;
976    let body = args
977        .get("body")
978        .and_then(Value::as_str)
979        .ok_or("missing 'body'")?;
980    let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
981
982    if !config::is_initialized().map_err(|e| e.to_string())? {
983        return Err("not initialized — operator must run `wire init <handle>` first".into());
984    }
985    let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
986    let card = config::read_agent_card().map_err(|e| e.to_string())?;
987    let did = card
988        .get("did")
989        .and_then(Value::as_str)
990        .unwrap_or("")
991        .to_string();
992    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
993    let pk_b64 = card
994        .get("verify_keys")
995        .and_then(Value::as_object)
996        .and_then(|m| m.values().next())
997        .and_then(|v| v.get("key"))
998        .and_then(Value::as_str)
999        .ok_or("agent-card missing verify_keys[*].key")?;
1000    let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1001
1002    // Body parses as JSON if possible, else stays a string.
1003    let body_value: Value =
1004        serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1005    let kind_id = parse_kind(kind);
1006
1007    let now = time::OffsetDateTime::now_utc()
1008        .format(&time::format_description::well_known::Rfc3339)
1009        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1010
1011    let mut event = json!({
1012        "timestamp": now,
1013        "from": did,
1014        "to": format!("did:wire:{peer}"),
1015        "type": kind,
1016        "kind": kind_id,
1017        "body": body_value,
1018    });
1019    if let Some(deadline) = deadline {
1020        event["time_sensitive_until"] =
1021            json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1022    }
1023    let signed =
1024        sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1025    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1026
1027    let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1028    let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1029
1030    Ok(json!({
1031        "event_id": event_id,
1032        "status": "queued",
1033        "peer": peer,
1034        "outbox": outbox.to_string_lossy(),
1035    }))
1036}
1037
1038fn tool_tail(args: &Value) -> Result<Value, String> {
1039    use crate::config;
1040    use crate::signing::verify_message_v31;
1041
1042    let peer_filter = args.get("peer").and_then(Value::as_str);
1043    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1044    let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1045    if !inbox.exists() {
1046        return Ok(json!([]));
1047    }
1048    let trust = config::read_trust().map_err(|e| e.to_string())?;
1049    let mut events = Vec::new();
1050    let entries: Vec<_> = std::fs::read_dir(&inbox)
1051        .map_err(|e| e.to_string())?
1052        .filter_map(|e| e.ok())
1053        .map(|e| e.path())
1054        .filter(|p| {
1055            p.extension().map(|x| x == "jsonl").unwrap_or(false)
1056                && match peer_filter {
1057                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1058                    None => true,
1059                }
1060        })
1061        .collect();
1062    for path in entries {
1063        let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
1064        for line in body.lines() {
1065            let event: Value = match serde_json::from_str(line) {
1066                Ok(v) => v,
1067                Err(_) => continue,
1068            };
1069            let verified = verify_message_v31(&event, &trust).is_ok();
1070            let mut event_with_meta = event.clone();
1071            if let Some(obj) = event_with_meta.as_object_mut() {
1072                obj.insert("verified".into(), json!(verified));
1073            }
1074            events.push(event_with_meta);
1075            if events.len() >= limit {
1076                return Ok(Value::Array(events));
1077            }
1078        }
1079    }
1080    Ok(Value::Array(events))
1081}
1082
1083fn tool_verify(args: &Value) -> Result<Value, String> {
1084    use crate::config;
1085    use crate::signing::verify_message_v31;
1086
1087    let event_str = args
1088        .get("event")
1089        .and_then(Value::as_str)
1090        .ok_or("missing 'event'")?;
1091    let event: Value =
1092        serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1093    let trust = config::read_trust().map_err(|e| e.to_string())?;
1094    match verify_message_v31(&event, &trust) {
1095        Ok(()) => Ok(json!({"verified": true})),
1096        Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1097    }
1098}
1099
1100// ---------- pairing tools ----------
1101
1102fn tool_init(args: &Value) -> Result<Value, String> {
1103    let handle = args
1104        .get("handle")
1105        .and_then(Value::as_str)
1106        .ok_or("missing 'handle'")?;
1107    let name = args.get("name").and_then(Value::as_str);
1108    let relay = args.get("relay_url").and_then(Value::as_str);
1109    crate::pair_session::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1110}
1111
1112/// Resolve the relay URL: explicit arg wins, else the relay this agent's
1113/// identity is already bound to (from `wire init --relay` or a previous
1114/// pair_initiate). Errors if neither is set.
1115fn resolve_relay_url(args: &Value) -> Result<String, String> {
1116    if let Some(url) = args.get("relay_url").and_then(Value::as_str) {
1117        return Ok(url.to_string());
1118    }
1119    let state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1120    state["self"]["relay_url"]
1121        .as_str()
1122        .map(str::to_string)
1123        .ok_or_else(|| "no relay_url provided and no relay bound (call wire_init with relay_url, or pass relay_url here)".into())
1124}
1125
1126/// If `handle` is provided and identity isn't yet initialized, call
1127/// `init_self_idempotent` so a single MCP call can do both. If handle is
1128/// missing and not initialized, surface a clear error pointing the agent at
1129/// wire_init. If already initialized under a different handle, the
1130/// idempotent init errors clearly (same as direct wire_init).
1131fn auto_init_if_needed(args: &Value) -> Result<(), String> {
1132    let initialized = crate::config::is_initialized().map_err(|e| e.to_string())?;
1133    if initialized {
1134        return Ok(());
1135    }
1136    let handle = args.get("handle").and_then(Value::as_str).ok_or(
1137        "not initialized — pass `handle` to auto-init, or call wire_init explicitly first",
1138    )?;
1139    let relay = args.get("relay_url").and_then(Value::as_str);
1140    crate::pair_session::init_self_idempotent(handle, None, relay)
1141        .map(|_| ())
1142        .map_err(|e| e.to_string())
1143}
1144
1145fn tool_pair_initiate(args: &Value) -> Result<Value, String> {
1146    use crate::pair_session::{
1147        pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1148    };
1149
1150    store_sweep_expired();
1151    // Auto-init if `handle` arg provided and not yet inited (idempotent).
1152    auto_init_if_needed(args)?;
1153
1154    let relay_url = resolve_relay_url(args)?;
1155    let max_wait = args
1156        .get("max_wait_secs")
1157        .and_then(Value::as_u64)
1158        .unwrap_or(30)
1159        .min(60);
1160
1161    let mut s = pair_session_open("host", &relay_url, None).map_err(|e| e.to_string())?;
1162    let code = s.code.clone();
1163
1164    let sas_opt = if max_wait > 0 {
1165        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1166            .map_err(|e| e.to_string())?
1167    } else {
1168        None
1169    };
1170
1171    let session_id = store_insert(s);
1172
1173    let mut out = json!({
1174        "session_id": session_id,
1175        "code_phrase": code,
1176        "relay_url": relay_url,
1177    });
1178    match sas_opt {
1179        Some(sas) => {
1180            out["state"] = json!("sas_ready");
1181            out["sas"] = json!(sas);
1182            out["next"] = json!(
1183                "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel (voice/text). \
1184                 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1185            );
1186        }
1187        None => {
1188            out["state"] = json!("waiting");
1189            out["next"] = json!(
1190                "Share the code_phrase with the user; ask them to read it to their peer (the peer pastes into wire_pair_join). \
1191                 Poll wire_pair_check(session_id) until state='sas_ready'."
1192            );
1193        }
1194    }
1195    Ok(out)
1196}
1197
1198fn tool_pair_join(args: &Value) -> Result<Value, String> {
1199    use crate::pair_session::{
1200        pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1201    };
1202
1203    store_sweep_expired();
1204    auto_init_if_needed(args)?;
1205
1206    let code = args
1207        .get("code_phrase")
1208        .and_then(Value::as_str)
1209        .ok_or("missing 'code_phrase'")?;
1210    let relay_url = resolve_relay_url(args)?;
1211    let max_wait = args
1212        .get("max_wait_secs")
1213        .and_then(Value::as_u64)
1214        .unwrap_or(30)
1215        .min(60);
1216
1217    let mut s = pair_session_open("guest", &relay_url, Some(code)).map_err(|e| e.to_string())?;
1218
1219    let sas_opt =
1220        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1221            .map_err(|e| e.to_string())?;
1222
1223    let session_id = store_insert(s);
1224
1225    let mut out = json!({
1226        "session_id": session_id,
1227        "relay_url": relay_url,
1228    });
1229    match sas_opt {
1230        Some(sas) => {
1231            out["state"] = json!("sas_ready");
1232            out["sas"] = json!(sas);
1233            out["next"] = json!(
1234                "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel. \
1235                 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1236            );
1237        }
1238        None => {
1239            out["state"] = json!("waiting");
1240            out["next"] = json!("Poll wire_pair_check(session_id).");
1241        }
1242    }
1243    Ok(out)
1244}
1245
1246fn tool_pair_check(args: &Value) -> Result<Value, String> {
1247    use crate::pair_session::{pair_session_wait_for_sas, store_get, store_sweep_expired};
1248
1249    store_sweep_expired();
1250    let session_id = args
1251        .get("session_id")
1252        .and_then(Value::as_str)
1253        .ok_or("missing 'session_id'")?;
1254    let max_wait = args
1255        .get("max_wait_secs")
1256        .and_then(Value::as_u64)
1257        .unwrap_or(8)
1258        .min(60);
1259
1260    let arc = store_get(session_id)
1261        .ok_or_else(|| format!("no such session_id (expired or never opened): {session_id}"))?;
1262    let mut s = arc.lock().map_err(|e| e.to_string())?;
1263
1264    if s.finalized {
1265        return Ok(json!({
1266            "state": "finalized",
1267            "session_id": session_id,
1268            "sas": s.formatted_sas(),
1269        }));
1270    }
1271    if let Some(reason) = s.aborted.clone() {
1272        return Ok(json!({
1273            "state": "aborted",
1274            "session_id": session_id,
1275            "reason": reason,
1276        }));
1277    }
1278
1279    let sas_opt =
1280        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1281            .map_err(|e| e.to_string())?;
1282
1283    Ok(match sas_opt {
1284        Some(sas) => json!({
1285            "state": "sas_ready",
1286            "session_id": session_id,
1287            "sas": sas,
1288            "next": "Have the user TYPE the 6 SAS digits BACK INTO CHAT, then pass to wire_pair_confirm."
1289        }),
1290        None => json!({
1291            "state": "waiting",
1292            "session_id": session_id,
1293        }),
1294    })
1295}
1296
1297fn tool_pair_confirm(args: &Value, state: &McpState) -> Result<Value, String> {
1298    use crate::pair_session::{
1299        pair_session_confirm_sas, pair_session_finalize, store_get, store_remove,
1300    };
1301
1302    let session_id = args
1303        .get("session_id")
1304        .and_then(Value::as_str)
1305        .ok_or("missing 'session_id'")?;
1306    let typed = args
1307        .get("user_typed_digits")
1308        .and_then(Value::as_str)
1309        .ok_or(
1310            "missing 'user_typed_digits' — the user must type the 6 SAS digits back into chat",
1311        )?;
1312
1313    let arc = store_get(session_id).ok_or_else(|| format!("no such session_id: {session_id}"))?;
1314
1315    let confirm_err = {
1316        let mut s = arc.lock().map_err(|e| e.to_string())?;
1317        match pair_session_confirm_sas(&mut s, typed) {
1318            Ok(()) => None,
1319            Err(e) => Some((s.aborted.is_some(), e.to_string())),
1320        }
1321    };
1322    if let Some((aborted, msg)) = confirm_err {
1323        if aborted {
1324            store_remove(session_id);
1325        }
1326        return Err(msg);
1327    }
1328
1329    let mut result = {
1330        let mut s = arc.lock().map_err(|e| e.to_string())?;
1331        pair_session_finalize(&mut s, 30).map_err(|e| e.to_string())?
1332    };
1333    store_remove(session_id);
1334
1335    // ---- Post-pair auto-setup (Goal: zero friction after SAS) ----
1336    // 1. Auto-subscribe to wire://inbox/<peer> so clients that support
1337    //    resources/subscribe get push notifications/resources/updated.
1338    // 2. Spawn `wire daemon` if not already running so push/pull is automatic.
1339    // 3. Spawn `wire notify` if not already running so OS toasts fire on
1340    //    inbox grow (covers MCP hosts that lack resources/subscribe).
1341    // 4. Emit notifications/resources/list_changed via the writer channel so
1342    //    a client that called resources/list before pairing refreshes its view.
1343    let peer_handle = result["peer_handle"].as_str().unwrap_or("").to_string();
1344    let peer_uri = format!("wire://inbox/{peer_handle}");
1345
1346    let mut auto = json!({
1347        "subscribed": false,
1348        "daemon": "unknown",
1349        "notify": "unknown",
1350        "resources_list_changed_emitted": false,
1351    });
1352
1353    if !peer_handle.is_empty()
1354        && let Ok(mut g) = state.subscribed.lock()
1355    {
1356        g.insert(peer_uri.clone());
1357        auto["subscribed"] = json!(true);
1358    }
1359
1360    auto["daemon"] = match crate::ensure_up::ensure_daemon_running() {
1361        Ok(true) => json!("spawned"),
1362        Ok(false) => json!("already_running"),
1363        Err(e) => json!(format!("spawn_error: {e}")),
1364    };
1365    auto["notify"] = match crate::ensure_up::ensure_notify_running() {
1366        Ok(true) => json!("spawned"),
1367        Ok(false) => json!("already_running"),
1368        Err(e) => json!(format!("spawn_error: {e}")),
1369    };
1370
1371    if let Some(tx) = state.notif_tx.lock().ok().and_then(|g| g.clone()) {
1372        let notif = json!({
1373            "jsonrpc": "2.0",
1374            "method": "notifications/resources/list_changed",
1375        });
1376        if tx.send(notif.to_string()).is_ok() {
1377            auto["resources_list_changed_emitted"] = json!(true);
1378        }
1379    }
1380
1381    result["auto"] = auto;
1382    result["next"] = json!(
1383        "Done. Daemon + notify running, subscribed to peer inbox. Use wire_send/wire_tail \
1384         freely; new events arrive via notifications/resources/updated (where supported) and \
1385         OS toasts (always)."
1386    );
1387    Ok(result)
1388}
1389
1390// ---------- detached pair tools (daemon-orchestrated) ----------
1391
1392fn tool_pair_initiate_detached(args: &Value) -> Result<Value, String> {
1393    auto_init_if_needed(args)?;
1394    let relay_url = resolve_relay_url(args)?;
1395    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1396        let _ = crate::ensure_up::ensure_daemon_running();
1397    }
1398    let code = crate::sas::generate_code_phrase();
1399    let code_hash = crate::pair_session::derive_code_hash(&code);
1400    let now = time::OffsetDateTime::now_utc()
1401        .format(&time::format_description::well_known::Rfc3339)
1402        .unwrap_or_default();
1403    let p = crate::pending_pair::PendingPair {
1404        code: code.clone(),
1405        code_hash,
1406        role: "host".to_string(),
1407        relay_url: relay_url.clone(),
1408        status: "request_host".to_string(),
1409        sas: None,
1410        peer_did: None,
1411        created_at: now,
1412        last_error: None,
1413        pair_id: None,
1414        our_slot_id: None,
1415        our_slot_token: None,
1416        spake2_seed_b64: None,
1417    };
1418    crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1419    Ok(json!({
1420        "code_phrase": code,
1421        "relay_url": relay_url,
1422        "state": "queued",
1423        "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."
1424    }))
1425}
1426
1427fn tool_pair_join_detached(args: &Value) -> Result<Value, String> {
1428    auto_init_if_needed(args)?;
1429    let relay_url = resolve_relay_url(args)?;
1430    let code_phrase = args
1431        .get("code_phrase")
1432        .and_then(Value::as_str)
1433        .ok_or("missing 'code_phrase'")?;
1434    let code = crate::sas::parse_code_phrase(code_phrase)
1435        .map_err(|e| e.to_string())?
1436        .to_string();
1437    let code_hash = crate::pair_session::derive_code_hash(&code);
1438    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1439        let _ = crate::ensure_up::ensure_daemon_running();
1440    }
1441    let now = time::OffsetDateTime::now_utc()
1442        .format(&time::format_description::well_known::Rfc3339)
1443        .unwrap_or_default();
1444    let p = crate::pending_pair::PendingPair {
1445        code: code.clone(),
1446        code_hash,
1447        role: "guest".to_string(),
1448        relay_url: relay_url.clone(),
1449        status: "request_guest".to_string(),
1450        sas: None,
1451        peer_did: None,
1452        created_at: now,
1453        last_error: None,
1454        pair_id: None,
1455        our_slot_id: None,
1456        our_slot_token: None,
1457        spake2_seed_b64: None,
1458    };
1459    crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1460    Ok(json!({
1461        "code_phrase": code,
1462        "relay_url": relay_url,
1463        "state": "queued",
1464        "next": "Subscribe to wire://pending-pair/all; on sas_ready notification, surface digits to user and call wire_pair_confirm_detached."
1465    }))
1466}
1467
1468fn tool_pair_list_pending() -> Result<Value, String> {
1469    let items = crate::pending_pair::list_pending().map_err(|e| e.to_string())?;
1470    Ok(json!({"pending": items}))
1471}
1472
1473fn tool_pair_confirm_detached(args: &Value) -> Result<Value, String> {
1474    let code_phrase = args
1475        .get("code_phrase")
1476        .and_then(Value::as_str)
1477        .ok_or("missing 'code_phrase'")?;
1478    let typed = args
1479        .get("user_typed_digits")
1480        .and_then(Value::as_str)
1481        .ok_or("missing 'user_typed_digits'")?;
1482    let code = crate::sas::parse_code_phrase(code_phrase)
1483        .map_err(|e| e.to_string())?
1484        .to_string();
1485    let typed: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
1486    if typed.len() != 6 {
1487        return Err(format!(
1488            "expected 6 digits (got {} after stripping non-digits)",
1489            typed.len()
1490        ));
1491    }
1492    let mut p = crate::pending_pair::read_pending(&code)
1493        .map_err(|e| e.to_string())?
1494        .ok_or_else(|| format!("no pending pair for code {code}"))?;
1495    if p.status != "sas_ready" {
1496        return Err(format!(
1497            "pair {code} not in sas_ready state (current: {})",
1498            p.status
1499        ));
1500    }
1501    let stored = p
1502        .sas
1503        .as_ref()
1504        .ok_or("pending file has status=sas_ready but no sas field")?
1505        .clone();
1506    if stored == typed {
1507        p.status = "confirmed".to_string();
1508        crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1509        Ok(json!({
1510            "state": "confirmed",
1511            "code_phrase": code,
1512            "next": "Daemon will finalize on its next tick (~1s). Poll wire_peers or watch wire://pending-pair/all for the entry to disappear."
1513        }))
1514    } else {
1515        p.status = "aborted".to_string();
1516        p.last_error = Some(format!(
1517            "SAS digit mismatch (typed {typed}, expected {stored})"
1518        ));
1519        let client = crate::relay_client::RelayClient::new(&p.relay_url);
1520        let _ = client.pair_abandon(&p.code_hash);
1521        let _ = crate::pending_pair::write_pending(&p);
1522        crate::os_notify::toast(
1523            &format!("wire — pair aborted ({code})"),
1524            p.last_error.as_deref().unwrap_or("digits mismatch"),
1525        );
1526        Err(
1527            "digits mismatch — pair aborted. Re-issue with wire_pair_initiate_detached."
1528                .to_string(),
1529        )
1530    }
1531}
1532
1533fn tool_pair_cancel_pending(args: &Value) -> Result<Value, String> {
1534    let code_phrase = args
1535        .get("code_phrase")
1536        .and_then(Value::as_str)
1537        .ok_or("missing 'code_phrase'")?;
1538    let code = crate::sas::parse_code_phrase(code_phrase)
1539        .map_err(|e| e.to_string())?
1540        .to_string();
1541    if let Some(p) = crate::pending_pair::read_pending(&code).map_err(|e| e.to_string())? {
1542        let client = crate::relay_client::RelayClient::new(&p.relay_url);
1543        let _ = client.pair_abandon(&p.code_hash);
1544    }
1545    crate::pending_pair::delete_pending(&code).map_err(|e| e.to_string())?;
1546    Ok(json!({"state": "cancelled", "code_phrase": code}))
1547}
1548
1549// ---------- invite-URL one-paste pair (v0.4.0) ----------
1550
1551fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1552    let relay_url = args.get("relay_url").and_then(Value::as_str);
1553    let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1554    let uses = args
1555        .get("uses")
1556        .and_then(Value::as_u64)
1557        .map(|u| u as u32)
1558        .unwrap_or(1);
1559    let url =
1560        crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1561    let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1562    Ok(json!({
1563        "invite_url": url,
1564        "ttl_secs": ttl_resolved,
1565        "uses": uses,
1566    }))
1567}
1568
1569fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1570    let url = args
1571        .get("url")
1572        .and_then(Value::as_str)
1573        .ok_or("missing 'url'")?;
1574    crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1575}
1576
1577// ---------- v0.5 — agentic hotline tools ----------
1578
1579fn tool_add(args: &Value) -> Result<Value, String> {
1580    let handle = args
1581        .get("handle")
1582        .and_then(Value::as_str)
1583        .ok_or("missing 'handle'")?;
1584    let relay_override = args.get("relay_url").and_then(Value::as_str);
1585
1586    let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1587
1588    // Ensure self has identity + relay slot (auto-inits if needed).
1589    let (our_did, our_relay, our_slot_id, our_slot_token) =
1590        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1591
1592    // Resolve peer via .well-known.
1593    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1594        .map_err(|e| format!("{e:#}"))?;
1595    let peer_card = resolved
1596        .get("card")
1597        .cloned()
1598        .ok_or("resolved missing card")?;
1599    let peer_did = resolved
1600        .get("did")
1601        .and_then(Value::as_str)
1602        .ok_or("resolved missing did")?
1603        .to_string();
1604    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1605    let peer_slot_id = resolved
1606        .get("slot_id")
1607        .and_then(Value::as_str)
1608        .ok_or("resolved missing slot_id")?
1609        .to_string();
1610    let peer_relay = resolved
1611        .get("relay_url")
1612        .and_then(Value::as_str)
1613        .map(str::to_string)
1614        .or_else(|| relay_override.map(str::to_string))
1615        .unwrap_or_else(|| format!("https://{}", parsed.domain));
1616
1617    // Pin peer in trust + relay-state. slot_token arrives via ack later.
1618    let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1619    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1620    crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1621    let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1622    let existing_token = relay_state
1623        .get("peers")
1624        .and_then(|p| p.get(&peer_handle))
1625        .and_then(|p| p.get("slot_token"))
1626        .and_then(Value::as_str)
1627        .map(str::to_string)
1628        .unwrap_or_default();
1629    relay_state["peers"][&peer_handle] = json!({
1630        "relay_url": peer_relay,
1631        "slot_id": peer_slot_id,
1632        "slot_token": existing_token,
1633    });
1634    crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1635
1636    // Build + sign pair_drop event (no nonce — open-mode handle pair).
1637    let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1638    let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1639    let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1640    let pk_b64 = our_card
1641        .get("verify_keys")
1642        .and_then(Value::as_object)
1643        .and_then(|m| m.values().next())
1644        .and_then(|v| v.get("key"))
1645        .and_then(Value::as_str)
1646        .ok_or("our card missing verify_keys[*].key")?;
1647    let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1648    let now = time::OffsetDateTime::now_utc()
1649        .format(&time::format_description::well_known::Rfc3339)
1650        .unwrap_or_default();
1651    let event = json!({
1652        "timestamp": now,
1653        "from": our_did,
1654        "to": peer_did,
1655        "type": "pair_drop",
1656        "kind": 1100u32,
1657        "body": {
1658            "card": our_card,
1659            "relay_url": our_relay,
1660            "slot_id": our_slot_id,
1661            "slot_token": our_slot_token,
1662        },
1663    });
1664    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
1665        .map_err(|e| format!("{e:#}"))?;
1666
1667    let client = crate::relay_client::RelayClient::new(&peer_relay);
1668    let resp = client
1669        .handle_intro(&parsed.nick, &signed)
1670        .map_err(|e| format!("{e:#}"))?;
1671    let event_id = signed
1672        .get("event_id")
1673        .and_then(Value::as_str)
1674        .unwrap_or("")
1675        .to_string();
1676    Ok(json!({
1677        "handle": handle,
1678        "paired_with": peer_did,
1679        "peer_handle": peer_handle,
1680        "event_id": event_id,
1681        "drop_response": resp,
1682        "status": "drop_sent",
1683    }))
1684}
1685
1686/// v0.5.14: MCP `wire_pair_accept` — bilateral completion of a
1687/// pending-inbound pair request. The agent SHOULD have surfaced the
1688/// pending request to the operator before calling this; acceptance
1689/// grants peer authenticated write access to this agent's inbox.
1690fn tool_pair_accept(args: &Value) -> Result<Value, String> {
1691    let peer = args
1692        .get("peer")
1693        .and_then(Value::as_str)
1694        .ok_or("missing 'peer'")?;
1695    let nick = crate::agent_card::bare_handle(peer);
1696    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
1697        .map_err(|e| format!("{e:#}"))?
1698        .ok_or_else(|| {
1699            format!(
1700                "no pending pair request from {nick}. Call wire_pair_list_inbound to enumerate, \
1701                 or wire_add to send a fresh outbound pair request."
1702            )
1703        })?;
1704
1705    // Pin trust with VERIFIED — operator-equivalent consent gesture (the
1706    // agent is acting on the operator's instruction to accept).
1707    let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1708    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
1709    crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1710
1711    // Record peer's relay coords + slot_token from the stored drop.
1712    let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1713    relay_state["peers"][&pending.peer_handle] = json!({
1714        "relay_url": pending.peer_relay_url,
1715        "slot_id": pending.peer_slot_id,
1716        "slot_token": pending.peer_slot_token,
1717    });
1718    crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1719
1720    // Ship our slot_token via pair_drop_ack.
1721    crate::pair_invite::send_pair_drop_ack(
1722        &pending.peer_handle,
1723        &pending.peer_relay_url,
1724        &pending.peer_slot_id,
1725        &pending.peer_slot_token,
1726    )
1727    .map_err(|e| {
1728        format!(
1729            "pair_drop_ack send to {} @ {} slot {} failed: {e:#}",
1730            pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
1731        )
1732    })?;
1733
1734    crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1735
1736    Ok(json!({
1737        "status": "bilateral_accepted",
1738        "peer_handle": pending.peer_handle,
1739        "peer_did": pending.peer_did,
1740        "peer_relay_url": pending.peer_relay_url,
1741        "via": "pending_inbound",
1742    }))
1743}
1744
1745/// v0.5.14: MCP `wire_pair_reject` — delete a pending-inbound record
1746/// without pairing. Peer never receives our slot_token. Idempotent.
1747fn tool_pair_reject(args: &Value) -> Result<Value, String> {
1748    let peer = args
1749        .get("peer")
1750        .and_then(Value::as_str)
1751        .ok_or("missing 'peer'")?;
1752    let nick = crate::agent_card::bare_handle(peer);
1753    let existed =
1754        crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1755    crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1756    Ok(json!({
1757        "peer": nick,
1758        "rejected": existed.is_some(),
1759        "had_pending": existed.is_some(),
1760    }))
1761}
1762
1763/// v0.5.14: MCP `wire_pair_list_inbound` — enumerate pending-inbound
1764/// pair requests for operator review. Flat array sorted oldest-first.
1765fn tool_pair_list_inbound() -> Result<Value, String> {
1766    let items =
1767        crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
1768    Ok(json!(items))
1769}
1770
1771fn tool_claim_handle(args: &Value) -> Result<Value, String> {
1772    let nick = args
1773        .get("nick")
1774        .and_then(Value::as_str)
1775        .ok_or("missing 'nick'")?;
1776    let relay_override = args.get("relay_url").and_then(Value::as_str);
1777    let public_url = args.get("public_url").and_then(Value::as_str);
1778
1779    // Auto-init + ensure slot.
1780    let (_, our_relay, our_slot_id, our_slot_token) =
1781        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1782    let claim_relay = relay_override.unwrap_or(&our_relay);
1783    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1784    let client = crate::relay_client::RelayClient::new(claim_relay);
1785    let resp = client
1786        .handle_claim(nick, &our_slot_id, &our_slot_token, public_url, &card)
1787        .map_err(|e| format!("{e:#}"))?;
1788    Ok(json!({
1789        "nick": nick,
1790        "relay": claim_relay,
1791        "response": resp,
1792    }))
1793}
1794
1795fn tool_whois(args: &Value) -> Result<Value, String> {
1796    if let Some(handle) = args.get("handle").and_then(Value::as_str) {
1797        let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1798        let relay_override = args.get("relay_url").and_then(Value::as_str);
1799        crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
1800    } else {
1801        // Self.
1802        let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1803        Ok(json!({
1804            "did": card.get("did").cloned().unwrap_or(Value::Null),
1805            "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1806        }))
1807    }
1808}
1809
1810fn tool_profile_set(args: &Value) -> Result<Value, String> {
1811    let field = args
1812        .get("field")
1813        .and_then(Value::as_str)
1814        .ok_or("missing 'field'")?;
1815    let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
1816    // If value is a string that itself parses as JSON (e.g. "[\"rust\"]"),
1817    // unwrap it. Otherwise pass as-is. Lets agents send either typed values
1818    // or stringified JSON.
1819    let value = if let Some(s) = raw_value.as_str() {
1820        serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
1821    } else {
1822        raw_value
1823    };
1824    let new_profile =
1825        crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
1826    Ok(json!({
1827        "field": field,
1828        "profile": new_profile,
1829    }))
1830}
1831
1832fn tool_profile_get() -> Result<Value, String> {
1833    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1834    Ok(json!({
1835        "did": card.get("did").cloned().unwrap_or(Value::Null),
1836        "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1837    }))
1838}
1839
1840// ---------- helpers ----------
1841
1842fn parse_kind(s: &str) -> u32 {
1843    if let Ok(n) = s.parse::<u32>() {
1844        return n;
1845    }
1846    for (id, name) in crate::signing::kinds() {
1847        if *name == s {
1848            return *id;
1849        }
1850    }
1851    1
1852}
1853
1854fn error_response(id: &Value, code: i32, message: &str) -> Value {
1855    json!({
1856        "jsonrpc": "2.0",
1857        "id": id,
1858        "error": {"code": code, "message": message}
1859    })
1860}
1861
1862#[cfg(test)]
1863mod tests {
1864    use super::*;
1865
1866    #[test]
1867    fn unknown_method_returns_jsonrpc_error() {
1868        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
1869        let resp = handle_request(&req, &McpState::default());
1870        assert_eq!(resp["error"]["code"], -32601);
1871    }
1872
1873    #[test]
1874    fn initialize_advertises_tools_capability() {
1875        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
1876        let resp = handle_request(&req, &McpState::default());
1877        assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
1878        assert!(resp["result"]["capabilities"]["tools"].is_object());
1879        assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
1880    }
1881
1882    #[test]
1883    fn tools_list_includes_pairing_and_messaging() {
1884        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
1885        let resp = handle_request(&req, &McpState::default());
1886        let names: Vec<&str> = resp["result"]["tools"]
1887            .as_array()
1888            .unwrap()
1889            .iter()
1890            .filter_map(|t| t["name"].as_str())
1891            .collect();
1892        for required in [
1893            "wire_whoami",
1894            "wire_peers",
1895            "wire_send",
1896            "wire_tail",
1897            "wire_verify",
1898            "wire_init",
1899            "wire_pair_initiate",
1900            "wire_pair_join",
1901            "wire_pair_check",
1902            "wire_pair_confirm",
1903        ] {
1904            assert!(
1905                names.contains(&required),
1906                "missing required tool {required}"
1907            );
1908        }
1909        // wire_join (the old direct alias for pair-join, no SAS-typeback) is
1910        // explicitly NOT in the catalog. Calling it returns a deprecation
1911        // pointing to wire_pair_join (test below covers this).
1912        assert!(
1913            !names.contains(&"wire_join"),
1914            "wire_join must not be advertised — superseded by wire_pair_join"
1915        );
1916    }
1917
1918    #[test]
1919    fn legacy_wire_join_call_returns_helpful_error() {
1920        let req = json!({
1921            "jsonrpc": "2.0",
1922            "id": 1,
1923            "method": "tools/call",
1924            "params": {"name": "wire_join", "arguments": {}}
1925        });
1926        let resp = handle_request(&req, &McpState::default());
1927        assert_eq!(resp["result"]["isError"], true);
1928        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
1929        assert!(
1930            text.contains("wire_pair_join"),
1931            "expected redirect to wire_pair_join, got: {text}"
1932        );
1933    }
1934
1935    #[test]
1936    fn pair_confirm_missing_session_id_errors_cleanly() {
1937        let req = json!({
1938            "jsonrpc": "2.0",
1939            "id": 1,
1940            "method": "tools/call",
1941            "params": {"name": "wire_pair_confirm", "arguments": {"user_typed_digits": "111111"}}
1942        });
1943        let resp = handle_request(&req, &McpState::default());
1944        assert_eq!(resp["result"]["isError"], true);
1945    }
1946
1947    #[test]
1948    fn pair_confirm_unknown_session_errors_cleanly() {
1949        let req = json!({
1950            "jsonrpc": "2.0",
1951            "id": 1,
1952            "method": "tools/call",
1953            "params": {
1954                "name": "wire_pair_confirm",
1955                "arguments": {"session_id": "definitely-not-real", "user_typed_digits": "111111"}
1956            }
1957        });
1958        let resp = handle_request(&req, &McpState::default());
1959        assert_eq!(resp["result"]["isError"], true);
1960        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
1961        assert!(text.contains("no such session_id"), "got: {text}");
1962    }
1963
1964    #[test]
1965    fn initialize_advertises_resources_capability() {
1966        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
1967        let resp = handle_request(&req, &McpState::default());
1968        let caps = &resp["result"]["capabilities"];
1969        assert!(
1970            caps["resources"].is_object(),
1971            "resources capability must be present, got {resp}"
1972        );
1973        assert_eq!(
1974            caps["resources"]["subscribe"], true,
1975            "subscribe shipped in v0.2.1"
1976        );
1977    }
1978
1979    #[test]
1980    fn resources_read_with_bad_uri_errors() {
1981        let req = json!({
1982            "jsonrpc": "2.0",
1983            "id": 1,
1984            "method": "resources/read",
1985            "params": {"uri": "http://example.com/not-a-wire-uri"}
1986        });
1987        let resp = handle_request(&req, &McpState::default());
1988        assert!(resp.get("error").is_some(), "expected error, got {resp}");
1989    }
1990
1991    #[test]
1992    fn parse_inbox_uri_handles_variants() {
1993        assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
1994        assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
1995        assert!(
1996            parse_inbox_uri("wire://inbox/")
1997                .unwrap()
1998                .starts_with("__invalid__"),
1999            "empty peer must be invalid"
2000        );
2001        assert!(
2002            parse_inbox_uri("http://other")
2003                .unwrap()
2004                .starts_with("__invalid__"),
2005            "non-wire scheme must be invalid"
2006        );
2007    }
2008
2009    #[test]
2010    fn ping_returns_empty_result() {
2011        let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2012        let resp = handle_request(&req, &McpState::default());
2013        assert_eq!(resp["id"], 7);
2014        assert!(resp["result"].is_object());
2015    }
2016
2017    #[test]
2018    fn notification_returns_null_no_reply() {
2019        let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2020        let resp = handle_request(&req, &McpState::default());
2021        assert_eq!(resp, Value::Null);
2022    }
2023
2024    /// v0.6.1 regression: `detect_session_wire_home` must return the
2025    /// session's home dir when the cwd is in the registry AND the
2026    /// session dir exists on disk. The original v0.6.1 shipped with
2027    /// only an eprintln "verification" — this test asserts the
2028    /// observable return value so the env-set-but-not-consumed class
2029    /// of bug fails loudly.
2030    #[test]
2031    fn detect_session_wire_home_resolves_registered_cwd() {
2032        crate::config::test_support::with_temp_home(|| {
2033            // Set up sessions/registry.json + sessions/test-alpha/ under
2034            // the temp WIRE_HOME so session::read_registry +
2035            // session::session_dir resolve through it.
2036            let wire_home = std::env::var("WIRE_HOME").unwrap();
2037            let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2038            let session_home = sessions_root.join("test-alpha");
2039            std::fs::create_dir_all(&session_home).unwrap();
2040            let fake_cwd = "/tmp/fake-project-cwd-abc123";
2041            let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2042            std::fs::write(
2043                sessions_root.join("registry.json"),
2044                serde_json::to_vec_pretty(&registry).unwrap(),
2045            )
2046            .unwrap();
2047
2048            // Hit happy path.
2049            let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2050            assert_eq!(
2051                got.as_deref(),
2052                Some(session_home.as_path()),
2053                "registered cwd must resolve to session_home"
2054            );
2055
2056            // Unregistered cwd → None.
2057            let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2058                "/tmp/cwd-not-in-registry-xyz789",
2059            ));
2060            assert!(nope.is_none(), "unregistered cwd must return None");
2061
2062            // Registered cwd but session dir missing → None (defensive:
2063            // stale registry entry pointing at a deleted session).
2064            let stale_cwd = "/tmp/stale-session-cwd";
2065            let stale_registry =
2066                json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2067            std::fs::write(
2068                sessions_root.join("registry.json"),
2069                serde_json::to_vec_pretty(&stale_registry).unwrap(),
2070            )
2071            .unwrap();
2072            let stale_got =
2073                crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2074            assert!(
2075                stale_got.is_none(),
2076                "registered cwd whose session dir is missing must return None"
2077            );
2078        });
2079    }
2080}