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; operator gates via bilateral accept)**
18//!   - `wire_init`           — idempotent identity creation; same handle = no-op,
19//!     different handle = error (cannot re-key silently)
20//!   - `wire_dial`           — initiate a pair by handle (`<handle>@<relay>`);
21//!     the canonical pairing path
22//!   - `wire_pending` / `wire_accept` / `wire_reject` — inbound bilateral gate
23//!   - `wire_invite_mint` / `wire_invite_accept` — single-paste invite-URL pair
24//!
25//! The SAS / code-phrase / SPAKE2 ceremony (`wire_pair_initiate` / `_join` /
26//! `_confirm` and their detached variants) was removed in the RFC-005
27//! follow-on — `wire_dial` is the sole canonical pairing path.
28
29use anyhow::Result;
30use serde_json::{Value, json};
31use std::collections::HashSet;
32use std::io::{BufRead, BufReader, Write};
33use std::sync::{Arc, Mutex};
34
35/// Shared MCP-session state. Today: subscribed resource URIs + a writer
36/// channel for unsolicited notifications (push). Future per-session cursors,
37/// etc. go here.
38#[derive(Clone, Default)]
39pub struct McpState {
40    /// Resource URIs the client has subscribed to. Wildcard support is
41    /// intentionally NOT done — clients subscribe to specific URIs and
42    /// receive `notifications/resources/updated` only for those URIs.
43    pub subscribed: Arc<Mutex<HashSet<String>>>,
44    /// Writer-channel sender for emitting unsolicited notifications
45    /// (notifications/resources/list_changed, etc.). Populated by `run()`
46    /// before tools are dispatched; None in unit tests.
47    pub notif_tx: Arc<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
48}
49
50const PROTOCOL_VERSION: &str = "2025-06-18";
51const SERVER_NAME: &str = "wire";
52const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
53
54/// Run the MCP server until stdin closes.
55///
56/// Threading model (Goal 2.1):
57///
58/// - **Main thread**: reads stdin line-by-line, parses JSON-RPC, calls
59///   `handle_request` to compute a response, hands it to the writer via the
60///   mpsc channel.
61/// - **Writer thread**: single owner of stdout. Drains responses + push
62///   notifications from the channel, writes each as one line + flush. Single
63///   writer = no interleaving between responses and notifications.
64/// - **Watcher thread**: holds an `InboxWatcher::from_head` (starts at EOF —
65///   each MCP session only sees fresh events). Polls every 2s. For each new
66///   inbox event, checks the shared subscription set; if any matching
67///   `wire://inbox/<peer>` or `wire://inbox/all` URI is subscribed, pushes
68///   a `notifications/resources/updated` message into the channel.
69///
70/// v0.6.7: `detect_session_wire_home` moved to
71/// `session::detect_session_wire_home` (shared with the CLI auto-detect at
72/// `cli::run` entry). The mcp-only wrapper was removed; the regression test
73/// now calls the session-module version directly.
74pub fn run() -> Result<()> {
75    use std::sync::atomic::{AtomicBool, Ordering};
76    use std::sync::mpsc;
77    use std::time::{Duration, Instant};
78
79    // v0.6.1: auto-detect WIRE_HOME from cwd. If the operator already
80    // set it (explicit override via `.mcp.json env.WIRE_HOME`), respect
81    // that. Else: if the cwd maps to a `wire session` entry in the
82    // registry, adopt that session's WIRE_HOME for this MCP process so
83    // every subsequent tool call routes to the right inbox / outbox /
84    // identity.
85    //
86    // v0.6.7: identical helper now also runs at CLI entry (cli::run),
87    // so `wire whoami` / `wire monitor` from a session cwd resolve to
88    // the same identity the MCP server uses. Before v0.6.7 the CLI
89    // silently fell back to the default WIRE_HOME, leaving operators
90    // unable to tell which identity their monitor was tailing.
91    crate::session::maybe_adopt_session_wire_home("mcp");
92
93    // v0.7.0-alpha.2: if auto-detect found no session for this cwd
94    // (including via parent-walk), create one inline so every Claude
95    // tab in a fresh project gets its own wire identity rather than
96    // silently sharing the machine-wide default. Opt out via
97    // `WIRE_AUTO_INIT=0`.
98    crate::cli::maybe_auto_init_cwd_session("mcp");
99
100    // v0.13: a session-keyed WIRE_HOME (sessions/by-key/<hash>) starts empty.
101    // Bootstrap its identity on first MCP start — one-name init + federation
102    // slot + phonebook claim — so each Claude session is its own reachable,
103    // claimed identity. One-time per home (gated on is_initialized);
104    // best-effort (offline → init-only, no claim). Skipped under
105    // WIRE_MCP_SKIP_AUTO_UP (tests + manual-identity operators).
106    ensure_session_bootstrapped();
107
108    // v0.15.x: minting an identity isn't enough — without a running sync loop
109    // the session is "born deaf" (never pulls inbound, never pushes outbound),
110    // the #1 MCP first-run failure. `ensure_session_bootstrapped` only creates
111    // identity (and early-returns for already-initialized homes), so arm the
112    // daemon unconditionally here. Idempotent (singleton-guarded) and gated on
113    // an existing identity + the same skip env bootstrap honors.
114    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err()
115        && crate::config::is_initialized().unwrap_or(false)
116    {
117        let _ = crate::ensure_up::ensure_daemon_running();
118    }
119
120    // v0.6.10: surface multi-agent identity collisions explicitly.
121    // Two Claudes (or any MCP-host pair) launched in the same cwd
122    // auto-detect into the same wire session and silently share an
123    // inbox cursor. v0.6.7 made this invisible by design ("just adopt
124    // the cwd's session"); operators hit it as "they look identical"
125    // and burn hours debugging. The warning gives them a clear
126    // remediation path the first time they see it.
127    crate::session::warn_on_identity_collision(std::process::id(), "mcp");
128
129    let state = McpState::default();
130    let shutdown = Arc::new(AtomicBool::new(false));
131
132    let (tx, rx) = mpsc::channel::<String>();
133
134    // Expose the tx clone via state so tool handlers can push unsolicited
135    // notifications (notifications/resources/list_changed after a pair pin).
136    if let Ok(mut g) = state.notif_tx.lock() {
137        *g = Some(tx.clone());
138    }
139
140    // Writer thread — single owner of stdout. Exits when all senders drop.
141    let writer_handle = std::thread::spawn(move || {
142        let stdout = std::io::stdout();
143        let mut w = stdout.lock();
144        while let Ok(line) = rx.recv() {
145            if writeln!(w, "{line}").is_err() {
146                break;
147            }
148            if w.flush().is_err() {
149                break;
150            }
151        }
152    });
153
154    // Watcher thread — polls inbox every 2s and emits
155    // notifications/resources/updated on grow. Observes `shutdown` so we
156    // can exit cleanly on stdin EOF (otherwise its tx_w clone keeps the
157    // writer thread blocked on rx.recv forever).
158    let subs_w = state.subscribed.clone();
159    let tx_w = tx.clone();
160    let shutdown_w = shutdown.clone();
161    let watcher_handle = std::thread::spawn(move || {
162        let mut watcher = match crate::inbox_watch::InboxWatcher::from_head() {
163            Ok(w) => w,
164            Err(_) => return,
165        };
166        let poll_interval = Duration::from_secs(2);
167        let mut next_poll = Instant::now() + poll_interval;
168        loop {
169            if shutdown_w.load(Ordering::SeqCst) {
170                return;
171            }
172            std::thread::sleep(Duration::from_millis(100));
173            if Instant::now() < next_poll {
174                continue;
175            }
176            next_poll = Instant::now() + poll_interval;
177            let subs_snapshot = match subs_w.lock() {
178                Ok(g) => g.clone(),
179                Err(_) => return,
180            };
181
182            let mut affected: HashSet<String> = HashSet::new();
183
184            // ---- inbox events ----
185            if !subs_snapshot.is_empty()
186                && let Ok(events) = watcher.poll()
187            {
188                for ev in &events {
189                    if subs_snapshot.contains("wire://inbox/all") {
190                        affected.insert("wire://inbox/all".to_string());
191                    }
192                    let peer_uri = format!("wire://inbox/{}", ev.peer);
193                    if subs_snapshot.contains(&peer_uri) {
194                        affected.insert(peer_uri);
195                    }
196                }
197            }
198
199            for uri in affected {
200                let notif = json!({
201                    "jsonrpc": "2.0",
202                    "method": "notifications/resources/updated",
203                    "params": {"uri": uri}
204                });
205                if tx_w.send(notif.to_string()).is_err() {
206                    return;
207                }
208            }
209        }
210    });
211
212    let stdin = std::io::stdin();
213    let mut reader = BufReader::new(stdin.lock());
214    let mut line = String::new();
215    loop {
216        line.clear();
217        let n = reader.read_line(&mut line)?;
218        if n == 0 {
219            // EOF — signal watcher to exit; clear the notif_tx Sender clone
220            // that state holds (otherwise writer's rx.recv() never sees
221            // all-senders-dropped); drop main tx; wait for worker threads.
222            shutdown.store(true, Ordering::SeqCst);
223            if let Ok(mut g) = state.notif_tx.lock() {
224                *g = None;
225            }
226            drop(tx);
227            let _ = watcher_handle.join();
228            let _ = writer_handle.join();
229            return Ok(());
230        }
231        let trimmed = line.trim();
232        if trimmed.is_empty() {
233            continue;
234        }
235        let request: Value = match serde_json::from_str(trimmed) {
236            Ok(v) => v,
237            Err(e) => {
238                let err = error_response(&Value::Null, -32700, &format!("parse error: {e}"));
239                let _ = tx.send(err.to_string());
240                continue;
241            }
242        };
243        let response = handle_request(&request, &state);
244        // Notifications (no `id`) get no response.
245        if response.get("id").is_some() || response.get("error").is_some() {
246            let _ = tx.send(response.to_string());
247        }
248    }
249}
250
251fn handle_request(req: &Value, state: &McpState) -> Value {
252    let id = req.get("id").cloned().unwrap_or(Value::Null);
253    let method = match req.get("method").and_then(Value::as_str) {
254        Some(m) => m,
255        None => return error_response(&id, -32600, "missing method"),
256    };
257    match method {
258        "initialize" => handle_initialize(&id),
259        "notifications/initialized" => Value::Null, // notification — no reply
260        "tools/list" => handle_tools_list(&id),
261        "tools/call" => handle_tools_call(&id, req.get("params").unwrap_or(&Value::Null), state),
262        "resources/list" => handle_resources_list(&id),
263        "resources/read" => handle_resources_read(&id, req.get("params").unwrap_or(&Value::Null)),
264        "resources/subscribe" => {
265            handle_resources_subscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
266        }
267        "resources/unsubscribe" => {
268            handle_resources_unsubscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
269        }
270        "ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}),
271        other => error_response(&id, -32601, &format!("method not found: {other}")),
272    }
273}
274
275// ---------- resources (Goal 2) ----------
276//
277// MCP resources expose semi-static state for agents that want a "read this
278// when relevant" surface instead of polling tools. v0.2 ships read-only;
279// subscribe (push-notify on inbox grow) is v0.2.1 — requires a background
280// watcher thread + async stdout writer.
281//
282// Resource URI scheme:
283//   wire://inbox/<peer>    last 50 verified events for that pinned peer
284//   wire://inbox/all       last 50 events across all peers, newest first
285
286fn handle_resources_list(id: &Value) -> Value {
287    let mut resources = vec![json!({
288        "uri": "wire://inbox/all",
289        "name": "wire inbox (all peers)",
290        "description": "Most recent verified events from all pinned peers, JSONL.",
291        "mimeType": "application/x-ndjson"
292    })];
293
294    if let Ok(trust) = crate::config::read_trust() {
295        let agents = trust
296            .get("agents")
297            .and_then(Value::as_object)
298            .cloned()
299            .unwrap_or_default();
300        let self_did = crate::config::read_agent_card()
301            .ok()
302            .and_then(|c| c.get("did").and_then(Value::as_str).map(str::to_string));
303        for (handle, agent) in agents.iter() {
304            let did = agent
305                .get("did")
306                .and_then(Value::as_str)
307                .unwrap_or("")
308                .to_string();
309            if Some(did.as_str()) == self_did.as_deref() {
310                continue;
311            }
312            resources.push(json!({
313                "uri": format!("wire://inbox/{handle}"),
314                "name": format!("inbox from {handle}"),
315                "description": format!("Recent verified events from did:wire:{handle}."),
316                "mimeType": "application/x-ndjson"
317            }));
318        }
319    }
320
321    json!({
322        "jsonrpc": "2.0",
323        "id": id,
324        "result": {
325            "resources": resources
326        }
327    })
328}
329
330fn handle_resources_subscribe(id: &Value, params: &Value, state: &McpState) -> Value {
331    let uri = match params.get("uri").and_then(Value::as_str) {
332        Some(u) => u.to_string(),
333        None => return error_response(id, -32602, "missing 'uri'"),
334    };
335    // Validate the URI shape. Accept wire://inbox/<peer>, wire://inbox/all.
336    // Anything else is rejected so we don't pile up dead subscriptions.
337    let inbox_peer = parse_inbox_uri(&uri);
338    if let Some(ref p) = inbox_peer
339        && p.starts_with("__invalid__")
340    {
341        return error_response(
342            id,
343            -32602,
344            "subscribe URI must be wire://inbox/<peer> or wire://inbox/all",
345        );
346    }
347    if let Ok(mut g) = state.subscribed.lock() {
348        g.insert(uri);
349    }
350    json!({"jsonrpc": "2.0", "id": id, "result": {}})
351}
352
353fn handle_resources_unsubscribe(id: &Value, params: &Value, state: &McpState) -> Value {
354    let uri = match params.get("uri").and_then(Value::as_str) {
355        Some(u) => u.to_string(),
356        None => return error_response(id, -32602, "missing 'uri'"),
357    };
358    if let Ok(mut g) = state.subscribed.lock() {
359        g.remove(&uri);
360    }
361    json!({"jsonrpc": "2.0", "id": id, "result": {}})
362}
363
364fn handle_resources_read(id: &Value, params: &Value) -> Value {
365    let uri = match params.get("uri").and_then(Value::as_str) {
366        Some(u) => u,
367        None => return error_response(id, -32602, "missing 'uri'"),
368    };
369    let peer_opt = parse_inbox_uri(uri);
370    match read_inbox_resource(peer_opt) {
371        Ok(payload) => json!({
372            "jsonrpc": "2.0",
373            "id": id,
374            "result": {
375                "contents": [{
376                    "uri": uri,
377                    "mimeType": "application/x-ndjson",
378                    "text": payload,
379                }]
380            }
381        }),
382        Err(e) => error_response(id, -32603, &e.to_string()),
383    }
384}
385
386/// Parse `wire://inbox/<peer>` → Some(peer). `wire://inbox/all` → None.
387/// Anything else → returns a marker that triggers "unknown URI" on read.
388fn parse_inbox_uri(uri: &str) -> Option<String> {
389    if let Some(rest) = uri.strip_prefix("wire://inbox/") {
390        if rest == "all" {
391            return None;
392        }
393        if !rest.is_empty() {
394            return Some(rest.to_string());
395        }
396    }
397    Some(format!("__invalid__{uri}"))
398}
399
400fn read_inbox_resource(peer_opt: Option<String>) -> Result<String, String> {
401    const LIMIT: usize = 50;
402    // Validate URI shape FIRST — an invalid URI is an error regardless of
403    // whether the inbox dir exists yet.
404    if let Some(ref p) = peer_opt
405        && p.starts_with("__invalid__")
406    {
407        return Err(
408            "unknown resource URI (must be wire://inbox/<peer> or wire://inbox/all)".into(),
409        );
410    }
411    let inbox = crate::config::inbox_dir().map_err(|e| e.to_string())?;
412    if !inbox.exists() {
413        return Ok(String::new());
414    }
415    let trust = crate::config::read_trust().map_err(|e| e.to_string())?;
416
417    let paths: Vec<std::path::PathBuf> = match peer_opt {
418        Some(p) => {
419            let path = inbox.join(format!("{p}.jsonl"));
420            if !path.exists() {
421                return Ok(String::new());
422            }
423            vec![path]
424        }
425        None => std::fs::read_dir(&inbox)
426            .map_err(|e| e.to_string())?
427            .flatten()
428            .map(|e| e.path())
429            .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
430            .collect(),
431    };
432
433    let mut events: Vec<(String, bool, Value)> = Vec::new();
434    for path in paths {
435        let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
436        let peer = path
437            .file_stem()
438            .and_then(|s| s.to_str())
439            .unwrap_or("")
440            .to_string();
441        for line in body.lines() {
442            let event: Value = match serde_json::from_str(line) {
443                Ok(v) => v,
444                Err(_) => continue,
445            };
446            let verified = crate::signing::verify_message_v31(&event, &trust).is_ok();
447            events.push((peer.clone(), verified, event));
448        }
449    }
450    // Newest last (JSONL append order is chronological); take tail LIMIT.
451    let take_from = events.len().saturating_sub(LIMIT);
452    let tail = &events[take_from..];
453
454    // D1: our seed, to decrypt enc-bearing bodies for the agent reading this
455    // resource. The on-disk JSONL stays verbatim ciphertext; only the response
456    // body is decrypted (and a `dec: true` flag marks it).
457    let seed: Option<[u8; 32]> = crate::config::read_private_key()
458        .ok()
459        .and_then(|v| v.get(..32).and_then(|s| <[u8; 32]>::try_from(s).ok()));
460
461    let mut out = String::new();
462    for (_peer, verified, mut event) in tail.iter().cloned() {
463        // Decrypt for agent consumption (verify-gated inside open_event_body).
464        if event.get("enc").and_then(Value::as_str)
465            == Some(crate::enc::wire_x25519::ENC_DISCRIMINATOR)
466            && let Some(ref s) = seed
467            && let Ok(Some(plain)) = crate::enc::wire_x25519::open_event_body(&event, &trust, s)
468            && let Some(obj) = event.as_object_mut()
469        {
470            obj.insert("body".into(), plain);
471            obj.insert("dec".into(), json!(true));
472        }
473        if let Some(obj) = event.as_object_mut() {
474            obj.insert("verified".into(), json!(verified));
475        }
476        out.push_str(&serde_json::to_string(&event).map_err(|e| e.to_string())?);
477        out.push('\n');
478    }
479    Ok(out)
480}
481
482fn handle_initialize(id: &Value) -> Value {
483    json!({
484        "jsonrpc": "2.0",
485        "id": id,
486        "result": {
487            "protocolVersion": PROTOCOL_VERSION,
488            "capabilities": {
489                "tools": {"listChanged": false},
490                "resources": {
491                    "listChanged": false,
492                    // Goal 2.1 (v0.2.1): subscribe shipped. A background watcher
493                    // thread polls the inbox every 2s and pushes
494                    // notifications/resources/updated via a writer-thread channel
495                    // for any subscribed URI.
496                    "subscribe": true
497                }
498            },
499            "serverInfo": {
500                "name": SERVER_NAME,
501                "version": SERVER_VERSION,
502            },
503            "instructions": "wire — agent-to-agent comms. Canonical verbs (v0.9+): wire_dial (initiate pair), wire_send (talk; auto-pairs on miss), wire_pending (see inbound requests), wire_accept (consent to a pending pair), wire_reject (refuse), wire_whois (inspect identity), wire_status (daemon + sync health). For cross-machine pairing pass `<handle>@<relay>` to wire_dial. Bilateral: the peer must also accept (wire_accept or wire_dial back) before capability flows in both directions. INBOUND pair requests from strangers land in pending-inbound: call wire_pending to enumerate, surface to operator, then wire_accept or wire_reject. NEVER auto-accept inbound pair requests without operator consent — accepting grants the peer authenticated write access to this agent's inbox. Resources: 'wire://inbox/<peer>' exposes each pinned peer's verified inbox (JSONL). ON SESSION START, DO THIS IMMEDIATELY (every wire session): (1) call wire_status to confirm the sync loop is healthy — `daemon_running:true` + `last_sync_age_seconds < 60`. The daemon is auto-started for you when this MCP server launches, so this is normally already true; if daemon_running is false, run `wire up` (or `wire service install` for a reboot-durable daemon). (2) arm a PERSISTENT stream-watcher on `wire monitor --json` — in Claude Code, the Monitor tool with persistent:true — so peer messages surface live mid-session. The monitor does NOT sync the relay; it only tails the inbox the daemon writes. Both are required. WHEN A PEER MESSAGE ARRIVES, reply to it in your own live context WITHOUT waiting for the operator to prompt you; this is how paired agents collaborate immediately. (Default filter strips pair_drop/pair_drop_ack/heartbeat noise.) v0.14.2: wire_send POSTs synchronously by default — response `status` is the actual relay verdict: `delivered` (event landed on peer's slot), `duplicate` (same event_id already on slot; peer can still pull), `peer_unknown` (peer not pinned — run wire_dial first), `slot_stale` (peer's slot rotated — run wire_dial to re-pair), or `transport_error` (TLS/DNS/relay-5xx; check `reason` field). Pass `queue:true` to opt back into the legacy outbox→daemon-push path for offline-buffer / pre-pair queueing. wire_pull is the symmetric receive primitive — call it to trigger an immediate relay GET instead of waiting for the daemon's 5s pull cycle; returns written[]/rejected[]/total_seen the same way `wire pull --json` does. See docs/AGENT_INTEGRATION.md for the full monitor recipe and THREAT_MODEL.md (T10/T14)."
504        }
505    })
506}
507
508fn handle_tools_list(id: &Value) -> Value {
509    json!({
510        "jsonrpc": "2.0",
511        "id": id,
512        "result": {
513            "tools": tool_defs(),
514        }
515    })
516}
517
518fn tool_defs() -> Vec<Value> {
519    vec![
520        json!({
521            "name": "wire_whoami",
522            "description": "Return this agent's DID, fingerprint, key_id, public key, and capabilities. Read-only.",
523            "inputSchema": {"type": "object", "properties": {}, "required": []}
524        }),
525        json!({
526            "name": "wire_peers",
527            "description": "List pinned peers with their tier (UNTRUSTED/VERIFIED/ATTESTED) and advertised capabilities. Read-only.",
528            "inputSchema": {"type": "object", "properties": {}, "required": []}
529        }),
530        json!({
531            "name": "wire_here",
532            "description": "\"Who am I and who can I talk to?\" — the cold-start orientation tool. Returns {self: {handle, did, persona, cwd, wire_home}, sister_sessions: [...], pinned_peers: [...]}. Sister sessions are other agents on THIS machine you can reach with wire_dial by their `session` name (no relay round-trip); pinned_peers are already-paired contacts. Call this first when wire_peers is empty and you need to find a dial target. Read-only.",
533            "inputSchema": {"type": "object", "properties": {}, "required": []}
534        }),
535        json!({
536            "name": "wire_status",
537            "description": "v0.14.2 — daemon + sync-loop health check. Returns: daemon_running (pidfile pid alive), all_running_pids (pgrep for `wire daemon`), last_sync_age_seconds (age of the most recent successful daemon cycle; null if no cycle ever recorded), outbox_count, inbox_count, peer count. The daemon is auto-started for you on MCP launch; a healthy session shows daemon_running:true + last_sync_age_seconds < 60. Default `wire_send` is synchronous (its own status is the delivery verdict); only `queue:true` sends depend on the daemon to drain — a nonzero outbox_count with a stale last_sync means those are stuck. Read-only.",
538            "inputSchema": {"type": "object", "properties": {}, "required": []}
539        }),
540        json!({
541            "name": "wire_send",
542            "description": "Sign and send an event to a peer. Synchronous by default (v0.14.2): the response `status` is the actual relay verdict — `delivered`, `duplicate`, `peer_unknown` (run wire_dial first), `slot_stale` (run wire_dial to re-pair), or `transport_error` (see `reason`). Pass `queue:true` to opt into the legacy outbox→daemon-push path (offline buffer / pre-pair). Returns event_id (SHA-256 of canonical body — content-addressed, so identical bodies dedupe). Body may be plain text or JSON. Concurrent sends to different peers are safe; same-peer sends serialize via a per-path lock.",
543            "inputSchema": {
544                "type": "object",
545                "properties": {
546                    "peer": {"type": "string", "description": "Peer handle (without did:wire: prefix). Must be a pinned peer; check wire_peers first."},
547                    "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."},
548                    "body": {"type": "string", "description": "Event body. Plain text becomes a JSON string; valid JSON is parsed and embedded structurally."},
549                    "time_sensitive_until": {"type": "string", "description": "Optional advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp."}
550                },
551                "required": ["peer", "kind", "body"]
552            }
553        }),
554        json!({
555            "name": "wire_pull",
556            "description": "v0.14.2: trigger an immediate, synchronous pull from this agent's relay slot(s). Returns the same shape as `wire pull --json`: written[] (events landed in inbox), rejected[] (failed signature / cursor verify / dedupe), total_seen, cursor_blocked, endpoints_pulled. **Use this when you want events NOW** instead of waiting for the daemon's 5s pull cycle. Symmetric to wire_send's sync POST. Read-only — only consults the relay's GET, no mutations beyond writing inbox.jsonl + advancing per-slot cursors. Idempotent: re-pulling with the same cursor returns nothing new.",
557            "inputSchema": {"type": "object", "properties": {}, "required": []}
558        }),
559        json!({
560            "name": "wire_tail",
561            "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. **Orientation (wire #79):** defaults to NEWEST-N (last `limit` events across all matched peers, sorted chronologically by timestamp). Pass `oldest: true` for FIFO behaviour (first-N, for inbox replay from the start).",
562            "inputSchema": {
563                "type": "object",
564                "properties": {
565                    "peer": {"type": "string", "description": "Optional peer handle to filter inbox by."},
566                    "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Max events to return."},
567                    "oldest": {"type": "boolean", "default": false, "description": "Return the FIRST `limit` events (oldest-N) instead of the default last-N (newest-N)."}
568                },
569                "required": []
570            }
571        }),
572        json!({
573            "name": "wire_verify",
574            "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).",
575            "inputSchema": {
576                "type": "object",
577                "properties": {
578                    "event": {"type": "string", "description": "JSON-encoded signed event."}
579                },
580                "required": ["event"]
581            }
582        }),
583        json!({
584            "name": "wire_init",
585            "description": "Rarely needed — identity auto-bootstraps when this MCP server starts. Idempotent manual identity creation: already initialized → returns the existing identity (no-op); different handle → errors (delete config to re-key). The typed handle is vestigial under the one-name rule (your handle is DID-derived). If relay_url is passed and not yet bound, also allocates a relay slot.",
586            "inputSchema": {
587                "type": "object",
588                "properties": {
589                    "handle": {"type": "string", "description": "Short handle (becomes did:wire:<handle>). ASCII alphanumeric / '-' / '_' only."},
590                    "name": {"type": "string", "description": "Optional display name (defaults to capitalized handle)."},
591                    "relay_url": {"type": "string", "description": "Optional relay URL — if set, also binds a relay slot."}
592                },
593                "required": ["handle"]
594            }
595        }),
596        json!({
597            "name": "wire_invite_mint",
598            "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}.",
599            "inputSchema": {
600                "type": "object",
601                "properties": {
602                    "relay_url": {"type": "string", "description": "Override relay for first-time auto-allocate."},
603                    "ttl_secs": {"type": "integer", "description": "Invite lifetime in seconds (default 86400)."},
604                    "uses": {"type": "integer", "description": "Number of distinct peers that can accept before consumption (default 1)."}
605                }
606            }
607        }),
608        json!({
609            "name": "wire_invite_accept",
610            "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}.",
611            "inputSchema": {
612                "type": "object",
613                "properties": {
614                    "url": {"type": "string", "description": "Full wire://pair?v=1&inv=... URL."}
615                },
616                "required": ["url"]
617            }
618        }),
619        // v0.5 — agentic hotline.
620        json!({
621            "name": "wire_add",
622            "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 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_accept` or `wire_reject` instead.",
623            "inputSchema": {
624                "type": "object",
625                "properties": {
626                    "handle": {"type": "string", "description": "Peer handle like `nick@domain`."},
627                    "relay_url": {"type": "string", "description": "Override resolver URL (default: `https://<domain>`)."}
628                },
629                "required": ["handle"]
630            }
631        }),
632        // v0.10.1: canonical MCP names mirroring the operator-facing
633        // verbs (wire dial / accept / reject / pending). Deprecated aliases
634        // wire_pair_accept / wire_pair_reject / wire_pair_list_inbound were
635        // removed from the catalog in RFC-005 Phase 2; calls to those names
636        // now return a helpful redirect error (see dispatch).
637        json!({
638            "name": "wire_dial",
639            "description": "v0.8 — go talk to this name. Accepts a character nickname (`noble-slate`), session name, card handle, or DID — or a federation handle (`<handle>@<relay>`). Resolves through the local addressing layer (pinned peers, local sister sessions) or routes federation via `.well-known/wire/agent`. Drives the right pair flow (already-pinned: no-op, local sister: disk-read --local-sister, federation: pair_drop). After this completes the peer is in `wire_peers` and `wire_send` to them works.",
640            "inputSchema": {
641                "type": "object",
642                "properties": {
643                    "name": {"type": "string", "description": "Peer name — character nickname / session / handle / DID / `<handle>@<relay>`."}
644                },
645                "required": ["name"]
646            }
647        }),
648        json!({
649            "name": "wire_accept",
650            "description": "v0.9 — accept a pending-inbound pair request by character nickname or handle. Replaces deprecated wire_pair_accept. Pins the peer VERIFIED, ships our slot_token via pair_drop_ack, and deletes the pending record. Requires explicit operator consent — surface the request to the user before calling.",
651            "inputSchema": {
652                "type": "object",
653                "properties": {
654                    "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle, from wire_pending)."}
655                },
656                "required": ["peer"]
657            }
658        }),
659        json!({
660            "name": "wire_reject",
661            "description": "v0.9 — refuse a pending-inbound pair request without pairing. Replaces deprecated wire_pair_reject. Idempotent: succeeds with `rejected: false` if no record existed for that peer.",
662            "inputSchema": {
663                "type": "object",
664                "properties": {
665                    "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle)."}
666                },
667                "required": ["peer"]
668            }
669        }),
670        json!({
671            "name": "wire_pending",
672            "description": "v0.9 — list pending-inbound pair requests waiting for operator consent. Returns the same flat array as legacy wire_pair_list_inbound. Use on session start (or in response to a `wire — pair request from X` OS toast) to surface inbound requests for accept/reject decisions.",
673            "inputSchema": {"type": "object", "properties": {}}
674        }),
675        json!({
676            "name": "wire_claim",
677            "description": "Publish this agent in a relay's handle directory so others can reach it by `<persona>@<relay-domain>`. ONE-NAME RULE: the claimed handle is ALWAYS your DID-derived persona — you do not choose it. The `nick` arg is optional + advisory; a value that differs from your persona is ignored (response sets typed_nick_ignored=true). Auto-inits + auto-allocates a relay slot if needed. FCFS — same-DID re-claims allowed (used for profile/slot updates).",
678            "inputSchema": {
679                "type": "object",
680                "properties": {
681                    "nick": {"type": "string", "description": "Optional + advisory. Ignored if it differs from your DID-derived persona (one-name rule)."},
682                    "relay_url": {"type": "string", "description": "Relay to claim on. Default = our relay."},
683                    "public_url": {"type": "string", "description": "Public URL the relay should advertise to resolvers."}
684                }
685            }
686        }),
687        json!({
688            "name": "wire_whois",
689            "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.",
690            "inputSchema": {
691                "type": "object",
692                "properties": {
693                    "handle": {"type": "string", "description": "Optional `nick@domain`. Omit for self."},
694                    "relay_url": {"type": "string", "description": "Override resolver URL."}
695                }
696            }
697        }),
698        json!({
699            "name": "wire_profile_set",
700            "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.",
701            "inputSchema": {
702                "type": "object",
703                "properties": {
704                    "field": {"type": "string", "description": "One of: display_name, emoji, motto, vibe, pronouns, avatar_url, handle, now."},
705                    "value": {"description": "String for most fields; array for vibe; object for now. Pass JSON null to clear a field."}
706                },
707                "required": ["field", "value"]
708            }
709        }),
710        json!({
711            "name": "wire_profile_get",
712            "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.",
713            "inputSchema": {"type": "object", "properties": {}}
714        }),
715        // ---- group chat (v0.13.4): a group is a shared relay-room slot; the
716        // creator-signed roster carries member keys so members verify each
717        // other without pairing. GroupTier (creator/member/introduced) is a
718        // SEPARATE axis from bilateral peer trust. ----
719        json!({
720            "name": "wire_group_create",
721            "description": "Create a group chat room (you become the creator). Allocates a shared relay slot whose token is the room key, signs the initial roster, and persists it locally. Returns {id, name, members, relay_url}. Use the returned id with the other wire_group_* tools.",
722            "inputSchema": {
723                "type": "object",
724                "properties": {"name": {"type": "string", "description": "Human label for the group."}},
725                "required": ["name"]
726            }
727        }),
728        json!({
729            "name": "wire_group_add",
730            "description": "Add a bilaterally-VERIFIED pinned peer to a group you created, as a Member. The peer must already be paired + VERIFIED (check wire_peers). Re-signs the roster and queues a signed group_invite to every member (run a normal push/let the daemon deliver). Creator-only.",
731            "inputSchema": {
732                "type": "object",
733                "properties": {
734                    "group": {"type": "string", "description": "Group id or name."},
735                    "peer": {"type": "string", "description": "Handle of a VERIFIED pinned peer."}
736                },
737                "required": ["group", "peer"]
738            }
739        }),
740        json!({
741            "name": "wire_group_send",
742            "description": "Post a message to a group room (one signed event to the shared slot; every member reads it). You must have the group locally (created it, were added, or joined by code).",
743            "inputSchema": {
744                "type": "object",
745                "properties": {
746                    "group": {"type": "string", "description": "Group id or name."},
747                    "message": {"type": "string", "description": "Message text."}
748                },
749                "required": ["group", "message"]
750            }
751        }),
752        json!({
753            "name": "wire_group_tail",
754            "description": "Read recent messages from a group room. Each message has a 'verified' bool (signature checked against the roster + room-announced joiner keys). Also surfaces join notices. Pulls the shared room slot.",
755            "inputSchema": {
756                "type": "object",
757                "properties": {
758                    "group": {"type": "string", "description": "Group id or name."},
759                    "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 20, "description": "Max timeline entries to return."}
760                },
761                "required": ["group"]
762            }
763        }),
764        json!({
765            "name": "wire_group_list",
766            "description": "List the groups this agent is in, with each group's members and their GroupTiers (creator/member/introduced). Read-only, local.",
767            "inputSchema": {"type": "object", "properties": {}, "required": []}
768        }),
769        json!({
770            "name": "wire_group_invite",
771            "description": "Mint a shareable join code for a group — a self-contained token (room coords + signed roster). Anyone you give it to can wire_group_join to enter at Introduced tier. The code IS the room key; share only with people you want in the room.",
772            "inputSchema": {
773                "type": "object",
774                "properties": {"group": {"type": "string", "description": "Group id or name."}},
775                "required": ["group"]
776            }
777        }),
778        json!({
779            "name": "wire_group_join",
780            "description": "Join a group from a code minted by wire_group_invite. Materializes the room locally, pins existing members on the creator's vouch, and announces you to the room so members verify your messages. No prior pairing needed.",
781            "inputSchema": {
782                "type": "object",
783                "properties": {"code": {"type": "string", "description": "The `wire-group:` join code."}},
784                "required": ["code"]
785            }
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_status" => tool_status(),
803        "wire_peers" => tool_peers(),
804        "wire_here" => tool_here(),
805        "wire_send" => tool_send(&args),
806        "wire_pull" => tool_pull(),
807        "wire_tail" => tool_tail(&args),
808        "wire_verify" => tool_verify(&args),
809        "wire_init" => tool_init(&args),
810        "wire_invite_mint" => tool_invite_mint(&args),
811        "wire_invite_accept" => tool_invite_accept(&args),
812        // v0.5 — agentic hotline (handle + profile + zero-paste discovery).
813        "wire_add" => tool_add(&args),
814        // v0.5.14 — bilateral-required pair: inbound queue management.
815        // v0.10.1: canonical names introduced; v0.14.x (RFC-005 Phase 2):
816        // deprecated wire_pair_* alias surface removed from tools/list.
817        // Calls to the old names return a helpful redirect error.
818        "wire_accept" => tool_pair_accept(&args),
819        "wire_reject" => tool_pair_reject(&args),
820        "wire_pending" => tool_pair_list_inbound(),
821        "wire_pair_accept" => Err("wire_pair_accept was renamed to wire_accept (v0.9+). \
822             Use wire_accept instead."
823            .into()),
824        "wire_pair_reject" => Err("wire_pair_reject was renamed to wire_reject (v0.9+). \
825             Use wire_reject instead."
826            .into()),
827        "wire_pair_list_inbound" => Err(
828            "wire_pair_list_inbound was renamed to wire_pending (v0.9+). \
829             Use wire_pending instead."
830                .into(),
831        ),
832        "wire_dial" => tool_dial(&args),
833        "wire_claim" => tool_claim_handle(&args),
834        "wire_whois" => tool_whois(&args),
835        "wire_profile_set" => tool_profile_set(&args),
836        "wire_profile_get" => tool_profile_get(),
837        // v0.13.4 — group chat (shared-room slot + introduce-on-vouch).
838        "wire_group_create" => tool_group_create(&args),
839        "wire_group_add" => tool_group_add(&args),
840        "wire_group_send" => tool_group_send(&args),
841        "wire_group_tail" => tool_group_tail(&args),
842        "wire_group_list" => tool_group_list(),
843        "wire_group_invite" => tool_group_invite(&args),
844        "wire_group_join" => tool_group_join(&args),
845        // Legacy alias kept for older agent prompts that reference `wire_join`.
846        // The SAS code-phrase pair flow it pointed at is gone — redirect to the
847        // canonical handle-dial path.
848        "wire_join" => Err("wire_join (SAS code-phrase pairing) was removed. \
849             Use wire_dial(\"<handle>@<relay>\") to pair by handle. \
850             See docs/AGENT_INTEGRATION.md."
851            .into()),
852        other => Err(format!("unknown tool: {other}")),
853    };
854
855    match result {
856        Ok(value) => json!({
857            "jsonrpc": "2.0",
858            "id": id,
859            "result": {
860                "content": [{
861                    "type": "text",
862                    "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
863                }],
864                "isError": false
865            }
866        }),
867        Err(message) => json!({
868            "jsonrpc": "2.0",
869            "id": id,
870            "result": {
871                "content": [{"type": "text", "text": message}],
872                "isError": true
873            }
874        }),
875    }
876}
877
878// ---------- tool implementations ----------
879
880fn tool_whoami() -> Result<Value, String> {
881    use crate::config;
882    use crate::signing::{b64decode, fingerprint, make_key_id};
883
884    if !config::is_initialized().map_err(|e| e.to_string())? {
885        return Err("not initialized — operator must run `wire up` first".into());
886    }
887    let card = config::read_agent_card().map_err(|e| e.to_string())?;
888    let did = card
889        .get("did")
890        .and_then(Value::as_str)
891        .unwrap_or("")
892        .to_string();
893    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
894    let pk_b64 = card
895        .get("verify_keys")
896        .and_then(Value::as_object)
897        .and_then(|m| m.values().next())
898        .and_then(|v| v.get("key"))
899        .and_then(Value::as_str)
900        .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
901    let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
902    let fp = fingerprint(&pk_bytes);
903    let key_id = make_key_id(&handle, &pk_bytes);
904    let capabilities = card
905        .get("capabilities")
906        .cloned()
907        .unwrap_or_else(|| json!(["wire/v3.2"]));
908    // v0.12: surface the DID-derived persona (nickname + emoji + palette)
909    // that the CLI `wire whoami`/`here` already emit, so agents and toasts
910    // see the persona, not just the raw handle.
911    let persona =
912        serde_json::to_value(crate::character::Character::from_card(&card)).unwrap_or(Value::Null);
913    // v0.14: surface the RFC-001 op claims (op_did / op_pubkey / op_cert /
914    // org_memberships / schema_version) when enrolled, mirroring the CLI
915    // `wire whoami --json` shape. Same `op_claims_from_card` helper as
916    // CLI ⇒ MCP + CLI stay in lock-step as the inline set grows. Older
917    // cards / unenrolled ⇒ no extra keys (no JSON null-spam).
918    let mut payload = serde_json::Map::new();
919    payload.insert("did".into(), json!(did));
920    payload.insert("handle".into(), json!(handle));
921    payload.insert("persona".into(), persona);
922    payload.insert("fingerprint".into(), json!(fp));
923    payload.insert("key_id".into(), json!(key_id));
924    payload.insert("public_key_b64".into(), json!(pk_b64));
925    payload.insert("capabilities".into(), capabilities);
926    // RFC-008 §A: same `session_source` the CLI `wire whoami --json` emits —
927    // which signal won session/home resolution — so an agent diagnosing a
928    // wrong/shared identity over MCP sees the cause without shelling out.
929    payload.insert(
930        "session_source".into(),
931        json!(crate::session::session_source()),
932    );
933    for (k, v) in crate::cli::op_claims_from_card(&card) {
934        payload.insert(k, v);
935    }
936    Ok(Value::Object(payload))
937}
938
939fn tool_peers() -> Result<Value, String> {
940    use crate::config;
941
942    let trust = config::read_trust().map_err(|e| e.to_string())?;
943    let agents = trust
944        .get("agents")
945        .and_then(Value::as_object)
946        .cloned()
947        .unwrap_or_default();
948    // v0.14.3 (coral dogfood 2026-06-01): use effective tier so the
949    // MCP surface matches the CLI ones (wire status / wire peers /
950    // wire here all switched to effective_tier in #199 + #201).
951    // Pre-fix, agents calling wire_peers via MCP got raw
952    // trust-promoted VERIFIED even when the bilateral handshake
953    // never delivered the slot credentials → daemon can't push but
954    // agent thought it could.
955    let relay_state =
956        config::read_relay_state().unwrap_or_else(|_| json!({"self": null, "peers": {}}));
957    let mut self_did: Option<String> = None;
958    if let Ok(card) = config::read_agent_card() {
959        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
960    }
961    let mut peers = Vec::new();
962    for (handle, agent) in agents.iter() {
963        let did = agent
964            .get("did")
965            .and_then(Value::as_str)
966            .unwrap_or("")
967            .to_string();
968        if Some(did.as_str()) == self_did.as_deref() {
969            continue;
970        }
971        // v0.12: include the persona (respecting the peer's advertised
972        // override when their card carries one, else DID-derived) so MCP
973        // callers render the nickname/emoji instead of the raw handle.
974        let persona = match agent.get("card") {
975            Some(c) => crate::character::Character::from_card(c),
976            None => crate::character::Character::from_did(&did),
977        };
978        // v0.14: surface peer's inline op claims (when their pinned card
979        // carries them) so paired agents see ORG_VERIFIED-source membership
980        // without reading trust.json directly. Identical shape to the CLI
981        // `wire peers --json` row; older peers ⇒ no extra keys.
982        let peer_op_claims = agent
983            .get("card")
984            .map(crate::cli::op_claims_from_card)
985            .unwrap_or_default();
986        let mut row = serde_json::Map::new();
987        row.insert("handle".into(), json!(handle));
988        row.insert(
989            "persona".into(),
990            serde_json::to_value(&persona).unwrap_or(Value::Null),
991        );
992        row.insert("did".into(), json!(did));
993        row.insert(
994            "tier".into(),
995            json!(crate::trust::effective_tier(&trust, &relay_state, handle)),
996        );
997        row.insert(
998            "capabilities".into(),
999            agent
1000                .get("card")
1001                .and_then(|c| c.get("capabilities"))
1002                .cloned()
1003                .unwrap_or_else(|| json!([])),
1004        );
1005        for (k, v) in peer_op_claims {
1006            row.insert(k, v);
1007        }
1008        peers.push(Value::Object(row));
1009    }
1010    Ok(json!(peers))
1011}
1012
1013/// Run `wire group <args> --json` by spawning this same binary, inheriting the
1014/// MCP session's WIRE_* env so it resolves the same identity/home. Group ops are
1015/// infrequent, so this reuses the exact, tested CLI logic — including the
1016/// verification-sensitive invite/join paths — rather than duplicating it here.
1017fn group_cli_json(args: &[&str]) -> Result<Value, String> {
1018    let exe = std::env::current_exe().map_err(|e| format!("locating wire binary: {e}"))?;
1019    let out = std::process::Command::new(exe)
1020        .arg("group")
1021        .args(args)
1022        .arg("--json")
1023        .env("WIRE_QUIET_AUTOSESSION", "1") // suppress the adopt-session stderr line
1024        .output()
1025        .map_err(|e| format!("spawning `wire group`: {e}"))?;
1026    if !out.status.success() {
1027        let err = String::from_utf8_lossy(&out.stderr);
1028        return Err(err.trim().to_string());
1029    }
1030    let s = String::from_utf8_lossy(&out.stdout);
1031    // Last JSON object line is the result (any adopt chatter went to stderr).
1032    let line = s
1033        .lines()
1034        .rev()
1035        .find(|l| l.trim_start().starts_with('{'))
1036        .unwrap_or("{}");
1037    serde_json::from_str(line).map_err(|e| format!("parsing `wire group` output: {e}"))
1038}
1039
1040fn tool_group_create(args: &Value) -> Result<Value, String> {
1041    let name = args
1042        .get("name")
1043        .and_then(Value::as_str)
1044        .ok_or("missing 'name'")?;
1045    group_cli_json(&["create", name])
1046}
1047
1048fn tool_group_add(args: &Value) -> Result<Value, String> {
1049    let group = args
1050        .get("group")
1051        .and_then(Value::as_str)
1052        .ok_or("missing 'group'")?;
1053    let peer = args
1054        .get("peer")
1055        .and_then(Value::as_str)
1056        .ok_or("missing 'peer'")?;
1057    group_cli_json(&["add", group, peer])
1058}
1059
1060fn tool_group_send(args: &Value) -> Result<Value, String> {
1061    let group = args
1062        .get("group")
1063        .and_then(Value::as_str)
1064        .ok_or("missing 'group'")?;
1065    let message = args
1066        .get("message")
1067        .and_then(Value::as_str)
1068        .ok_or("missing 'message'")?;
1069    group_cli_json(&["send", group, message])
1070}
1071
1072fn tool_group_tail(args: &Value) -> Result<Value, String> {
1073    let group = args
1074        .get("group")
1075        .and_then(Value::as_str)
1076        .ok_or("missing 'group'")?;
1077    if let Some(n) = args.get("limit").and_then(Value::as_u64) {
1078        group_cli_json(&["tail", group, "--limit", &n.to_string()])
1079    } else {
1080        group_cli_json(&["tail", group])
1081    }
1082}
1083
1084fn tool_group_list() -> Result<Value, String> {
1085    group_cli_json(&["list"])
1086}
1087
1088fn tool_group_invite(args: &Value) -> Result<Value, String> {
1089    let group = args
1090        .get("group")
1091        .and_then(Value::as_str)
1092        .ok_or("missing 'group'")?;
1093    group_cli_json(&["invite", group])
1094}
1095
1096fn tool_group_join(args: &Value) -> Result<Value, String> {
1097    let code = args
1098        .get("code")
1099        .and_then(Value::as_str)
1100        .ok_or("missing 'code'")?;
1101    group_cli_json(&["join", code])
1102}
1103
1104/// v0.14.2 (#162): daemon + sync-loop health check, MCP-side mirror of
1105/// `wire status`. Specifically engineered to answer the silent-send
1106/// question — "if I call wire_send right now, will the daemon actually
1107/// push it?". Returns the daemon-liveness section + last-sync metadata +
1108/// outbox/inbox depth so callers can branch on a stale or absent sync.
1109///
1110/// Read-only. No initialization gate — runs against an empty home
1111/// (returns `initialized:false` shape mirroring wire_whoami's
1112/// degraded-uninit path from #152).
1113fn tool_status() -> Result<Value, String> {
1114    use crate::config;
1115
1116    let initialized = config::is_initialized().unwrap_or(false);
1117    if !initialized {
1118        return Ok(json!({
1119            "initialized": false,
1120            "daemon_running": false,
1121            "last_sync_age_seconds": Value::Null,
1122        }));
1123    }
1124
1125    let snap = crate::ensure_up::daemon_liveness();
1126    let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1127    let last_sync_record = crate::ensure_up::read_last_sync_record();
1128
1129    let mut daemon = json!({
1130        "running": snap.pidfile_alive,
1131        "pid": snap.pidfile_pid,
1132        "all_running_pids": snap.pgrep_pids,
1133        "orphans": snap.orphan_pids,
1134    });
1135    if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
1136        daemon["version"] = json!(d.version);
1137        daemon["bin_path"] = json!(d.bin_path);
1138        daemon["did"] = json!(d.did);
1139        daemon["relay_url"] = json!(d.relay_url);
1140        daemon["started_at"] = json!(d.started_at);
1141    }
1142
1143    let (last_sync_at, last_sync_push_n, last_sync_pull_n, last_sync_rejected_n) =
1144        match last_sync_record {
1145            Some(rec) => (
1146                Some(rec.ts),
1147                Some(rec.push_n),
1148                Some(rec.pull_n),
1149                Some(rec.rejected_n),
1150            ),
1151            None => (None, None, None, None),
1152        };
1153
1154    let outbox_count = config::outbox_dir()
1155        .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1156        .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1157        .unwrap_or(0);
1158    let inbox_count = config::inbox_dir()
1159        .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1160        .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1161        .unwrap_or(0);
1162
1163    // v0.14.2 (#162 fix #2): total events queued but not yet pushed.
1164    // `pending_push_count > 0` + `stale_sync == true` = the
1165    // silent-send class — events queued, daemon not pushing.
1166    // v0.14.3 (coral dogfood 2026-06-01): also surface a per-peer
1167    // breakdown so MCP-side agents (and the CLI both share the
1168    // same derivation) can see which peer is wedged + at what
1169    // trust tier without re-walking the outbox.
1170    let pending_push_breakdown = config::compute_pending_push_breakdown();
1171    let pending_push_count: u64 = pending_push_breakdown.iter().map(|p| p.count).sum();
1172
1173    // v0.14.2 (#162 fix #7): SSE stream-subscriber state so callers
1174    // can distinguish "stream alive (live monitor will fire on
1175    // inbound)" from "polling-only (daemon up, monitor will wait
1176    // until next poll cycle)". Best-effort read; missing file is
1177    // Value::Null (unknown).
1178    let stream_state = config::read_stream_state();
1179
1180    Ok(json!({
1181        "initialized": true,
1182        "daemon": daemon,
1183        "daemon_running": snap.pidfile_alive,
1184        "last_sync_at": last_sync_at,
1185        "last_sync_age_seconds": last_sync_age,
1186        "last_sync_push_n": last_sync_push_n,
1187        "last_sync_pull_n": last_sync_pull_n,
1188        "last_sync_rejected_n": last_sync_rejected_n,
1189        "stale_sync": config::stale_sync(last_sync_age),
1190        "outbox_count": outbox_count,
1191        "inbox_count": inbox_count,
1192        "pending_push_count": pending_push_count,
1193        "pending_push_breakdown": pending_push_breakdown,
1194        "stream_state": stream_state,
1195    }))
1196}
1197
1198fn tool_send(args: &Value) -> Result<Value, String> {
1199    use crate::config;
1200    use crate::signing::{b64decode, sign_message_v31};
1201
1202    let peer = args
1203        .get("peer")
1204        .and_then(Value::as_str)
1205        .ok_or("missing 'peer'")?;
1206    let peer = crate::agent_card::bare_handle(peer);
1207    let kind = args
1208        .get("kind")
1209        .and_then(Value::as_str)
1210        .ok_or("missing 'kind'")?;
1211    let body = args
1212        .get("body")
1213        .and_then(Value::as_str)
1214        .ok_or("missing 'body'")?;
1215    let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
1216    // v0.14.2 (paul, 2026-06-01): opt back into the legacy outbox →
1217    // daemon-push pipeline. Default is synchronous POST so callers get
1218    // a real `delivered` / `duplicate` / `failed` verdict instead of
1219    // a `queued` lie. `queue: true` writes to outbox like pre-v0.14.2.
1220    let queue = args.get("queue").and_then(Value::as_bool).unwrap_or(false);
1221
1222    if !config::is_initialized().map_err(|e| e.to_string())? {
1223        return Err("not initialized — operator must run `wire up` first".into());
1224    }
1225    let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
1226    let card = config::read_agent_card().map_err(|e| e.to_string())?;
1227    let did = card
1228        .get("did")
1229        .and_then(Value::as_str)
1230        .unwrap_or("")
1231        .to_string();
1232    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1233    let pk_b64 = card
1234        .get("verify_keys")
1235        .and_then(Value::as_object)
1236        .and_then(|m| m.values().next())
1237        .and_then(|v| v.get("key"))
1238        .and_then(Value::as_str)
1239        .ok_or("agent-card missing verify_keys[*].key")?;
1240    let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1241
1242    // Body parses as JSON if possible, else stays a string.
1243    let body_value: Value =
1244        serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1245    let kind_id = parse_kind(kind);
1246
1247    let now = time::OffsetDateTime::now_utc()
1248        .format(&time::format_description::well_known::Rfc3339)
1249        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1250
1251    // v0.14.2 (#162 fix #4): canonicalize `to:` against the pinned
1252    // peer's full DID via the trust store. Bare-handle
1253    // `to:did:wire:<handle>` misses the long-fingerprint suffix
1254    // (`did:wire:sunlit-aurora-ec6f890d`) that pinned peers actually
1255    // publish — mismatch risks receiver rejection at canonical/cursor
1256    // verification. resolve_peer_did falls back to the bare form when
1257    // the peer isn't pinned yet (pre-pair queue best-effort).
1258    let trust_for_did = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
1259    let to_did = crate::trust::resolve_peer_did(&trust_for_did, peer);
1260    let mut event = json!({
1261        // Parity with the CLI send skeleton (review finding #4): carry
1262        // schema_version so enc-bearing MCP events pass the same schema gate.
1263        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
1264        "timestamp": now,
1265        "from": did,
1266        "to": to_did,
1267        "type": kind,
1268        "kind": kind_id,
1269        "body": body_value,
1270    });
1271    if let Some(deadline) = deadline {
1272        event["time_sensitive_until"] =
1273            json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1274    }
1275    // D1 (RFC-006): encrypt the body when the recipient is dh-capable. Binds the
1276    // event's own from/to; runs BEFORE signing. Plaintext for legacy peers.
1277    if let Some(peer_dh) = crate::enc::wire_x25519::peer_dh_pubkey(&trust_for_did, peer) {
1278        crate::enc::wire_x25519::seal_event_body(&mut event, &peer_dh, &sk_seed)
1279            .map_err(|e| e.to_string())?;
1280    }
1281    let signed =
1282        sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1283    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1284
1285    // v0.14.2 (paul, 2026-06-01): collapse send → outbox → push into
1286    // a synchronous POST by default. `queue: true` opts back into the
1287    // legacy outbox path for offline-buffer / batch / pre-pair queue
1288    // use cases.
1289    if !queue {
1290        let outcome = crate::send::attempt_deliver(peer, &signed).map_err(|e| e.to_string())?;
1291        let mut v = crate::send::delivery_json(&outcome, peer);
1292        // Carry the same daemon-health annotations the caller used to
1293        // get on the legacy `queued` response. With sync delivery
1294        // these are diagnostic-only (the verdict in `status` is the
1295        // authoritative answer), but they're cheap to compute and
1296        // existing consumers may key on them.
1297        let snap = crate::ensure_up::daemon_liveness();
1298        let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1299        if let Some(obj) = v.as_object_mut() {
1300            obj.insert("daemon_seen".into(), json!(snap.pidfile_alive));
1301            obj.insert("last_sync_age_seconds".into(), json!(last_sync_age));
1302            obj.insert(
1303                "stale_sync".into(),
1304                json!(config::stale_sync(last_sync_age)),
1305            );
1306        }
1307        return Ok(v);
1308    }
1309
1310    // Legacy --queue path. Outbox-write, daemon push loop drains.
1311    let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1312    let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1313    let snap = crate::ensure_up::daemon_liveness();
1314    let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1315    // Honesty check mirror of the CLI: if the peer is BOTH
1316    // unpinned in trust AND has no pending pair (outbound or
1317    // inbound), the queued event has nowhere to go and will sit
1318    // in outbox forever. Surface the warning as a structured
1319    // `warning` field so MCP-side agents can branch on it instead
1320    // of treating `status:"queued"` as success.
1321    let peer_pinned_in_trust = trust_for_did
1322        .get("agents")
1323        .and_then(Value::as_object)
1324        .map(|a| a.contains_key(peer))
1325        .unwrap_or(false);
1326    let peer_in_relay_state = config::read_relay_state()
1327        .ok()
1328        .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
1329        .map(|peers| peers.contains_key(peer))
1330        .unwrap_or(false);
1331    let pending_inbound = crate::pending_inbound_pair::list_pending_inbound()
1332        .ok()
1333        .map(|v| v.iter().any(|p| p.peer_handle == peer))
1334        .unwrap_or(false);
1335    let unpushable = !peer_pinned_in_trust && !peer_in_relay_state && !pending_inbound;
1336    let mut out = json!({
1337        "event_id": event_id,
1338        "status": "queued",
1339        "peer": peer,
1340        "outbox": outbox.to_string_lossy(),
1341        "daemon_seen": snap.pidfile_alive,
1342        "last_sync_age_seconds": last_sync_age,
1343        "stale_sync": config::stale_sync(last_sync_age),
1344    });
1345    if unpushable {
1346        out["warning"] = json!(format!(
1347            "`{peer}` is not pinned and has no pending pair — the event will sit in outbox forever unless you pair first (wire_dial)."
1348        ));
1349    }
1350    Ok(out)
1351}
1352
1353/// v0.14.2 (paul, post-#187): symmetric receive primitive. `wire_send`
1354/// became sync in #187; `wire_pull` is the mirror — trigger an
1355/// immediate relay GET on this agent's slot(s), write new events to
1356/// inbox, advance per-slot cursors, return the verdict. Thin wrapper
1357/// over `cli::run_sync_pull`; same code path the daemon's 5s pull
1358/// loop uses.
1359fn tool_pull() -> Result<Value, String> {
1360    crate::cli::run_sync_pull().map_err(|e| format!("{e:#}"))
1361}
1362
1363fn tool_tail(args: &Value) -> Result<Value, String> {
1364    use crate::config;
1365    use crate::signing::verify_message_v31;
1366
1367    let peer_filter = args.get("peer").and_then(Value::as_str);
1368    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1369    // wire #79: orientation parity with `wire tail` CLI — default newest-N,
1370    // `oldest=true` opts back into FIFO. Agents almost always want the
1371    // freshest inbox slice when re-tailing an established peer, not the
1372    // wire-init handshake noise.
1373    let oldest = args.get("oldest").and_then(Value::as_bool).unwrap_or(false);
1374    let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1375    if !inbox.exists() {
1376        return Ok(json!([]));
1377    }
1378    let trust = config::read_trust().map_err(|e| e.to_string())?;
1379    let seed = crate::enc::wire_x25519::self_seed_for_read();
1380    let entries: Vec<_> = std::fs::read_dir(&inbox)
1381        .map_err(|e| e.to_string())?
1382        .filter_map(|e| e.ok())
1383        .map(|e| e.path())
1384        .filter(|p| {
1385            p.extension().map(|x| x == "jsonl").unwrap_or(false)
1386                && match peer_filter {
1387                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1388                    None => true,
1389                }
1390        })
1391        .collect();
1392
1393    // (timestamp, per-file line index, event with verified meta). Sort key
1394    // mirrors the CLI cmd_tail for cross-tool consistency.
1395    let mut collected: Vec<(String, usize, Value)> = Vec::new();
1396    for path in &entries {
1397        let body = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1398        for (idx, line) in body.lines().enumerate() {
1399            let event: Value = match serde_json::from_str(line) {
1400                Ok(v) => v,
1401                Err(_) => continue,
1402            };
1403            let verified = verify_message_v31(&event, &trust).is_ok();
1404            // D1: decrypt enc-bearing bodies for the agent (verify-gated).
1405            let mut event_with_meta = match &seed {
1406                Some(s) => crate::enc::wire_x25519::decrypt_event_for_read(&event, &trust, s),
1407                None => event.clone(),
1408            };
1409            if let Some(obj) = event_with_meta.as_object_mut() {
1410                obj.insert("verified".into(), json!(verified));
1411            }
1412            let ts = event
1413                .get("timestamp")
1414                .and_then(Value::as_str)
1415                .unwrap_or("")
1416                .to_string();
1417            collected.push((ts, idx, event_with_meta));
1418        }
1419    }
1420    collected.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
1421
1422    let total = collected.len();
1423    let window: Vec<Value> = if limit == 0 {
1424        collected.into_iter().map(|(_, _, e)| e).collect()
1425    } else if oldest {
1426        collected
1427            .into_iter()
1428            .take(limit)
1429            .map(|(_, _, e)| e)
1430            .collect()
1431    } else {
1432        let start = total.saturating_sub(limit);
1433        collected
1434            .into_iter()
1435            .skip(start)
1436            .map(|(_, _, e)| e)
1437            .collect()
1438    };
1439    Ok(Value::Array(window))
1440}
1441
1442fn tool_verify(args: &Value) -> Result<Value, String> {
1443    use crate::config;
1444    use crate::signing::verify_message_v31;
1445
1446    let event_str = args
1447        .get("event")
1448        .and_then(Value::as_str)
1449        .ok_or("missing 'event'")?;
1450    let event: Value =
1451        serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1452    let trust = config::read_trust().map_err(|e| e.to_string())?;
1453    match verify_message_v31(&event, &trust) {
1454        Ok(()) => Ok(json!({"verified": true})),
1455        Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1456    }
1457}
1458
1459// ---------- pairing tools ----------
1460
1461/// v0.13: bootstrap a freshly-resolved session-keyed identity. Runs once per
1462/// session home (gated on `is_initialized`); no-op under WIRE_MCP_SKIP_AUTO_UP.
1463/// init (one-name) + federation slot via `ensure_self_with_relay`, then a
1464/// best-effort phonebook claim of the DID-derived persona. Network failures
1465/// are swallowed — the identity is still created locally; the claim retries on
1466/// a later start.
1467fn ensure_session_bootstrapped() {
1468    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
1469        return;
1470    }
1471    if crate::config::is_initialized().unwrap_or(false) {
1472        return; // this session home already has an identity
1473    }
1474    let (did, relay_url, slot_id, slot_token) =
1475        match crate::pair_invite::ensure_self_with_relay(None) {
1476            Ok(t) => t,
1477            Err(_) => return, // offline / relay down — init may have happened locally; skip claim
1478        };
1479    if let Ok(card) = crate::config::read_agent_card() {
1480        let persona = crate::agent_card::display_handle_from_did(&did).to_string();
1481        let client = crate::relay_client::RelayClient::new(&relay_url);
1482        let _ = client.handle_claim_v2(&persona, &slot_id, &slot_token, None, &card, None);
1483    }
1484}
1485
1486fn tool_init(args: &Value) -> Result<Value, String> {
1487    let handle = args
1488        .get("handle")
1489        .and_then(Value::as_str)
1490        .ok_or("missing 'handle'")?;
1491    let name = args.get("name").and_then(Value::as_str);
1492    let relay = args.get("relay_url").and_then(Value::as_str);
1493    crate::init::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1494}
1495
1496// ---------- invite-URL one-paste pair (v0.4.0) ----------
1497
1498fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1499    let relay_url = args.get("relay_url").and_then(Value::as_str);
1500    let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1501    let uses = args
1502        .get("uses")
1503        .and_then(Value::as_u64)
1504        .map(|u| u as u32)
1505        .unwrap_or(1);
1506    let url =
1507        crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1508    let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1509    Ok(json!({
1510        "invite_url": url,
1511        "ttl_secs": ttl_resolved,
1512        "uses": uses,
1513    }))
1514}
1515
1516fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1517    let url = args
1518        .get("url")
1519        .and_then(Value::as_str)
1520        .ok_or("missing 'url'")?;
1521    crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1522}
1523
1524/// wire_here (MCP): the cold-agent orientation answer — self + same-machine
1525/// sister sessions + pinned peers. Mirrors `wire here --json` exactly (shares
1526/// `cli::comms::here_summary`), so an MCP-only agent with an empty wire_peers
1527/// can discover a dial target instead of dead-ending.
1528fn tool_here() -> Result<Value, String> {
1529    crate::cli::here_summary().map_err(|e| format!("{e:#}"))
1530}
1531
1532// ---------- v0.5 — agentic hotline tools ----------
1533
1534/// wire_dial (MCP): mirror the CLI `dial` resolution ladder. The prior
1535/// wiring routed straight to `tool_add`, which reads a required `handle`
1536/// arg — but the wire_dial schema only provides `name`, so every dial
1537/// errored `missing 'handle'`. This reads `name` and routes:
1538///   • `<nick>@<relay>`  -> federation pair (via tool_add).
1539///   • already-pinned     -> no-op success (peer already reachable).
1540///   • otherwise          -> honest error. Bare-nickname / local-sister
1541///     resolution over MCP is not yet wired (CLI `wire dial` does it);
1542///     use `<nick>@<relay>` or `wire_send` (auto-pairs on miss).
1543fn tool_dial(args: &Value) -> Result<Value, String> {
1544    let name = args
1545        .get("name")
1546        .and_then(Value::as_str)
1547        .or_else(|| args.get("handle").and_then(Value::as_str))
1548        .ok_or("missing 'name'")?;
1549
1550    if name.contains('@') {
1551        // Federation path. Present `name` as the `handle` tool_add expects.
1552        let mut a = args.clone();
1553        if let Some(obj) = a.as_object_mut() {
1554            obj.insert("handle".into(), Value::String(name.to_string()));
1555        }
1556        return tool_add(&a);
1557    }
1558
1559    // Bare nick: mirror the CLI `wire dial` resolution ladder via the shared
1560    // resolver — pinned peer (already reachable) or local sister (pair now).
1561    // Previously this dead-ended ("use wire_send, it auto-pairs") which is
1562    // circular — wire_send returns peer_unknown telling you to wire_dial.
1563    match crate::cli::resolve_name_to_target(name) {
1564        Ok(crate::cli::DialTarget::PinnedPeer {
1565            handle, did, tier, ..
1566        }) => Ok(json!({
1567            "name_input": name,
1568            "status": "already_pinned",
1569            "peer_handle": handle,
1570            "did": did,
1571            "tier": tier,
1572        })),
1573        Ok(crate::cli::DialTarget::LocalSister { session_name, .. }) => {
1574            let drop =
1575                crate::cli::add_local_sister_core(&session_name).map_err(|e| format!("{e:#}"))?;
1576            Ok(json!({
1577                "name_input": name,
1578                "status": "paired_local_sister",
1579                "peer_handle": drop.peer_handle,
1580                "paired_with": drop.paired_with_did,
1581                "event_id": drop.event_id,
1582                "delivered_via": drop.delivered_via,
1583            }))
1584        }
1585        // Unresolvable: surface the resolver's own did-you-mean message
1586        // (names pinned peers + sisters + the handle@relay federation form).
1587        Err(e) => Err(format!("{e:#}")),
1588    }
1589}
1590
1591fn tool_add(args: &Value) -> Result<Value, String> {
1592    let handle = args
1593        .get("handle")
1594        .and_then(Value::as_str)
1595        .ok_or("missing 'handle'")?;
1596    let relay_override = args.get("relay_url").and_then(Value::as_str);
1597
1598    let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1599
1600    // Ensure self has identity + relay slot (auto-inits if needed).
1601    let (our_did, our_relay, our_slot_id, our_slot_token) =
1602        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1603
1604    // Resolve peer via .well-known.
1605    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1606        .map_err(|e| format!("{e:#}"))?;
1607    let peer_card = resolved
1608        .get("card")
1609        .cloned()
1610        .ok_or("resolved missing card")?;
1611    let peer_did = resolved
1612        .get("did")
1613        .and_then(Value::as_str)
1614        .ok_or("resolved missing did")?
1615        .to_string();
1616    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1617    let peer_slot_id = resolved
1618        .get("slot_id")
1619        .and_then(Value::as_str)
1620        .ok_or("resolved missing slot_id")?
1621        .to_string();
1622    let peer_relay = resolved
1623        .get("relay_url")
1624        .and_then(Value::as_str)
1625        .map(str::to_string)
1626        .or_else(|| relay_override.map(str::to_string))
1627        .unwrap_or_else(|| format!("https://{}", parsed.domain));
1628
1629    // Pin peer in trust + relay-state. slot_token arrives via ack later.
1630    let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1631    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1632    crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1633    let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1634    let existing_token = relay_state
1635        .get("peers")
1636        .and_then(|p| p.get(&peer_handle))
1637        .and_then(|p| p.get("slot_token"))
1638        .and_then(Value::as_str)
1639        .map(str::to_string)
1640        .unwrap_or_default();
1641    // RFC-006 Part B: pin as an `endpoints[]` entry (single routing source).
1642    crate::endpoints::pin_peer_endpoints(
1643        &mut relay_state,
1644        &peer_handle,
1645        &[crate::endpoints::Endpoint::federation(
1646            peer_relay.clone(),
1647            peer_slot_id.clone(),
1648            existing_token.clone(),
1649        )],
1650    )
1651    .map_err(|e| format!("{e:#}"))?;
1652    crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1653
1654    // Build + sign pair_drop event (no nonce — open-mode handle pair).
1655    let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1656    let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1657    let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1658    let pk_b64 = our_card
1659        .get("verify_keys")
1660        .and_then(Value::as_object)
1661        .and_then(|m| m.values().next())
1662        .and_then(|v| v.get("key"))
1663        .and_then(Value::as_str)
1664        .ok_or("our card missing verify_keys[*].key")?;
1665    let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1666    let now = time::OffsetDateTime::now_utc()
1667        .format(&time::format_description::well_known::Rfc3339)
1668        .unwrap_or_default();
1669    let event = json!({
1670        "timestamp": now,
1671        "from": our_did,
1672        "to": peer_did,
1673        "type": "pair_drop",
1674        "kind": 1100u32,
1675        "body": {
1676            "card": our_card,
1677            "relay_url": our_relay,
1678            "slot_id": our_slot_id,
1679            "slot_token": our_slot_token,
1680        },
1681    });
1682    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
1683        .map_err(|e| format!("{e:#}"))?;
1684
1685    let client = crate::relay_client::RelayClient::new(&peer_relay);
1686    let resp = client
1687        .handle_intro(&parsed.nick, &signed)
1688        .map_err(|e| format!("{e:#}"))?;
1689    let event_id = signed
1690        .get("event_id")
1691        .and_then(Value::as_str)
1692        .unwrap_or("")
1693        .to_string();
1694    Ok(json!({
1695        "handle": handle,
1696        "paired_with": peer_did,
1697        "peer_handle": peer_handle,
1698        "event_id": event_id,
1699        "drop_response": resp,
1700        "status": "drop_sent",
1701    }))
1702}
1703
1704/// MCP `wire_accept` (v0.9+, formerly wire_pair_accept) — bilateral completion
1705/// of a pending-inbound pair request. The agent SHOULD have surfaced the
1706/// pending request to the operator before calling this; acceptance grants
1707/// peer authenticated write access to this agent's inbox.
1708fn tool_pair_accept(args: &Value) -> Result<Value, String> {
1709    let peer = args
1710        .get("peer")
1711        .and_then(Value::as_str)
1712        .ok_or("missing 'peer'")?;
1713    let nick = crate::agent_card::bare_handle(peer);
1714    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
1715        .map_err(|e| format!("{e:#}"))?
1716        .ok_or_else(|| {
1717            format!(
1718                "no pending pair request from {nick}. Call wire_pending to enumerate, \
1719                 or wire_add to send a fresh outbound pair request."
1720            )
1721        })?;
1722
1723    // Pin trust with VERIFIED — operator-equivalent consent gesture (the
1724    // agent is acting on the operator's instruction to accept).
1725    let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1726    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
1727    crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1728
1729    // Record peer's relay coords + slot_token from the stored drop.
1730    let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1731    relay_state["peers"][&pending.peer_handle] = json!({
1732        "relay_url": pending.peer_relay_url,
1733        "slot_id": pending.peer_slot_id,
1734        "slot_token": pending.peer_slot_token,
1735    });
1736    crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1737
1738    // Ship our slot_token via pair_drop_ack — Bug 2 fix: iterate the peer's
1739    // advertised endpoints in priority order, only fail if all are dead. The
1740    // pending record's `peer_endpoints` carries the full advertised list when
1741    // the pair_drop was written by a v0.5.17+ peer; fall back to a one-element
1742    // slice from the legacy triple for older records so we still hit the
1743    // failover helper with a valid input.
1744    let ack_endpoints: Vec<crate::endpoints::Endpoint> = if pending.peer_endpoints.is_empty() {
1745        vec![crate::endpoints::Endpoint::federation(
1746            pending.peer_relay_url.clone(),
1747            pending.peer_slot_id.clone(),
1748            pending.peer_slot_token.clone(),
1749        )]
1750    } else {
1751        pending.peer_endpoints.clone()
1752    };
1753    crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &ack_endpoints).map_err(|e| {
1754        format!(
1755            "pair_drop_ack send to {} (across {} endpoint(s)) failed: {e:#}",
1756            pending.peer_handle,
1757            ack_endpoints.len()
1758        )
1759    })?;
1760
1761    crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1762
1763    Ok(json!({
1764        "status": "bilateral_accepted",
1765        "peer_handle": pending.peer_handle,
1766        "peer_did": pending.peer_did,
1767        "peer_relay_url": pending.peer_relay_url,
1768        "via": "pending_inbound",
1769    }))
1770}
1771
1772/// MCP `wire_reject` (v0.9+, formerly wire_pair_reject) — delete a
1773/// pending-inbound record without pairing. Peer never receives our
1774/// slot_token. Idempotent.
1775fn tool_pair_reject(args: &Value) -> Result<Value, String> {
1776    let peer = args
1777        .get("peer")
1778        .and_then(Value::as_str)
1779        .ok_or("missing 'peer'")?;
1780    let nick = crate::agent_card::bare_handle(peer);
1781    let existed =
1782        crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1783    crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1784    Ok(json!({
1785        "peer": nick,
1786        "rejected": existed.is_some(),
1787        "had_pending": existed.is_some(),
1788    }))
1789}
1790
1791/// MCP `wire_pending` (v0.9+, formerly wire_pair_list_inbound) — enumerate
1792/// pending-inbound pair requests for operator review. Flat array sorted
1793/// oldest-first.
1794fn tool_pair_list_inbound() -> Result<Value, String> {
1795    let items =
1796        crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
1797    Ok(json!(items))
1798}
1799
1800fn tool_claim_handle(args: &Value) -> Result<Value, String> {
1801    let typed = args.get("nick").and_then(Value::as_str);
1802    let relay_override = args.get("relay_url").and_then(Value::as_str);
1803    let public_url = args.get("public_url").and_then(Value::as_str);
1804
1805    // Auto-init + ensure slot.
1806    let (_, our_relay, our_slot_id, our_slot_token) =
1807        crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1808    let claim_relay = relay_override.unwrap_or(&our_relay);
1809    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1810
1811    // One-name rule (v0.13.1): the claimed handle is ALWAYS the DID-derived
1812    // persona, so the phonebook entry can never drift from the agent-card
1813    // handle. `nick` is optional + advisory — a value that differs is ignored.
1814    // See cmd_claim for the rationale (closes the claim-path "two names" hole).
1815    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
1816    let canonical = crate::agent_card::display_handle_from_did(did).to_string();
1817    let nick = if canonical.is_empty() {
1818        typed.unwrap_or_default().to_string()
1819    } else {
1820        canonical
1821    };
1822    let typed_nick_ignored = typed.map(|t| t != nick).unwrap_or(false);
1823
1824    let client = crate::relay_client::RelayClient::new(claim_relay);
1825    let resp = client
1826        .handle_claim(&nick, &our_slot_id, &our_slot_token, public_url, &card)
1827        .map_err(|e| format!("{e:#}"))?;
1828    Ok(json!({
1829        "nick": nick,
1830        "relay": claim_relay,
1831        "response": resp,
1832        "one_name": true,
1833        "typed_nick_ignored": typed_nick_ignored,
1834    }))
1835}
1836
1837fn tool_whois(args: &Value) -> Result<Value, String> {
1838    if let Some(handle) = args.get("handle").and_then(Value::as_str) {
1839        // v0.14.x: mirror the CLI's resolution order. Bare nicks (no `@`)
1840        // route through the local resolver first (pinned peers + local
1841        // sister sessions); federation handles fall through to
1842        // `parse_handle` + remote resolution. Previously the MCP
1843        // surface only accepted federation-shaped handles and rejected
1844        // bare nicks with `missing '@' separator`, breaking
1845        // agent-side discovery of paired-but-not-federated peers.
1846        // Mirrors `cli::cmd_whois_local` for the local arms; mirrors
1847        // `cli::cmd_whois` for the federation arm.
1848        if !handle.contains('@')
1849            && let Ok(target) = crate::cli::resolve_name_to_target(handle)
1850        {
1851            return Ok(dial_target_to_whois_json(&target));
1852        }
1853        let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1854        let relay_override = args.get("relay_url").and_then(Value::as_str);
1855        crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
1856    } else {
1857        // Self. v0.14.x: surface inline op claims so MCP whois stays in
1858        // parity with `wire whoami --json` / CLI self-whois (#114 + #115
1859        // shared the same helper).
1860        let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1861        let mut payload = serde_json::Map::new();
1862        payload.insert(
1863            "did".into(),
1864            card.get("did").cloned().unwrap_or(Value::Null),
1865        );
1866        payload.insert(
1867            "profile".into(),
1868            card.get("profile").cloned().unwrap_or(Value::Null),
1869        );
1870        for (k, v) in crate::cli::op_claims_from_card(&card) {
1871            payload.insert(k, v);
1872        }
1873        Ok(Value::Object(payload))
1874    }
1875}
1876
1877/// Convert a `cli::DialTarget` (the CLI's local-resolver hit) into the
1878/// JSON shape MCP whois callers expect. Mirrors the human-readable arms
1879/// of `cli::cmd_whois_local` but keyed for programmatic consumption.
1880/// Surfaces inline op claims from the peer's pinned card via the same
1881/// `op_claims_from_card` helper used everywhere else in v0.14.x.
1882fn dial_target_to_whois_json(target: &crate::cli::DialTarget) -> Value {
1883    use crate::cli::DialTarget;
1884    match target {
1885        DialTarget::PinnedPeer {
1886            handle,
1887            did,
1888            nickname,
1889            emoji,
1890            tier,
1891        } => {
1892            let op_claims = crate::config::read_trust()
1893                .ok()
1894                .and_then(|t| {
1895                    t.get("agents")
1896                        .and_then(Value::as_object)
1897                        .and_then(|m| m.get(handle))
1898                        .and_then(|a| a.get("card").cloned())
1899                })
1900                .map(|c| crate::cli::op_claims_from_card(&c))
1901                .unwrap_or_default();
1902            let mut payload = serde_json::Map::new();
1903            payload.insert("kind".into(), json!("pinned_peer"));
1904            payload.insert("handle".into(), json!(handle));
1905            payload.insert("did".into(), json!(did));
1906            payload.insert("nickname".into(), json!(nickname));
1907            payload.insert("emoji".into(), json!(emoji));
1908            payload.insert("tier".into(), json!(tier));
1909            for (k, v) in op_claims {
1910                payload.insert(k, v);
1911            }
1912            Value::Object(payload)
1913        }
1914        DialTarget::LocalSister {
1915            session_name,
1916            handle,
1917            did,
1918            nickname,
1919            emoji,
1920        } => json!({
1921            "kind": "local_sister",
1922            "session_name": session_name,
1923            "handle": handle,
1924            "did": did,
1925            "nickname": nickname,
1926            "emoji": emoji,
1927        }),
1928    }
1929}
1930
1931fn tool_profile_set(args: &Value) -> Result<Value, String> {
1932    let field = args
1933        .get("field")
1934        .and_then(Value::as_str)
1935        .ok_or("missing 'field'")?;
1936    let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
1937    // If value is a string that itself parses as JSON (e.g. "[\"rust\"]"),
1938    // unwrap it. Otherwise pass as-is. Lets agents send either typed values
1939    // or stringified JSON.
1940    let value = if let Some(s) = raw_value.as_str() {
1941        serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
1942    } else {
1943        raw_value
1944    };
1945    let new_profile =
1946        crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
1947    Ok(json!({
1948        "field": field,
1949        "profile": new_profile,
1950    }))
1951}
1952
1953fn tool_profile_get() -> Result<Value, String> {
1954    let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1955    Ok(json!({
1956        "did": card.get("did").cloned().unwrap_or(Value::Null),
1957        "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1958    }))
1959}
1960
1961// ---------- helpers ----------
1962
1963fn parse_kind(s: &str) -> u32 {
1964    if let Ok(n) = s.parse::<u32>() {
1965        return n;
1966    }
1967    for (id, name) in crate::signing::kinds() {
1968        if *name == s {
1969            return *id;
1970        }
1971    }
1972    1
1973}
1974
1975fn error_response(id: &Value, code: i32, message: &str) -> Value {
1976    json!({
1977        "jsonrpc": "2.0",
1978        "id": id,
1979        "error": {"code": code, "message": message}
1980    })
1981}
1982
1983#[cfg(test)]
1984mod tests {
1985    use super::*;
1986
1987    #[test]
1988    fn unknown_method_returns_jsonrpc_error() {
1989        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
1990        let resp = handle_request(&req, &McpState::default());
1991        assert_eq!(resp["error"]["code"], -32601);
1992    }
1993
1994    #[test]
1995    fn initialize_advertises_tools_capability() {
1996        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
1997        let resp = handle_request(&req, &McpState::default());
1998        assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
1999        assert!(resp["result"]["capabilities"]["tools"].is_object());
2000        assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
2001    }
2002
2003    #[test]
2004    fn tools_list_includes_pairing_and_messaging() {
2005        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2006        let resp = handle_request(&req, &McpState::default());
2007        let names: Vec<&str> = resp["result"]["tools"]
2008            .as_array()
2009            .unwrap()
2010            .iter()
2011            .filter_map(|t| t["name"].as_str())
2012            .collect();
2013        for required in [
2014            "wire_whoami",
2015            "wire_peers",
2016            "wire_send",
2017            "wire_tail",
2018            "wire_verify",
2019            "wire_init",
2020            "wire_dial",
2021        ] {
2022            assert!(
2023                names.contains(&required),
2024                "missing required tool {required}"
2025            );
2026        }
2027        // The SAS code-phrase pair tools were removed (RFC-005 follow-on) —
2028        // they must NOT be advertised.
2029        for removed in [
2030            "wire_pair_initiate",
2031            "wire_pair_join",
2032            "wire_pair_check",
2033            "wire_pair_confirm",
2034            "wire_pair_initiate_detached",
2035            "wire_pair_join_detached",
2036            "wire_pair_list_pending",
2037            "wire_pair_confirm_detached",
2038            "wire_pair_cancel_pending",
2039        ] {
2040            assert!(
2041                !names.contains(&removed),
2042                "SAS pair tool {removed} must not be advertised after removal"
2043            );
2044        }
2045        // wire_join (the old direct alias for the SAS pair-join) is explicitly
2046        // NOT in the catalog. Calling it returns a deprecation pointing to
2047        // wire_dial (test below covers this).
2048        assert!(
2049            !names.contains(&"wire_join"),
2050            "wire_join must not be advertised — SAS pairing removed"
2051        );
2052    }
2053
2054    #[test]
2055    fn agent_docs_match_advertised_tools() {
2056        // The agent-facing docs must not lie about the MCP surface:
2057        // advertising a tool that doesn't exist wastes an agent turn, and
2058        // omitting one hides a capability. Guard docs/PLUGIN.md (the plugin's
2059        // canonical tool reference) against drift from `tool_defs()` — the
2060        // authoritative catalog. Every advertised tool must be listed, and no
2061        // removed/never-existed "ghost" tool may appear in either agent doc.
2062        let advertised: Vec<String> = tool_defs()
2063            .iter()
2064            .filter_map(|t| t["name"].as_str().map(str::to_string))
2065            .collect();
2066        let manifest = env!("CARGO_MANIFEST_DIR");
2067        let plugin = std::fs::read_to_string(format!("{manifest}/docs/PLUGIN.md"))
2068            .expect("read docs/PLUGIN.md");
2069        for name in &advertised {
2070            assert!(
2071                plugin.contains(name.as_str()),
2072                "docs/PLUGIN.md missing advertised MCP tool `{name}` — it drifted from tool_defs()"
2073            );
2074        }
2075        let integ = std::fs::read_to_string(format!("{manifest}/docs/AGENT_INTEGRATION.md"))
2076            .expect("read docs/AGENT_INTEGRATION.md");
2077        for (doc, body) in [
2078            ("docs/PLUGIN.md", &plugin),
2079            ("docs/AGENT_INTEGRATION.md", &integ),
2080        ] {
2081            for ghost in [
2082                "wire_up",
2083                "wire_pair_host",
2084                "wire_pair_join",
2085                "wire_pair_confirm",
2086                "wire_pair_accept",
2087                "wire_pair_reject",
2088                "wire_pair_list_inbound",
2089            ] {
2090                assert!(
2091                    !body.contains(ghost),
2092                    "{doc} advertises ghost MCP tool `{ghost}` (removed / never existed)"
2093                );
2094            }
2095        }
2096    }
2097
2098    #[test]
2099    fn legacy_wire_join_call_returns_helpful_error() {
2100        let req = json!({
2101            "jsonrpc": "2.0",
2102            "id": 1,
2103            "method": "tools/call",
2104            "params": {"name": "wire_join", "arguments": {}}
2105        });
2106        let resp = handle_request(&req, &McpState::default());
2107        assert_eq!(resp["result"]["isError"], true);
2108        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2109        assert!(
2110            text.contains("wire_dial"),
2111            "expected redirect to wire_dial, got: {text}"
2112        );
2113    }
2114
2115    #[test]
2116    fn tools_list_canonical_present_deprecated_absent() {
2117        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2118        let resp = handle_request(&req, &McpState::default());
2119        let names: Vec<&str> = resp["result"]["tools"]
2120            .as_array()
2121            .unwrap()
2122            .iter()
2123            .filter_map(|t| t["name"].as_str())
2124            .collect();
2125
2126        // Canonical names must be present.
2127        for required in ["wire_accept", "wire_reject", "wire_pending"] {
2128            assert!(
2129                names.contains(&required),
2130                "canonical tool {required} missing from tools/list"
2131            );
2132        }
2133
2134        // Deprecated aliases must NOT be advertised (RFC-005 Phase 2).
2135        for removed in [
2136            "wire_pair_accept",
2137            "wire_pair_reject",
2138            "wire_pair_list_inbound",
2139        ] {
2140            assert!(
2141                !names.contains(&removed),
2142                "deprecated tool {removed} must not appear in tools/list"
2143            );
2144        }
2145    }
2146
2147    #[test]
2148    fn deprecated_pair_accept_call_returns_helpful_error() {
2149        for (old_name, canonical) in [
2150            ("wire_pair_accept", "wire_accept"),
2151            ("wire_pair_reject", "wire_reject"),
2152            ("wire_pair_list_inbound", "wire_pending"),
2153        ] {
2154            let req = json!({
2155                "jsonrpc": "2.0",
2156                "id": 1,
2157                "method": "tools/call",
2158                "params": {"name": old_name, "arguments": {}}
2159            });
2160            let resp = handle_request(&req, &McpState::default());
2161            assert_eq!(
2162                resp["result"]["isError"], true,
2163                "calling {old_name} should return isError:true"
2164            );
2165            let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2166            assert!(
2167                text.contains(canonical),
2168                "error for {old_name} should mention {canonical}, got: {text}"
2169            );
2170        }
2171    }
2172
2173    #[test]
2174    fn initialize_advertises_resources_capability() {
2175        let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
2176        let resp = handle_request(&req, &McpState::default());
2177        let caps = &resp["result"]["capabilities"];
2178        assert!(
2179            caps["resources"].is_object(),
2180            "resources capability must be present, got {resp}"
2181        );
2182        assert_eq!(
2183            caps["resources"]["subscribe"], true,
2184            "subscribe shipped in v0.2.1"
2185        );
2186    }
2187
2188    #[test]
2189    fn resources_read_with_bad_uri_errors() {
2190        let req = json!({
2191            "jsonrpc": "2.0",
2192            "id": 1,
2193            "method": "resources/read",
2194            "params": {"uri": "http://example.com/not-a-wire-uri"}
2195        });
2196        let resp = handle_request(&req, &McpState::default());
2197        assert!(resp.get("error").is_some(), "expected error, got {resp}");
2198    }
2199
2200    #[test]
2201    fn parse_inbox_uri_handles_variants() {
2202        assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
2203        assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
2204        assert!(
2205            parse_inbox_uri("wire://inbox/")
2206                .unwrap()
2207                .starts_with("__invalid__"),
2208            "empty peer must be invalid"
2209        );
2210        assert!(
2211            parse_inbox_uri("http://other")
2212                .unwrap()
2213                .starts_with("__invalid__"),
2214            "non-wire scheme must be invalid"
2215        );
2216    }
2217
2218    #[test]
2219    fn ping_returns_empty_result() {
2220        let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2221        let resp = handle_request(&req, &McpState::default());
2222        assert_eq!(resp["id"], 7);
2223        assert!(resp["result"].is_object());
2224    }
2225
2226    #[test]
2227    fn notification_returns_null_no_reply() {
2228        let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2229        let resp = handle_request(&req, &McpState::default());
2230        assert_eq!(resp, Value::Null);
2231    }
2232
2233    /// v0.6.1 regression: `detect_session_wire_home` must return the
2234    /// session's home dir when the cwd is in the registry AND the
2235    /// session dir exists on disk. The original v0.6.1 shipped with
2236    /// only an eprintln "verification" — this test asserts the
2237    /// observable return value so the env-set-but-not-consumed class
2238    /// of bug fails loudly.
2239    #[test]
2240    fn detect_session_wire_home_resolves_registered_cwd() {
2241        crate::config::test_support::with_temp_home(|| {
2242            // Set up sessions/registry.json + the by-key home for
2243            // `test-alpha` under the temp WIRE_HOME so session::read_registry
2244            // + session::session_dir resolve through it. RFC-006 Part A: a
2245            // named session's home is `sessions/by-key/<hash(name)>`, not a
2246            // top-level `sessions/<name>` dir.
2247            let wire_home = std::env::var("WIRE_HOME").unwrap();
2248            let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2249            std::fs::create_dir_all(&sessions_root).unwrap();
2250            let session_home = crate::session::session_dir("test-alpha").unwrap();
2251            std::fs::create_dir_all(&session_home).unwrap();
2252            let fake_cwd = "/tmp/fake-project-cwd-abc123";
2253            let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2254            std::fs::write(
2255                sessions_root.join("registry.json"),
2256                serde_json::to_vec_pretty(&registry).unwrap(),
2257            )
2258            .unwrap();
2259
2260            // Hit happy path.
2261            let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2262            assert_eq!(
2263                got.as_deref(),
2264                Some(session_home.as_path()),
2265                "registered cwd must resolve to session_home"
2266            );
2267
2268            // Unregistered cwd → None.
2269            let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2270                "/tmp/cwd-not-in-registry-xyz789",
2271            ));
2272            assert!(nope.is_none(), "unregistered cwd must return None");
2273
2274            // Registered cwd but session dir missing → None (defensive:
2275            // stale registry entry pointing at a deleted session).
2276            let stale_cwd = "/tmp/stale-session-cwd";
2277            let stale_registry =
2278                json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2279            std::fs::write(
2280                sessions_root.join("registry.json"),
2281                serde_json::to_vec_pretty(&stale_registry).unwrap(),
2282            )
2283            .unwrap();
2284            let stale_got =
2285                crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2286            assert!(
2287                stale_got.is_none(),
2288                "registered cwd whose session dir is missing must return None"
2289            );
2290        });
2291    }
2292
2293    // v0.14.x: shape tests for `dial_target_to_whois_json`. The MCP whois
2294    // bare-nick fix routes through `cli::resolve_name_to_target` (returns
2295    // a `DialTarget`) and reshapes it for JSON-RPC consumption. These
2296    // tests pin the response shape so a future refactor of either side
2297    // (resolver or wire shape) catches the contract drift.
2298
2299    #[test]
2300    fn dial_target_to_whois_json_pinned_peer_shape() {
2301        let target = crate::cli::DialTarget::PinnedPeer {
2302            handle: "slate-lotus".into(),
2303            did: "did:wire:slate-lotus-88232017".into(),
2304            nickname: Some("slate-lotus".into()),
2305            emoji: Some("🪴".into()),
2306            tier: "VERIFIED".into(),
2307        };
2308        crate::config::test_support::with_temp_home(|| {
2309            let out = dial_target_to_whois_json(&target);
2310            assert_eq!(out.get("kind").and_then(Value::as_str), Some("pinned_peer"));
2311            assert_eq!(
2312                out.get("handle").and_then(Value::as_str),
2313                Some("slate-lotus")
2314            );
2315            assert_eq!(out.get("tier").and_then(Value::as_str), Some("VERIFIED"));
2316            // op claims are absent when trust.json has no row for this
2317            // peer (the helper falls through to an empty map). No
2318            // spurious `null` op_did keys.
2319            assert!(out.get("op_did").is_none());
2320        });
2321    }
2322
2323    #[test]
2324    fn dial_target_to_whois_json_local_sister_shape() {
2325        let target = crate::cli::DialTarget::LocalSister {
2326            session_name: "vesper-valley".into(),
2327            handle: "vesper-valley".into(),
2328            did: Some("did:wire:vesper-valley-deadbeef".into()),
2329            nickname: Some("vesper-valley".into()),
2330            emoji: Some("🦌".into()),
2331        };
2332        let out = dial_target_to_whois_json(&target);
2333        assert_eq!(
2334            out.get("kind").and_then(Value::as_str),
2335            Some("local_sister")
2336        );
2337        assert_eq!(
2338            out.get("session_name").and_then(Value::as_str),
2339            Some("vesper-valley")
2340        );
2341        assert_eq!(
2342            out.get("did").and_then(Value::as_str),
2343            Some("did:wire:vesper-valley-deadbeef")
2344        );
2345        // LocalSister carries no card → no op_claims path. Spot-check
2346        // no leakage from the PinnedPeer arm.
2347        assert!(out.get("tier").is_none());
2348        assert!(out.get("op_did").is_none());
2349    }
2350}