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. 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": "Zero-paste pair (v0.5). 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. Peer's daemon completes the bilateral pin on next pull. After ~1-2 sec both sides can `wire_send` to each other. Use this when the operator gives you a handle like `coffee-ghost@wireup.net`.",
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_claim",
721            "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).",
722            "inputSchema": {
723                "type": "object",
724                "properties": {
725                    "nick": {"type": "string", "description": "2-32 chars, [a-z0-9_-], not in the reserved set."},
726                    "relay_url": {"type": "string", "description": "Relay to claim on. Default = our relay."},
727                    "public_url": {"type": "string", "description": "Public URL the relay should advertise to resolvers."}
728                },
729                "required": ["nick"]
730            }
731        }),
732        json!({
733            "name": "wire_whois",
734            "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.",
735            "inputSchema": {
736                "type": "object",
737                "properties": {
738                    "handle": {"type": "string", "description": "Optional `nick@domain`. Omit for self."},
739                    "relay_url": {"type": "string", "description": "Override resolver URL."}
740                }
741            }
742        }),
743        json!({
744            "name": "wire_profile_set",
745            "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.",
746            "inputSchema": {
747                "type": "object",
748                "properties": {
749                    "field": {"type": "string", "description": "One of: display_name, emoji, motto, vibe, pronouns, avatar_url, handle, now."},
750                    "value": {"description": "String for most fields; array for vibe; object for now. Pass JSON null to clear a field."}
751                },
752                "required": ["field", "value"]
753            }
754        }),
755        json!({
756            "name": "wire_profile_get",
757            "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.",
758            "inputSchema": {"type": "object", "properties": {}}
759        }),
760    ]
761}
762
763fn handle_tools_call(id: &Value, params: &Value, state: &McpState) -> Value {
764    let name = match params.get("name").and_then(Value::as_str) {
765        Some(n) => n,
766        None => return error_response(id, -32602, "missing tool name"),
767    };
768    let args = params
769        .get("arguments")
770        .cloned()
771        .unwrap_or_else(|| json!({}));
772
773    let result = match name {
774        "wire_whoami" => tool_whoami(),
775        "wire_peers" => tool_peers(),
776        "wire_send" => tool_send(&args),
777        "wire_tail" => tool_tail(&args),
778        "wire_verify" => tool_verify(&args),
779        "wire_init" => tool_init(&args),
780        "wire_pair_initiate" => tool_pair_initiate(&args),
781        "wire_pair_join" => tool_pair_join(&args),
782        "wire_pair_check" => tool_pair_check(&args),
783        "wire_pair_confirm" => tool_pair_confirm(&args, state),
784        "wire_pair_initiate_detached" => tool_pair_initiate_detached(&args),
785        "wire_pair_join_detached" => tool_pair_join_detached(&args),
786        "wire_pair_list_pending" => tool_pair_list_pending(),
787        "wire_pair_confirm_detached" => tool_pair_confirm_detached(&args),
788        "wire_pair_cancel_pending" => tool_pair_cancel_pending(&args),
789        "wire_invite_mint" => tool_invite_mint(&args),
790        "wire_invite_accept" => tool_invite_accept(&args),
791        // v0.5 — agentic hotline (handle + profile + zero-paste discovery).
792        "wire_add" => tool_add(&args),
793        "wire_claim" => tool_claim_handle(&args),
794        "wire_whois" => tool_whois(&args),
795        "wire_profile_set" => tool_profile_set(&args),
796        "wire_profile_get" => tool_profile_get(),
797        // Legacy alias kept for older agent prompts that reference `wire_join`.
798        // Surfaces the operator-friendly error pointing to wire_pair_join.
799        "wire_join" => Err(
800            "wire_join was renamed to wire_pair_join (use code_phrase argument). \
801             See docs/AGENT_INTEGRATION.md."
802                .into(),
803        ),
804        other => Err(format!("unknown tool: {other}")),
805    };
806
807    match result {
808        Ok(value) => json!({
809            "jsonrpc": "2.0",
810            "id": id,
811            "result": {
812                "content": [{
813                    "type": "text",
814                    "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
815                }],
816                "isError": false
817            }
818        }),
819        Err(message) => json!({
820            "jsonrpc": "2.0",
821            "id": id,
822            "result": {
823                "content": [{"type": "text", "text": message}],
824                "isError": true
825            }
826        }),
827    }
828}
829
830// ---------- tool implementations ----------
831
832fn tool_whoami() -> Result<Value, String> {
833    use crate::config;
834    use crate::signing::{b64decode, fingerprint, make_key_id};
835
836    if !config::is_initialized().map_err(|e| e.to_string())? {
837        return Err("not initialized — operator must run `wire init <handle>` first".into());
838    }
839    let card = config::read_agent_card().map_err(|e| e.to_string())?;
840    let did = card
841        .get("did")
842        .and_then(Value::as_str)
843        .unwrap_or("")
844        .to_string();
845    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
846    let pk_b64 = card
847        .get("verify_keys")
848        .and_then(Value::as_object)
849        .and_then(|m| m.values().next())
850        .and_then(|v| v.get("key"))
851        .and_then(Value::as_str)
852        .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
853    let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
854    let fp = fingerprint(&pk_bytes);
855    let key_id = make_key_id(&handle, &pk_bytes);
856    let capabilities = card
857        .get("capabilities")
858        .cloned()
859        .unwrap_or_else(|| json!(["wire/v3.1"]));
860    Ok(json!({
861        "did": did,
862        "handle": handle,
863        "fingerprint": fp,
864        "key_id": key_id,
865        "public_key_b64": pk_b64,
866        "capabilities": capabilities,
867    }))
868}
869
870fn tool_peers() -> Result<Value, String> {
871    use crate::config;
872    use crate::trust::get_tier;
873
874    let trust = config::read_trust().map_err(|e| e.to_string())?;
875    let agents = trust
876        .get("agents")
877        .and_then(Value::as_object)
878        .cloned()
879        .unwrap_or_default();
880    let mut self_did: Option<String> = None;
881    if let Ok(card) = config::read_agent_card() {
882        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
883    }
884    let mut peers = Vec::new();
885    for (handle, agent) in agents.iter() {
886        let did = agent
887            .get("did")
888            .and_then(Value::as_str)
889            .unwrap_or("")
890            .to_string();
891        if Some(did.as_str()) == self_did.as_deref() {
892            continue;
893        }
894        peers.push(json!({
895            "handle": handle,
896            "did": did,
897            "tier": get_tier(&trust, handle),
898            "capabilities": agent.get("card").and_then(|c| c.get("capabilities")).cloned().unwrap_or_else(|| json!([])),
899        }));
900    }
901    Ok(json!(peers))
902}
903
904fn tool_send(args: &Value) -> Result<Value, String> {
905    use crate::config;
906    use crate::signing::{b64decode, sign_message_v31};
907
908    let peer = args
909        .get("peer")
910        .and_then(Value::as_str)
911        .ok_or("missing 'peer'")?;
912    let peer = crate::agent_card::bare_handle(peer);
913    let kind = args
914        .get("kind")
915        .and_then(Value::as_str)
916        .ok_or("missing 'kind'")?;
917    let body = args
918        .get("body")
919        .and_then(Value::as_str)
920        .ok_or("missing 'body'")?;
921    let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
922
923    if !config::is_initialized().map_err(|e| e.to_string())? {
924        return Err("not initialized — operator must run `wire init <handle>` first".into());
925    }
926    let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
927    let card = config::read_agent_card().map_err(|e| e.to_string())?;
928    let did = card
929        .get("did")
930        .and_then(Value::as_str)
931        .unwrap_or("")
932        .to_string();
933    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
934    let pk_b64 = card
935        .get("verify_keys")
936        .and_then(Value::as_object)
937        .and_then(|m| m.values().next())
938        .and_then(|v| v.get("key"))
939        .and_then(Value::as_str)
940        .ok_or("agent-card missing verify_keys[*].key")?;
941    let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
942
943    // Body parses as JSON if possible, else stays a string.
944    let body_value: Value =
945        serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
946    let kind_id = parse_kind(kind);
947
948    let now = time::OffsetDateTime::now_utc()
949        .format(&time::format_description::well_known::Rfc3339)
950        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
951
952    let mut event = json!({
953        "timestamp": now,
954        "from": did,
955        "to": format!("did:wire:{peer}"),
956        "type": kind,
957        "kind": kind_id,
958        "body": body_value,
959    });
960    if let Some(deadline) = deadline {
961        event["time_sensitive_until"] =
962            json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
963    }
964    let signed =
965        sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
966    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
967
968    let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
969    let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
970
971    Ok(json!({
972        "event_id": event_id,
973        "status": "queued",
974        "peer": peer,
975        "outbox": outbox.to_string_lossy(),
976    }))
977}
978
979fn tool_tail(args: &Value) -> Result<Value, String> {
980    use crate::config;
981    use crate::signing::verify_message_v31;
982
983    let peer_filter = args.get("peer").and_then(Value::as_str);
984    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
985    let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
986    if !inbox.exists() {
987        return Ok(json!([]));
988    }
989    let trust = config::read_trust().map_err(|e| e.to_string())?;
990    let mut events = Vec::new();
991    let entries: Vec<_> = std::fs::read_dir(&inbox)
992        .map_err(|e| e.to_string())?
993        .filter_map(|e| e.ok())
994        .map(|e| e.path())
995        .filter(|p| {
996            p.extension().map(|x| x == "jsonl").unwrap_or(false)
997                && match peer_filter {
998                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
999                    None => true,
1000                }
1001        })
1002        .collect();
1003    for path in entries {
1004        let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
1005        for line in body.lines() {
1006            let event: Value = match serde_json::from_str(line) {
1007                Ok(v) => v,
1008                Err(_) => continue,
1009            };
1010            let verified = verify_message_v31(&event, &trust).is_ok();
1011            let mut event_with_meta = event.clone();
1012            if let Some(obj) = event_with_meta.as_object_mut() {
1013                obj.insert("verified".into(), json!(verified));
1014            }
1015            events.push(event_with_meta);
1016            if events.len() >= limit {
1017                return Ok(Value::Array(events));
1018            }
1019        }
1020    }
1021    Ok(Value::Array(events))
1022}
1023
1024fn tool_verify(args: &Value) -> Result<Value, String> {
1025    use crate::config;
1026    use crate::signing::verify_message_v31;
1027
1028    let event_str = args
1029        .get("event")
1030        .and_then(Value::as_str)
1031        .ok_or("missing 'event'")?;
1032    let event: Value =
1033        serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1034    let trust = config::read_trust().map_err(|e| e.to_string())?;
1035    match verify_message_v31(&event, &trust) {
1036        Ok(()) => Ok(json!({"verified": true})),
1037        Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1038    }
1039}
1040
1041// ---------- pairing tools ----------
1042
1043fn tool_init(args: &Value) -> Result<Value, String> {
1044    let handle = args
1045        .get("handle")
1046        .and_then(Value::as_str)
1047        .ok_or("missing 'handle'")?;
1048    let name = args.get("name").and_then(Value::as_str);
1049    let relay = args.get("relay_url").and_then(Value::as_str);
1050    crate::pair_session::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1051}
1052
1053/// Resolve the relay URL: explicit arg wins, else the relay this agent's
1054/// identity is already bound to (from `wire init --relay` or a previous
1055/// pair_initiate). Errors if neither is set.
1056fn resolve_relay_url(args: &Value) -> Result<String, String> {
1057    if let Some(url) = args.get("relay_url").and_then(Value::as_str) {
1058        return Ok(url.to_string());
1059    }
1060    let state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1061    state["self"]["relay_url"]
1062        .as_str()
1063        .map(str::to_string)
1064        .ok_or_else(|| "no relay_url provided and no relay bound (call wire_init with relay_url, or pass relay_url here)".into())
1065}
1066
1067/// If `handle` is provided and identity isn't yet initialized, call
1068/// `init_self_idempotent` so a single MCP call can do both. If handle is
1069/// missing and not initialized, surface a clear error pointing the agent at
1070/// wire_init. If already initialized under a different handle, the
1071/// idempotent init errors clearly (same as direct wire_init).
1072fn auto_init_if_needed(args: &Value) -> Result<(), String> {
1073    let initialized = crate::config::is_initialized().map_err(|e| e.to_string())?;
1074    if initialized {
1075        return Ok(());
1076    }
1077    let handle = args.get("handle").and_then(Value::as_str).ok_or(
1078        "not initialized — pass `handle` to auto-init, or call wire_init explicitly first",
1079    )?;
1080    let relay = args.get("relay_url").and_then(Value::as_str);
1081    crate::pair_session::init_self_idempotent(handle, None, relay)
1082        .map(|_| ())
1083        .map_err(|e| e.to_string())
1084}
1085
1086fn tool_pair_initiate(args: &Value) -> Result<Value, String> {
1087    use crate::pair_session::{
1088        pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1089    };
1090
1091    store_sweep_expired();
1092    // Auto-init if `handle` arg provided and not yet inited (idempotent).
1093    auto_init_if_needed(args)?;
1094
1095    let relay_url = resolve_relay_url(args)?;
1096    let max_wait = args
1097        .get("max_wait_secs")
1098        .and_then(Value::as_u64)
1099        .unwrap_or(30)
1100        .min(60);
1101
1102    let mut s = pair_session_open("host", &relay_url, None).map_err(|e| e.to_string())?;
1103    let code = s.code.clone();
1104
1105    let sas_opt = if max_wait > 0 {
1106        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1107            .map_err(|e| e.to_string())?
1108    } else {
1109        None
1110    };
1111
1112    let session_id = store_insert(s);
1113
1114    let mut out = json!({
1115        "session_id": session_id,
1116        "code_phrase": code,
1117        "relay_url": relay_url,
1118    });
1119    match sas_opt {
1120        Some(sas) => {
1121            out["state"] = json!("sas_ready");
1122            out["sas"] = json!(sas);
1123            out["next"] = json!(
1124                "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel (voice/text). \
1125                 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1126            );
1127        }
1128        None => {
1129            out["state"] = json!("waiting");
1130            out["next"] = json!(
1131                "Share the code_phrase with the user; ask them to read it to their peer (the peer pastes into wire_pair_join). \
1132                 Poll wire_pair_check(session_id) until state='sas_ready'."
1133            );
1134        }
1135    }
1136    Ok(out)
1137}
1138
1139fn tool_pair_join(args: &Value) -> Result<Value, String> {
1140    use crate::pair_session::{
1141        pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1142    };
1143
1144    store_sweep_expired();
1145    auto_init_if_needed(args)?;
1146
1147    let code = args
1148        .get("code_phrase")
1149        .and_then(Value::as_str)
1150        .ok_or("missing 'code_phrase'")?;
1151    let relay_url = resolve_relay_url(args)?;
1152    let max_wait = args
1153        .get("max_wait_secs")
1154        .and_then(Value::as_u64)
1155        .unwrap_or(30)
1156        .min(60);
1157
1158    let mut s = pair_session_open("guest", &relay_url, Some(code)).map_err(|e| e.to_string())?;
1159
1160    let sas_opt =
1161        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1162            .map_err(|e| e.to_string())?;
1163
1164    let session_id = store_insert(s);
1165
1166    let mut out = json!({
1167        "session_id": session_id,
1168        "relay_url": relay_url,
1169    });
1170    match sas_opt {
1171        Some(sas) => {
1172            out["state"] = json!("sas_ready");
1173            out["sas"] = json!(sas);
1174            out["next"] = json!(
1175                "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel. \
1176                 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1177            );
1178        }
1179        None => {
1180            out["state"] = json!("waiting");
1181            out["next"] = json!("Poll wire_pair_check(session_id).");
1182        }
1183    }
1184    Ok(out)
1185}
1186
1187fn tool_pair_check(args: &Value) -> Result<Value, String> {
1188    use crate::pair_session::{pair_session_wait_for_sas, store_get, store_sweep_expired};
1189
1190    store_sweep_expired();
1191    let session_id = args
1192        .get("session_id")
1193        .and_then(Value::as_str)
1194        .ok_or("missing 'session_id'")?;
1195    let max_wait = args
1196        .get("max_wait_secs")
1197        .and_then(Value::as_u64)
1198        .unwrap_or(8)
1199        .min(60);
1200
1201    let arc = store_get(session_id)
1202        .ok_or_else(|| format!("no such session_id (expired or never opened): {session_id}"))?;
1203    let mut s = arc.lock().map_err(|e| e.to_string())?;
1204
1205    if s.finalized {
1206        return Ok(json!({
1207            "state": "finalized",
1208            "session_id": session_id,
1209            "sas": s.formatted_sas(),
1210        }));
1211    }
1212    if let Some(reason) = s.aborted.clone() {
1213        return Ok(json!({
1214            "state": "aborted",
1215            "session_id": session_id,
1216            "reason": reason,
1217        }));
1218    }
1219
1220    let sas_opt =
1221        pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1222            .map_err(|e| e.to_string())?;
1223
1224    Ok(match sas_opt {
1225        Some(sas) => json!({
1226            "state": "sas_ready",
1227            "session_id": session_id,
1228            "sas": sas,
1229            "next": "Have the user TYPE the 6 SAS digits BACK INTO CHAT, then pass to wire_pair_confirm."
1230        }),
1231        None => json!({
1232            "state": "waiting",
1233            "session_id": session_id,
1234        }),
1235    })
1236}
1237
1238fn tool_pair_confirm(args: &Value, state: &McpState) -> Result<Value, String> {
1239    use crate::pair_session::{
1240        pair_session_confirm_sas, pair_session_finalize, store_get, store_remove,
1241    };
1242
1243    let session_id = args
1244        .get("session_id")
1245        .and_then(Value::as_str)
1246        .ok_or("missing 'session_id'")?;
1247    let typed = args
1248        .get("user_typed_digits")
1249        .and_then(Value::as_str)
1250        .ok_or(
1251            "missing 'user_typed_digits' — the user must type the 6 SAS digits back into chat",
1252        )?;
1253
1254    let arc = store_get(session_id).ok_or_else(|| format!("no such session_id: {session_id}"))?;
1255
1256    let confirm_err = {
1257        let mut s = arc.lock().map_err(|e| e.to_string())?;
1258        match pair_session_confirm_sas(&mut s, typed) {
1259            Ok(()) => None,
1260            Err(e) => Some((s.aborted.is_some(), e.to_string())),
1261        }
1262    };
1263    if let Some((aborted, msg)) = confirm_err {
1264        if aborted {
1265            store_remove(session_id);
1266        }
1267        return Err(msg);
1268    }
1269
1270    let mut result = {
1271        let mut s = arc.lock().map_err(|e| e.to_string())?;
1272        pair_session_finalize(&mut s, 30).map_err(|e| e.to_string())?
1273    };
1274    store_remove(session_id);
1275
1276    // ---- Post-pair auto-setup (Goal: zero friction after SAS) ----
1277    // 1. Auto-subscribe to wire://inbox/<peer> so clients that support
1278    //    resources/subscribe get push notifications/resources/updated.
1279    // 2. Spawn `wire daemon` if not already running so push/pull is automatic.
1280    // 3. Spawn `wire notify` if not already running so OS toasts fire on
1281    //    inbox grow (covers MCP hosts that lack resources/subscribe).
1282    // 4. Emit notifications/resources/list_changed via the writer channel so
1283    //    a client that called resources/list before pairing refreshes its view.
1284    let peer_handle = result["peer_handle"].as_str().unwrap_or("").to_string();
1285    let peer_uri = format!("wire://inbox/{peer_handle}");
1286
1287    let mut auto = json!({
1288        "subscribed": false,
1289        "daemon": "unknown",
1290        "notify": "unknown",
1291        "resources_list_changed_emitted": false,
1292    });
1293
1294    if !peer_handle.is_empty()
1295        && let Ok(mut g) = state.subscribed.lock()
1296    {
1297        g.insert(peer_uri.clone());
1298        auto["subscribed"] = json!(true);
1299    }
1300
1301    auto["daemon"] = match crate::ensure_up::ensure_daemon_running() {
1302        Ok(true) => json!("spawned"),
1303        Ok(false) => json!("already_running"),
1304        Err(e) => json!(format!("spawn_error: {e}")),
1305    };
1306    auto["notify"] = match crate::ensure_up::ensure_notify_running() {
1307        Ok(true) => json!("spawned"),
1308        Ok(false) => json!("already_running"),
1309        Err(e) => json!(format!("spawn_error: {e}")),
1310    };
1311
1312    if let Some(tx) = state.notif_tx.lock().ok().and_then(|g| g.clone()) {
1313        let notif = json!({
1314            "jsonrpc": "2.0",
1315            "method": "notifications/resources/list_changed",
1316        });
1317        if tx.send(notif.to_string()).is_ok() {
1318            auto["resources_list_changed_emitted"] = json!(true);
1319        }
1320    }
1321
1322    result["auto"] = auto;
1323    result["next"] = json!(
1324        "Done. Daemon + notify running, subscribed to peer inbox. Use wire_send/wire_tail \
1325         freely; new events arrive via notifications/resources/updated (where supported) and \
1326         OS toasts (always)."
1327    );
1328    Ok(result)
1329}
1330
1331// ---------- detached pair tools (daemon-orchestrated) ----------
1332
1333fn tool_pair_initiate_detached(args: &Value) -> Result<Value, String> {
1334    auto_init_if_needed(args)?;
1335    let relay_url = resolve_relay_url(args)?;
1336    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1337        let _ = crate::ensure_up::ensure_daemon_running();
1338    }
1339    let code = crate::sas::generate_code_phrase();
1340    let code_hash = crate::pair_session::derive_code_hash(&code);
1341    let now = time::OffsetDateTime::now_utc()
1342        .format(&time::format_description::well_known::Rfc3339)
1343        .unwrap_or_default();
1344    let p = crate::pending_pair::PendingPair {
1345        code: code.clone(),
1346        code_hash,
1347        role: "host".to_string(),
1348        relay_url: relay_url.clone(),
1349        status: "request_host".to_string(),
1350        sas: None,
1351        peer_did: None,
1352        created_at: now,
1353        last_error: None,
1354        pair_id: None,
1355        our_slot_id: None,
1356        our_slot_token: None,
1357        spake2_seed_b64: None,
1358    };
1359    crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1360    Ok(json!({
1361        "code_phrase": code,
1362        "relay_url": relay_url,
1363        "state": "queued",
1364        "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."
1365    }))
1366}
1367
1368fn tool_pair_join_detached(args: &Value) -> Result<Value, String> {
1369    auto_init_if_needed(args)?;
1370    let relay_url = resolve_relay_url(args)?;
1371    let code_phrase = args
1372        .get("code_phrase")
1373        .and_then(Value::as_str)
1374        .ok_or("missing 'code_phrase'")?;
1375    let code = crate::sas::parse_code_phrase(code_phrase)
1376        .map_err(|e| e.to_string())?
1377        .to_string();
1378    let code_hash = crate::pair_session::derive_code_hash(&code);
1379    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1380        let _ = crate::ensure_up::ensure_daemon_running();
1381    }
1382    let now = time::OffsetDateTime::now_utc()
1383        .format(&time::format_description::well_known::Rfc3339)
1384        .unwrap_or_default();
1385    let p = crate::pending_pair::PendingPair {
1386        code: code.clone(),
1387        code_hash,
1388        role: "guest".to_string(),
1389        relay_url: relay_url.clone(),
1390        status: "request_guest".to_string(),
1391        sas: None,
1392        peer_did: None,
1393        created_at: now,
1394        last_error: None,
1395        pair_id: None,
1396        our_slot_id: None,
1397        our_slot_token: None,
1398        spake2_seed_b64: None,
1399    };
1400    crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1401    Ok(json!({
1402        "code_phrase": code,
1403        "relay_url": relay_url,
1404        "state": "queued",
1405        "next": "Subscribe to wire://pending-pair/all; on sas_ready notification, surface digits to user and call wire_pair_confirm_detached."
1406    }))
1407}
1408
1409fn tool_pair_list_pending() -> Result<Value, String> {
1410    let items = crate::pending_pair::list_pending().map_err(|e| e.to_string())?;
1411    Ok(json!({"pending": items}))
1412}
1413
1414fn tool_pair_confirm_detached(args: &Value) -> Result<Value, String> {
1415    let code_phrase = args
1416        .get("code_phrase")
1417        .and_then(Value::as_str)
1418        .ok_or("missing 'code_phrase'")?;
1419    let typed = args
1420        .get("user_typed_digits")
1421        .and_then(Value::as_str)
1422        .ok_or("missing 'user_typed_digits'")?;
1423    let code = crate::sas::parse_code_phrase(code_phrase)
1424        .map_err(|e| e.to_string())?
1425        .to_string();
1426    let typed: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
1427    if typed.len() != 6 {
1428        return Err(format!(
1429            "expected 6 digits (got {} after stripping non-digits)",
1430            typed.len()
1431        ));
1432    }
1433    let mut p = crate::pending_pair::read_pending(&code)
1434        .map_err(|e| e.to_string())?
1435        .ok_or_else(|| format!("no pending pair for code {code}"))?;
1436    if p.status != "sas_ready" {
1437        return Err(format!(
1438            "pair {code} not in sas_ready state (current: {})",
1439            p.status
1440        ));
1441    }
1442    let stored = p
1443        .sas
1444        .as_ref()
1445        .ok_or("pending file has status=sas_ready but no sas field")?
1446        .clone();
1447    if stored == typed {
1448        p.status = "confirmed".to_string();
1449        crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1450        Ok(json!({
1451            "state": "confirmed",
1452            "code_phrase": code,
1453            "next": "Daemon will finalize on its next tick (~1s). Poll wire_peers or watch wire://pending-pair/all for the entry to disappear."
1454        }))
1455    } else {
1456        p.status = "aborted".to_string();
1457        p.last_error = Some(format!(
1458            "SAS digit mismatch (typed {typed}, expected {stored})"
1459        ));
1460        let client = crate::relay_client::RelayClient::new(&p.relay_url);
1461        let _ = client.pair_abandon(&p.code_hash);
1462        let _ = crate::pending_pair::write_pending(&p);
1463        crate::os_notify::toast(
1464            &format!("wire — pair aborted ({code})"),
1465            p.last_error.as_deref().unwrap_or("digits mismatch"),
1466        );
1467        Err(
1468            "digits mismatch — pair aborted. Re-issue with wire_pair_initiate_detached."
1469                .to_string(),
1470        )
1471    }
1472}
1473
1474fn tool_pair_cancel_pending(args: &Value) -> Result<Value, String> {
1475    let code_phrase = args
1476        .get("code_phrase")
1477        .and_then(Value::as_str)
1478        .ok_or("missing 'code_phrase'")?;
1479    let code = crate::sas::parse_code_phrase(code_phrase)
1480        .map_err(|e| e.to_string())?
1481        .to_string();
1482    if let Some(p) = crate::pending_pair::read_pending(&code).map_err(|e| e.to_string())? {
1483        let client = crate::relay_client::RelayClient::new(&p.relay_url);
1484        let _ = client.pair_abandon(&p.code_hash);
1485    }
1486    crate::pending_pair::delete_pending(&code).map_err(|e| e.to_string())?;
1487    Ok(json!({"state": "cancelled", "code_phrase": code}))
1488}
1489
1490// ---------- invite-URL one-paste pair (v0.4.0) ----------
1491
1492fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1493    let relay_url = args.get("relay_url").and_then(Value::as_str);
1494    let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1495    let uses = args
1496        .get("uses")
1497        .and_then(Value::as_u64)
1498        .map(|u| u as u32)
1499        .unwrap_or(1);
1500    let url =
1501        crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1502    let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1503    Ok(json!({
1504        "invite_url": url,
1505        "ttl_secs": ttl_resolved,
1506        "uses": uses,
1507    }))
1508}
1509
1510fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1511    let url = args
1512        .get("url")
1513        .and_then(Value::as_str)
1514        .ok_or("missing 'url'")?;
1515    crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1516}
1517
1518// ---------- v0.5 — agentic hotline tools ----------
1519
1520fn tool_add(args: &Value) -> Result<Value, String> {
1521    let handle = args
1522        .get("handle")
1523        .and_then(Value::as_str)
1524        .ok_or("missing 'handle'")?;
1525    let relay_override = args.get("relay_url").and_then(Value::as_str);
1526
1527    let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1528
1529    // Ensure self has identity + relay slot (auto-inits if needed).
1530    let (our_did, our_relay, our_slot_id, our_slot_token) =
1531        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1532
1533    // Resolve peer via .well-known.
1534    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1535        .map_err(|e| format!("{e:#}"))?;
1536    let peer_card = resolved
1537        .get("card")
1538        .cloned()
1539        .ok_or("resolved missing card")?;
1540    let peer_did = resolved
1541        .get("did")
1542        .and_then(Value::as_str)
1543        .ok_or("resolved missing did")?
1544        .to_string();
1545    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1546    let peer_slot_id = resolved
1547        .get("slot_id")
1548        .and_then(Value::as_str)
1549        .ok_or("resolved missing slot_id")?
1550        .to_string();
1551    let peer_relay = resolved
1552        .get("relay_url")
1553        .and_then(Value::as_str)
1554        .map(str::to_string)
1555        .or_else(|| relay_override.map(str::to_string))
1556        .unwrap_or_else(|| format!("https://{}", parsed.domain));
1557
1558    // Pin peer in trust + relay-state. slot_token arrives via ack later.
1559    let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1560    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1561    crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1562    let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1563    let existing_token = relay_state
1564        .get("peers")
1565        .and_then(|p| p.get(&peer_handle))
1566        .and_then(|p| p.get("slot_token"))
1567        .and_then(Value::as_str)
1568        .map(str::to_string)
1569        .unwrap_or_default();
1570    relay_state["peers"][&peer_handle] = json!({
1571        "relay_url": peer_relay,
1572        "slot_id": peer_slot_id,
1573        "slot_token": existing_token,
1574    });
1575    crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1576
1577    // Build + sign pair_drop event (no nonce — open-mode handle pair).
1578    let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1579    let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1580    let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1581    let pk_b64 = our_card
1582        .get("verify_keys")
1583        .and_then(Value::as_object)
1584        .and_then(|m| m.values().next())
1585        .and_then(|v| v.get("key"))
1586        .and_then(Value::as_str)
1587        .ok_or("our card missing verify_keys[*].key")?;
1588    let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1589    let now = time::OffsetDateTime::now_utc()
1590        .format(&time::format_description::well_known::Rfc3339)
1591        .unwrap_or_default();
1592    let event = json!({
1593        "timestamp": now,
1594        "from": our_did,
1595        "to": peer_did,
1596        "type": "pair_drop",
1597        "kind": 1100u32,
1598        "body": {
1599            "card": our_card,
1600            "relay_url": our_relay,
1601            "slot_id": our_slot_id,
1602            "slot_token": our_slot_token,
1603        },
1604    });
1605    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
1606        .map_err(|e| format!("{e:#}"))?;
1607
1608    let client = crate::relay_client::RelayClient::new(&peer_relay);
1609    let resp = client
1610        .handle_intro(&parsed.nick, &signed)
1611        .map_err(|e| format!("{e:#}"))?;
1612    let event_id = signed
1613        .get("event_id")
1614        .and_then(Value::as_str)
1615        .unwrap_or("")
1616        .to_string();
1617    Ok(json!({
1618        "handle": handle,
1619        "paired_with": peer_did,
1620        "peer_handle": peer_handle,
1621        "event_id": event_id,
1622        "drop_response": resp,
1623        "status": "drop_sent",
1624    }))
1625}
1626
1627fn tool_claim_handle(args: &Value) -> Result<Value, String> {
1628    let nick = args
1629        .get("nick")
1630        .and_then(Value::as_str)
1631        .ok_or("missing 'nick'")?;
1632    let relay_override = args.get("relay_url").and_then(Value::as_str);
1633    let public_url = args.get("public_url").and_then(Value::as_str);
1634
1635    // Auto-init + ensure slot.
1636    let (_, our_relay, our_slot_id, our_slot_token) =
1637        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1638    let claim_relay = relay_override.unwrap_or(&our_relay);
1639    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1640    let client = crate::relay_client::RelayClient::new(claim_relay);
1641    let resp = client
1642        .handle_claim(nick, &our_slot_id, &our_slot_token, public_url, &card)
1643        .map_err(|e| format!("{e:#}"))?;
1644    Ok(json!({
1645        "nick": nick,
1646        "relay": claim_relay,
1647        "response": resp,
1648    }))
1649}
1650
1651fn tool_whois(args: &Value) -> Result<Value, String> {
1652    if let Some(handle) = args.get("handle").and_then(Value::as_str) {
1653        let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1654        let relay_override = args.get("relay_url").and_then(Value::as_str);
1655        crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
1656    } else {
1657        // Self.
1658        let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1659        Ok(json!({
1660            "did": card.get("did").cloned().unwrap_or(Value::Null),
1661            "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1662        }))
1663    }
1664}
1665
1666fn tool_profile_set(args: &Value) -> Result<Value, String> {
1667    let field = args
1668        .get("field")
1669        .and_then(Value::as_str)
1670        .ok_or("missing 'field'")?;
1671    let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
1672    // If value is a string that itself parses as JSON (e.g. "[\"rust\"]"),
1673    // unwrap it. Otherwise pass as-is. Lets agents send either typed values
1674    // or stringified JSON.
1675    let value = if let Some(s) = raw_value.as_str() {
1676        serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
1677    } else {
1678        raw_value
1679    };
1680    let new_profile =
1681        crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
1682    Ok(json!({
1683        "field": field,
1684        "profile": new_profile,
1685    }))
1686}
1687
1688fn tool_profile_get() -> Result<Value, String> {
1689    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1690    Ok(json!({
1691        "did": card.get("did").cloned().unwrap_or(Value::Null),
1692        "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1693    }))
1694}
1695
1696// ---------- helpers ----------
1697
1698fn parse_kind(s: &str) -> u32 {
1699    if let Ok(n) = s.parse::<u32>() {
1700        return n;
1701    }
1702    for (id, name) in crate::signing::kinds() {
1703        if *name == s {
1704            return *id;
1705        }
1706    }
1707    1
1708}
1709
1710fn error_response(id: &Value, code: i32, message: &str) -> Value {
1711    json!({
1712        "jsonrpc": "2.0",
1713        "id": id,
1714        "error": {"code": code, "message": message}
1715    })
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720    use super::*;
1721
1722    #[test]
1723    fn unknown_method_returns_jsonrpc_error() {
1724        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
1725        let resp = handle_request(&req, &McpState::default());
1726        assert_eq!(resp["error"]["code"], -32601);
1727    }
1728
1729    #[test]
1730    fn initialize_advertises_tools_capability() {
1731        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
1732        let resp = handle_request(&req, &McpState::default());
1733        assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
1734        assert!(resp["result"]["capabilities"]["tools"].is_object());
1735        assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
1736    }
1737
1738    #[test]
1739    fn tools_list_includes_pairing_and_messaging() {
1740        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
1741        let resp = handle_request(&req, &McpState::default());
1742        let names: Vec<&str> = resp["result"]["tools"]
1743            .as_array()
1744            .unwrap()
1745            .iter()
1746            .filter_map(|t| t["name"].as_str())
1747            .collect();
1748        for required in [
1749            "wire_whoami",
1750            "wire_peers",
1751            "wire_send",
1752            "wire_tail",
1753            "wire_verify",
1754            "wire_init",
1755            "wire_pair_initiate",
1756            "wire_pair_join",
1757            "wire_pair_check",
1758            "wire_pair_confirm",
1759        ] {
1760            assert!(
1761                names.contains(&required),
1762                "missing required tool {required}"
1763            );
1764        }
1765        // wire_join (the old direct alias for pair-join, no SAS-typeback) is
1766        // explicitly NOT in the catalog. Calling it returns a deprecation
1767        // pointing to wire_pair_join (test below covers this).
1768        assert!(
1769            !names.contains(&"wire_join"),
1770            "wire_join must not be advertised — superseded by wire_pair_join"
1771        );
1772    }
1773
1774    #[test]
1775    fn legacy_wire_join_call_returns_helpful_error() {
1776        let req = json!({
1777            "jsonrpc": "2.0",
1778            "id": 1,
1779            "method": "tools/call",
1780            "params": {"name": "wire_join", "arguments": {}}
1781        });
1782        let resp = handle_request(&req, &McpState::default());
1783        assert_eq!(resp["result"]["isError"], true);
1784        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
1785        assert!(
1786            text.contains("wire_pair_join"),
1787            "expected redirect to wire_pair_join, got: {text}"
1788        );
1789    }
1790
1791    #[test]
1792    fn pair_confirm_missing_session_id_errors_cleanly() {
1793        let req = json!({
1794            "jsonrpc": "2.0",
1795            "id": 1,
1796            "method": "tools/call",
1797            "params": {"name": "wire_pair_confirm", "arguments": {"user_typed_digits": "111111"}}
1798        });
1799        let resp = handle_request(&req, &McpState::default());
1800        assert_eq!(resp["result"]["isError"], true);
1801    }
1802
1803    #[test]
1804    fn pair_confirm_unknown_session_errors_cleanly() {
1805        let req = json!({
1806            "jsonrpc": "2.0",
1807            "id": 1,
1808            "method": "tools/call",
1809            "params": {
1810                "name": "wire_pair_confirm",
1811                "arguments": {"session_id": "definitely-not-real", "user_typed_digits": "111111"}
1812            }
1813        });
1814        let resp = handle_request(&req, &McpState::default());
1815        assert_eq!(resp["result"]["isError"], true);
1816        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
1817        assert!(text.contains("no such session_id"), "got: {text}");
1818    }
1819
1820    #[test]
1821    fn initialize_advertises_resources_capability() {
1822        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
1823        let resp = handle_request(&req, &McpState::default());
1824        let caps = &resp["result"]["capabilities"];
1825        assert!(
1826            caps["resources"].is_object(),
1827            "resources capability must be present, got {resp}"
1828        );
1829        assert_eq!(
1830            caps["resources"]["subscribe"], true,
1831            "subscribe shipped in v0.2.1"
1832        );
1833    }
1834
1835    #[test]
1836    fn resources_read_with_bad_uri_errors() {
1837        let req = json!({
1838            "jsonrpc": "2.0",
1839            "id": 1,
1840            "method": "resources/read",
1841            "params": {"uri": "http://example.com/not-a-wire-uri"}
1842        });
1843        let resp = handle_request(&req, &McpState::default());
1844        assert!(resp.get("error").is_some(), "expected error, got {resp}");
1845    }
1846
1847    #[test]
1848    fn parse_inbox_uri_handles_variants() {
1849        assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
1850        assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
1851        assert!(
1852            parse_inbox_uri("wire://inbox/")
1853                .unwrap()
1854                .starts_with("__invalid__"),
1855            "empty peer must be invalid"
1856        );
1857        assert!(
1858            parse_inbox_uri("http://other")
1859                .unwrap()
1860                .starts_with("__invalid__"),
1861            "non-wire scheme must be invalid"
1862        );
1863    }
1864
1865    #[test]
1866    fn ping_returns_empty_result() {
1867        let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
1868        let resp = handle_request(&req, &McpState::default());
1869        assert_eq!(resp["id"], 7);
1870        assert!(resp["result"].is_object());
1871    }
1872
1873    #[test]
1874    fn notification_returns_null_no_reply() {
1875        let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
1876        let resp = handle_request(&req, &McpState::default());
1877        assert_eq!(resp, Value::Null);
1878    }
1879}