Skip to main content

wire/
mcp.rs

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