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