Skip to main content

wire/
mcp.rs

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