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