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