Skip to main content

wire/
mcp.rs

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