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