1use anyhow::Result;
49use serde_json::{Value, json};
50use std::collections::HashSet;
51use std::io::{BufRead, BufReader, Write};
52use std::sync::{Arc, Mutex};
53
54#[derive(Clone, Default)]
58pub struct McpState {
59 pub subscribed: Arc<Mutex<HashSet<String>>>,
63 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
73pub fn run() -> Result<()> {
94 use std::sync::atomic::{AtomicBool, Ordering};
95 use std::sync::mpsc;
96 use std::time::{Duration, Instant};
97
98 crate::session::maybe_adopt_session_wire_home("mcp");
111
112 crate::cli::maybe_auto_init_cwd_session("mcp");
118
119 ensure_session_bootstrapped();
126
127 crate::session::warn_on_identity_collision(std::process::id(), "mcp");
135
136 let state = McpState::default();
137 let shutdown = Arc::new(AtomicBool::new(false));
138
139 let (tx, rx) = mpsc::channel::<String>();
140
141 if let Ok(mut g) = state.notif_tx.lock() {
144 *g = Some(tx.clone());
145 }
146
147 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 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 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 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 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 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 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 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, "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
306fn 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 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 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
448fn 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 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 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 "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), wire_status (daemon + sync health). For cross-machine pairing pass `<handle>@<relay>` to wire_dial. Bilateral: the peer must also accept (wire_accept or wire_dial back) before capability flows in both directions. INBOUND pair requests from strangers land in pending-inbound: call wire_pending to enumerate, surface to operator, then wire_accept or wire_reject. NEVER auto-accept inbound pair requests without operator consent — accepting grants the peer authenticated write access to this agent's inbox. Resources: 'wire://inbox/<peer>' exposes each pinned peer's verified inbox (JSONL). ON SESSION START, DO THIS IMMEDIATELY (every wire session): (1) call wire_status to confirm the `wire daemon` sync loop is running — `daemon_running:true` + `last_sync_age_seconds < 60`; if not, the session won't push outbound or pull inbound and the operator must start a daemon (`wire daemon --interval 5` in a background shell, or systemd/launchd). (2) arm a PERSISTENT stream-watcher on `wire monitor --json` — in Claude Code, the Monitor tool with persistent:true — so peer messages surface live mid-session. The monitor does NOT sync the relay; it only tails the inbox the daemon writes. Both are required. WHEN A PEER MESSAGE ARRIVES, reply to it in your own live context WITHOUT waiting for the operator to prompt you; this is how paired agents collaborate immediately. (Default filter strips pair_drop/pair_drop_ack/heartbeat noise.) v0.14.2: wire_send POSTs synchronously by default — response `status` is the actual relay verdict: `delivered` (event landed on peer's slot), `duplicate` (same event_id already on slot; peer can still pull), `peer_unknown` (peer not pinned — run wire_dial first), `slot_stale` (peer's slot rotated — run wire_dial to re-pair), or `transport_error` (TLS/DNS/relay-5xx; check `reason` field). Pass `queue:true` to opt back into the legacy outbox→daemon-push path for offline-buffer / pre-pair queueing. wire_pull is the symmetric receive primitive — call it to trigger an immediate relay GET instead of waiting for the daemon's 5s pull cycle; returns written[]/rejected[]/total_seen the same way `wire pull --json` does. 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_status",
577 "description": "v0.14.2 — daemon + sync-loop health check. Returns: daemon_running (pidfile pid alive), all_running_pids (pgrep for `wire daemon`), last_sync_age_seconds (age of the most recent successful daemon cycle; null if no cycle ever recorded), outbox_count, inbox_count, peer count. **Call this BEFORE assuming wire_send actually delivered** — `wire_send` returns `status:\"queued\"` even if no daemon is running to push the queued event. A nonzero `outbox_count` with no recent `last_sync` means events are queued into the void. Read-only.",
578 "inputSchema": {"type": "object", "properties": {}, "required": []}
579 }),
580 json!({
581 "name": "wire_send",
582 "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.",
583 "inputSchema": {
584 "type": "object",
585 "properties": {
586 "peer": {"type": "string", "description": "Peer handle (without did:wire: prefix). Must be a pinned peer; check wire_peers first."},
587 "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."},
588 "body": {"type": "string", "description": "Event body. Plain text becomes a JSON string; valid JSON is parsed and embedded structurally."},
589 "time_sensitive_until": {"type": "string", "description": "Optional advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp."}
590 },
591 "required": ["peer", "kind", "body"]
592 }
593 }),
594 json!({
595 "name": "wire_pull",
596 "description": "v0.14.2: trigger an immediate, synchronous pull from this agent's relay slot(s). Returns the same shape as `wire pull --json`: written[] (events landed in inbox), rejected[] (failed signature / cursor verify / dedupe), total_seen, cursor_blocked, endpoints_pulled. **Use this when you want events NOW** instead of waiting for the daemon's 5s pull cycle. Symmetric to wire_send's sync POST. Read-only — only consults the relay's GET, no mutations beyond writing inbox.jsonl + advancing per-slot cursors. Idempotent: re-pulling with the same cursor returns nothing new.",
597 "inputSchema": {"type": "object", "properties": {}, "required": []}
598 }),
599 json!({
600 "name": "wire_tail",
601 "description": "Read recent signed events from this agent's inbox. Each event has a 'verified' field (bool) — the Ed25519 signature was checked against the trust state before the daemon wrote the inbox. **Orientation (wire #79):** defaults to NEWEST-N (last `limit` events across all matched peers, sorted chronologically by timestamp). Pass `oldest: true` for FIFO behaviour (first-N, for inbox replay from the start).",
602 "inputSchema": {
603 "type": "object",
604 "properties": {
605 "peer": {"type": "string", "description": "Optional peer handle to filter inbox by."},
606 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Max events to return."},
607 "oldest": {"type": "boolean", "default": false, "description": "Return the FIRST `limit` events (oldest-N) instead of the default last-N (newest-N)."}
608 },
609 "required": []
610 }
611 }),
612 json!({
613 "name": "wire_verify",
614 "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).",
615 "inputSchema": {
616 "type": "object",
617 "properties": {
618 "event": {"type": "string", "description": "JSON-encoded signed event."}
619 },
620 "required": ["event"]
621 }
622 }),
623 json!({
624 "name": "wire_init",
625 "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.",
626 "inputSchema": {
627 "type": "object",
628 "properties": {
629 "handle": {"type": "string", "description": "Short handle (becomes did:wire:<handle>). ASCII alphanumeric / '-' / '_' only."},
630 "name": {"type": "string", "description": "Optional display name (defaults to capitalized handle)."},
631 "relay_url": {"type": "string", "description": "Optional relay URL — if set, also binds a relay slot."}
632 },
633 "required": ["handle"]
634 }
635 }),
636 json!({
637 "name": "wire_pair_initiate",
638 "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).",
639 "inputSchema": {
640 "type": "object",
641 "properties": {
642 "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
643 "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
644 "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."}
645 },
646 "required": []
647 }
648 }),
649 json!({
650 "name": "wire_pair_join",
651 "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.",
652 "inputSchema": {
653 "type": "object",
654 "properties": {
655 "code_phrase": {"type": "string", "description": "Code phrase from the host (e.g. '73-2QXC4P')."},
656 "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
657 "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
658 "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 30, "description": "How long to block waiting for SPAKE2 exchange to complete."}
659 },
660 "required": ["code_phrase"]
661 }
662 }),
663 json!({
664 "name": "wire_pair_check",
665 "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.",
666 "inputSchema": {
667 "type": "object",
668 "properties": {
669 "session_id": {"type": "string"},
670 "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 8}
671 },
672 "required": ["session_id"]
673 }
674 }),
675 json!({
676 "name": "wire_pair_confirm",
677 "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').",
678 "inputSchema": {
679 "type": "object",
680 "properties": {
681 "session_id": {"type": "string"},
682 "user_typed_digits": {"type": "string", "description": "The 6 SAS digits the user typed back, e.g. '384217' or '384-217'."}
683 },
684 "required": ["session_id", "user_typed_digits"]
685 }
686 }),
687 json!({
688 "name": "wire_pair_initiate_detached",
689 "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.",
690 "inputSchema": {
691 "type": "object",
692 "properties": {
693 "handle": {"type": "string", "description": "Optional handle for auto-init (idempotent)."},
694 "relay_url": {"type": "string"}
695 }
696 }
697 }),
698 json!({
699 "name": "wire_pair_join_detached",
700 "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.",
701 "inputSchema": {
702 "type": "object",
703 "properties": {
704 "handle": {"type": "string"},
705 "code_phrase": {"type": "string"},
706 "relay_url": {"type": "string"}
707 },
708 "required": ["code_phrase"]
709 }
710 }),
711 json!({
712 "name": "wire_pair_list_pending",
713 "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.",
714 "inputSchema": {"type": "object", "properties": {}}
715 }),
716 json!({
717 "name": "wire_pair_confirm_detached",
718 "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.",
719 "inputSchema": {
720 "type": "object",
721 "properties": {
722 "code_phrase": {"type": "string"},
723 "user_typed_digits": {"type": "string"}
724 },
725 "required": ["code_phrase", "user_typed_digits"]
726 }
727 }),
728 json!({
729 "name": "wire_pair_cancel_pending",
730 "description": "Cancel a pending detached pair. Releases the relay slot and removes the local pending file. Safe to call regardless of current status (idempotent).",
731 "inputSchema": {
732 "type": "object",
733 "properties": {"code_phrase": {"type": "string"}},
734 "required": ["code_phrase"]
735 }
736 }),
737 json!({
738 "name": "wire_invite_mint",
739 "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}.",
740 "inputSchema": {
741 "type": "object",
742 "properties": {
743 "relay_url": {"type": "string", "description": "Override relay for first-time auto-allocate."},
744 "ttl_secs": {"type": "integer", "description": "Invite lifetime in seconds (default 86400)."},
745 "uses": {"type": "integer", "description": "Number of distinct peers that can accept before consumption (default 1)."}
746 }
747 }
748 }),
749 json!({
750 "name": "wire_invite_accept",
751 "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}.",
752 "inputSchema": {
753 "type": "object",
754 "properties": {
755 "url": {"type": "string", "description": "Full wire://pair?v=1&inv=... URL."}
756 },
757 "required": ["url"]
758 }
759 }),
760 json!({
762 "name": "wire_add",
763 "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.",
764 "inputSchema": {
765 "type": "object",
766 "properties": {
767 "handle": {"type": "string", "description": "Peer handle like `nick@domain`."},
768 "relay_url": {"type": "string", "description": "Override resolver URL (default: `https://<domain>`)."}
769 },
770 "required": ["handle"]
771 }
772 }),
773 json!({
774 "name": "wire_pair_accept",
775 "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.",
776 "inputSchema": {
777 "type": "object",
778 "properties": {
779 "peer": {"type": "string", "description": "Bare peer handle (without `@<relay>`). Match exactly what `wire_pair_list_inbound` returned in `peer_handle`."}
780 },
781 "required": ["peer"]
782 }
783 }),
784 json!({
785 "name": "wire_pair_reject",
786 "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.",
787 "inputSchema": {
788 "type": "object",
789 "properties": {
790 "peer": {"type": "string", "description": "Bare peer handle (without `@<relay>`)."}
791 },
792 "required": ["peer"]
793 }
794 }),
795 json!({
796 "name": "wire_pair_list_inbound",
797 "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.",
798 "inputSchema": {"type": "object", "properties": {}}
799 }),
800 json!({
805 "name": "wire_dial",
806 "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.",
807 "inputSchema": {
808 "type": "object",
809 "properties": {
810 "name": {"type": "string", "description": "Peer name — character nickname / session / handle / DID / `<handle>@<relay>`."}
811 },
812 "required": ["name"]
813 }
814 }),
815 json!({
816 "name": "wire_accept",
817 "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.",
818 "inputSchema": {
819 "type": "object",
820 "properties": {
821 "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle, from wire_pending)."}
822 },
823 "required": ["peer"]
824 }
825 }),
826 json!({
827 "name": "wire_reject",
828 "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.",
829 "inputSchema": {
830 "type": "object",
831 "properties": {
832 "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle)."}
833 },
834 "required": ["peer"]
835 }
836 }),
837 json!({
838 "name": "wire_pending",
839 "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.",
840 "inputSchema": {"type": "object", "properties": {}}
841 }),
842 json!({
843 "name": "wire_claim",
844 "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).",
845 "inputSchema": {
846 "type": "object",
847 "properties": {
848 "nick": {"type": "string", "description": "Optional + advisory. Ignored if it differs from your DID-derived persona (one-name rule)."},
849 "relay_url": {"type": "string", "description": "Relay to claim on. Default = our relay."},
850 "public_url": {"type": "string", "description": "Public URL the relay should advertise to resolvers."}
851 }
852 }
853 }),
854 json!({
855 "name": "wire_whois",
856 "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.",
857 "inputSchema": {
858 "type": "object",
859 "properties": {
860 "handle": {"type": "string", "description": "Optional `nick@domain`. Omit for self."},
861 "relay_url": {"type": "string", "description": "Override resolver URL."}
862 }
863 }
864 }),
865 json!({
866 "name": "wire_profile_set",
867 "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.",
868 "inputSchema": {
869 "type": "object",
870 "properties": {
871 "field": {"type": "string", "description": "One of: display_name, emoji, motto, vibe, pronouns, avatar_url, handle, now."},
872 "value": {"description": "String for most fields; array for vibe; object for now. Pass JSON null to clear a field."}
873 },
874 "required": ["field", "value"]
875 }
876 }),
877 json!({
878 "name": "wire_profile_get",
879 "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.",
880 "inputSchema": {"type": "object", "properties": {}}
881 }),
882 json!({
887 "name": "wire_group_create",
888 "description": "Create a group chat room (you become the creator). Allocates a shared relay slot whose token is the room key, signs the initial roster, and persists it locally. Returns {id, name, members, relay_url}. Use the returned id with the other wire_group_* tools.",
889 "inputSchema": {
890 "type": "object",
891 "properties": {"name": {"type": "string", "description": "Human label for the group."}},
892 "required": ["name"]
893 }
894 }),
895 json!({
896 "name": "wire_group_add",
897 "description": "Add a bilaterally-VERIFIED pinned peer to a group you created, as a Member. The peer must already be paired + VERIFIED (check wire_peers). Re-signs the roster and queues a signed group_invite to every member (run a normal push/let the daemon deliver). Creator-only.",
898 "inputSchema": {
899 "type": "object",
900 "properties": {
901 "group": {"type": "string", "description": "Group id or name."},
902 "peer": {"type": "string", "description": "Handle of a VERIFIED pinned peer."}
903 },
904 "required": ["group", "peer"]
905 }
906 }),
907 json!({
908 "name": "wire_group_send",
909 "description": "Post a message to a group room (one signed event to the shared slot; every member reads it). You must have the group locally (created it, were added, or joined by code).",
910 "inputSchema": {
911 "type": "object",
912 "properties": {
913 "group": {"type": "string", "description": "Group id or name."},
914 "message": {"type": "string", "description": "Message text."}
915 },
916 "required": ["group", "message"]
917 }
918 }),
919 json!({
920 "name": "wire_group_tail",
921 "description": "Read recent messages from a group room. Each message has a 'verified' bool (signature checked against the roster + room-announced joiner keys). Also surfaces join notices. Pulls the shared room slot.",
922 "inputSchema": {
923 "type": "object",
924 "properties": {
925 "group": {"type": "string", "description": "Group id or name."},
926 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 20, "description": "Max timeline entries to return."}
927 },
928 "required": ["group"]
929 }
930 }),
931 json!({
932 "name": "wire_group_list",
933 "description": "List the groups this agent is in, with each group's members and their GroupTiers (creator/member/introduced). Read-only, local.",
934 "inputSchema": {"type": "object", "properties": {}, "required": []}
935 }),
936 json!({
937 "name": "wire_group_invite",
938 "description": "Mint a shareable join code for a group — a self-contained token (room coords + signed roster). Anyone you give it to can wire_group_join to enter at Introduced tier. The code IS the room key; share only with people you want in the room.",
939 "inputSchema": {
940 "type": "object",
941 "properties": {"group": {"type": "string", "description": "Group id or name."}},
942 "required": ["group"]
943 }
944 }),
945 json!({
946 "name": "wire_group_join",
947 "description": "Join a group from a code minted by wire_group_invite. Materializes the room locally, pins existing members on the creator's vouch, and announces you to the room so members verify your messages. No prior pairing needed.",
948 "inputSchema": {
949 "type": "object",
950 "properties": {"code": {"type": "string", "description": "The `wire-group:` join code."}},
951 "required": ["code"]
952 }
953 }),
954 ]
955}
956
957fn handle_tools_call(id: &Value, params: &Value, state: &McpState) -> Value {
958 let name = match params.get("name").and_then(Value::as_str) {
959 Some(n) => n,
960 None => return error_response(id, -32602, "missing tool name"),
961 };
962 let args = params
963 .get("arguments")
964 .cloned()
965 .unwrap_or_else(|| json!({}));
966
967 let result = match name {
968 "wire_whoami" => tool_whoami(),
969 "wire_status" => tool_status(),
970 "wire_peers" => tool_peers(),
971 "wire_send" => tool_send(&args),
972 "wire_pull" => tool_pull(),
973 "wire_tail" => tool_tail(&args),
974 "wire_verify" => tool_verify(&args),
975 "wire_init" => tool_init(&args),
976 "wire_pair_initiate" => tool_pair_initiate(&args),
977 "wire_pair_join" => tool_pair_join(&args),
978 "wire_pair_check" => tool_pair_check(&args),
979 "wire_pair_confirm" => tool_pair_confirm(&args, state),
980 "wire_pair_initiate_detached" => tool_pair_initiate_detached(&args),
981 "wire_pair_join_detached" => tool_pair_join_detached(&args),
982 "wire_pair_list_pending" => tool_pair_list_pending(),
983 "wire_pair_confirm_detached" => tool_pair_confirm_detached(&args),
984 "wire_pair_cancel_pending" => tool_pair_cancel_pending(&args),
985 "wire_invite_mint" => tool_invite_mint(&args),
986 "wire_invite_accept" => tool_invite_accept(&args),
987 "wire_add" => tool_add(&args),
989 "wire_pair_accept" | "wire_accept" => tool_pair_accept(&args),
995 "wire_pair_reject" | "wire_reject" => tool_pair_reject(&args),
996 "wire_pair_list_inbound" | "wire_pending" => tool_pair_list_inbound(),
997 "wire_dial" => tool_dial(&args),
998 "wire_claim" => tool_claim_handle(&args),
999 "wire_whois" => tool_whois(&args),
1000 "wire_profile_set" => tool_profile_set(&args),
1001 "wire_profile_get" => tool_profile_get(),
1002 "wire_group_create" => tool_group_create(&args),
1004 "wire_group_add" => tool_group_add(&args),
1005 "wire_group_send" => tool_group_send(&args),
1006 "wire_group_tail" => tool_group_tail(&args),
1007 "wire_group_list" => tool_group_list(),
1008 "wire_group_invite" => tool_group_invite(&args),
1009 "wire_group_join" => tool_group_join(&args),
1010 "wire_join" => Err(
1013 "wire_join was renamed to wire_pair_join (use code_phrase argument). \
1014 See docs/AGENT_INTEGRATION.md."
1015 .into(),
1016 ),
1017 other => Err(format!("unknown tool: {other}")),
1018 };
1019
1020 match result {
1021 Ok(value) => json!({
1022 "jsonrpc": "2.0",
1023 "id": id,
1024 "result": {
1025 "content": [{
1026 "type": "text",
1027 "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
1028 }],
1029 "isError": false
1030 }
1031 }),
1032 Err(message) => json!({
1033 "jsonrpc": "2.0",
1034 "id": id,
1035 "result": {
1036 "content": [{"type": "text", "text": message}],
1037 "isError": true
1038 }
1039 }),
1040 }
1041}
1042
1043fn tool_whoami() -> Result<Value, String> {
1046 use crate::config;
1047 use crate::signing::{b64decode, fingerprint, make_key_id};
1048
1049 if !config::is_initialized().map_err(|e| e.to_string())? {
1050 return Err("not initialized — operator must run `wire init <handle>` first".into());
1051 }
1052 let card = config::read_agent_card().map_err(|e| e.to_string())?;
1053 let did = card
1054 .get("did")
1055 .and_then(Value::as_str)
1056 .unwrap_or("")
1057 .to_string();
1058 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1059 let pk_b64 = card
1060 .get("verify_keys")
1061 .and_then(Value::as_object)
1062 .and_then(|m| m.values().next())
1063 .and_then(|v| v.get("key"))
1064 .and_then(Value::as_str)
1065 .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
1066 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1067 let fp = fingerprint(&pk_bytes);
1068 let key_id = make_key_id(&handle, &pk_bytes);
1069 let capabilities = card
1070 .get("capabilities")
1071 .cloned()
1072 .unwrap_or_else(|| json!(["wire/v3.2"]));
1073 let persona =
1077 serde_json::to_value(crate::character::Character::from_card(&card)).unwrap_or(Value::Null);
1078 let mut payload = serde_json::Map::new();
1084 payload.insert("did".into(), json!(did));
1085 payload.insert("handle".into(), json!(handle));
1086 payload.insert("persona".into(), persona);
1087 payload.insert("fingerprint".into(), json!(fp));
1088 payload.insert("key_id".into(), json!(key_id));
1089 payload.insert("public_key_b64".into(), json!(pk_b64));
1090 payload.insert("capabilities".into(), capabilities);
1091 for (k, v) in crate::cli::op_claims_from_card(&card) {
1092 payload.insert(k, v);
1093 }
1094 Ok(Value::Object(payload))
1095}
1096
1097fn tool_peers() -> Result<Value, String> {
1098 use crate::config;
1099
1100 let trust = config::read_trust().map_err(|e| e.to_string())?;
1101 let agents = trust
1102 .get("agents")
1103 .and_then(Value::as_object)
1104 .cloned()
1105 .unwrap_or_default();
1106 let relay_state =
1114 config::read_relay_state().unwrap_or_else(|_| json!({"self": null, "peers": {}}));
1115 let mut self_did: Option<String> = None;
1116 if let Ok(card) = config::read_agent_card() {
1117 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
1118 }
1119 let mut peers = Vec::new();
1120 for (handle, agent) in agents.iter() {
1121 let did = agent
1122 .get("did")
1123 .and_then(Value::as_str)
1124 .unwrap_or("")
1125 .to_string();
1126 if Some(did.as_str()) == self_did.as_deref() {
1127 continue;
1128 }
1129 let persona = match agent.get("card") {
1133 Some(c) => crate::character::Character::from_card(c),
1134 None => crate::character::Character::from_did(&did),
1135 };
1136 let peer_op_claims = agent
1141 .get("card")
1142 .map(crate::cli::op_claims_from_card)
1143 .unwrap_or_default();
1144 let mut row = serde_json::Map::new();
1145 row.insert("handle".into(), json!(handle));
1146 row.insert(
1147 "persona".into(),
1148 serde_json::to_value(&persona).unwrap_or(Value::Null),
1149 );
1150 row.insert("did".into(), json!(did));
1151 row.insert(
1152 "tier".into(),
1153 json!(crate::trust::effective_tier(&trust, &relay_state, handle)),
1154 );
1155 row.insert(
1156 "capabilities".into(),
1157 agent
1158 .get("card")
1159 .and_then(|c| c.get("capabilities"))
1160 .cloned()
1161 .unwrap_or_else(|| json!([])),
1162 );
1163 for (k, v) in peer_op_claims {
1164 row.insert(k, v);
1165 }
1166 peers.push(Value::Object(row));
1167 }
1168 Ok(json!(peers))
1169}
1170
1171fn group_cli_json(args: &[&str]) -> Result<Value, String> {
1176 let exe = std::env::current_exe().map_err(|e| format!("locating wire binary: {e}"))?;
1177 let out = std::process::Command::new(exe)
1178 .arg("group")
1179 .args(args)
1180 .arg("--json")
1181 .env("WIRE_QUIET_AUTOSESSION", "1") .output()
1183 .map_err(|e| format!("spawning `wire group`: {e}"))?;
1184 if !out.status.success() {
1185 let err = String::from_utf8_lossy(&out.stderr);
1186 return Err(err.trim().to_string());
1187 }
1188 let s = String::from_utf8_lossy(&out.stdout);
1189 let line = s
1191 .lines()
1192 .rev()
1193 .find(|l| l.trim_start().starts_with('{'))
1194 .unwrap_or("{}");
1195 serde_json::from_str(line).map_err(|e| format!("parsing `wire group` output: {e}"))
1196}
1197
1198fn tool_group_create(args: &Value) -> Result<Value, String> {
1199 let name = args
1200 .get("name")
1201 .and_then(Value::as_str)
1202 .ok_or("missing 'name'")?;
1203 group_cli_json(&["create", name])
1204}
1205
1206fn tool_group_add(args: &Value) -> Result<Value, String> {
1207 let group = args
1208 .get("group")
1209 .and_then(Value::as_str)
1210 .ok_or("missing 'group'")?;
1211 let peer = args
1212 .get("peer")
1213 .and_then(Value::as_str)
1214 .ok_or("missing 'peer'")?;
1215 group_cli_json(&["add", group, peer])
1216}
1217
1218fn tool_group_send(args: &Value) -> Result<Value, String> {
1219 let group = args
1220 .get("group")
1221 .and_then(Value::as_str)
1222 .ok_or("missing 'group'")?;
1223 let message = args
1224 .get("message")
1225 .and_then(Value::as_str)
1226 .ok_or("missing 'message'")?;
1227 group_cli_json(&["send", group, message])
1228}
1229
1230fn tool_group_tail(args: &Value) -> Result<Value, String> {
1231 let group = args
1232 .get("group")
1233 .and_then(Value::as_str)
1234 .ok_or("missing 'group'")?;
1235 if let Some(n) = args.get("limit").and_then(Value::as_u64) {
1236 group_cli_json(&["tail", group, "--limit", &n.to_string()])
1237 } else {
1238 group_cli_json(&["tail", group])
1239 }
1240}
1241
1242fn tool_group_list() -> Result<Value, String> {
1243 group_cli_json(&["list"])
1244}
1245
1246fn tool_group_invite(args: &Value) -> Result<Value, String> {
1247 let group = args
1248 .get("group")
1249 .and_then(Value::as_str)
1250 .ok_or("missing 'group'")?;
1251 group_cli_json(&["invite", group])
1252}
1253
1254fn tool_group_join(args: &Value) -> Result<Value, String> {
1255 let code = args
1256 .get("code")
1257 .and_then(Value::as_str)
1258 .ok_or("missing 'code'")?;
1259 group_cli_json(&["join", code])
1260}
1261
1262fn tool_status() -> Result<Value, String> {
1272 use crate::config;
1273
1274 let initialized = config::is_initialized().unwrap_or(false);
1275 if !initialized {
1276 return Ok(json!({
1277 "initialized": false,
1278 "daemon_running": false,
1279 "last_sync_age_seconds": Value::Null,
1280 }));
1281 }
1282
1283 let snap = crate::ensure_up::daemon_liveness();
1284 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1285 let last_sync_record = crate::ensure_up::read_last_sync_record();
1286
1287 let mut daemon = json!({
1288 "running": snap.pidfile_alive,
1289 "pid": snap.pidfile_pid,
1290 "all_running_pids": snap.pgrep_pids,
1291 "orphans": snap.orphan_pids,
1292 });
1293 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
1294 daemon["version"] = json!(d.version);
1295 daemon["bin_path"] = json!(d.bin_path);
1296 daemon["did"] = json!(d.did);
1297 daemon["relay_url"] = json!(d.relay_url);
1298 daemon["started_at"] = json!(d.started_at);
1299 }
1300
1301 let (last_sync_at, last_sync_push_n, last_sync_pull_n, last_sync_rejected_n) =
1302 match last_sync_record {
1303 Some(rec) => (
1304 Some(rec.ts),
1305 Some(rec.push_n),
1306 Some(rec.pull_n),
1307 Some(rec.rejected_n),
1308 ),
1309 None => (None, None, None, None),
1310 };
1311
1312 let outbox_count = config::outbox_dir()
1313 .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1314 .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1315 .unwrap_or(0);
1316 let inbox_count = config::inbox_dir()
1317 .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1318 .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1319 .unwrap_or(0);
1320
1321 let pending_push_breakdown = config::compute_pending_push_breakdown();
1329 let pending_push_count: u64 = pending_push_breakdown.iter().map(|p| p.count).sum();
1330
1331 let stream_state = config::read_stream_state();
1337
1338 Ok(json!({
1339 "initialized": true,
1340 "daemon": daemon,
1341 "daemon_running": snap.pidfile_alive,
1342 "last_sync_at": last_sync_at,
1343 "last_sync_age_seconds": last_sync_age,
1344 "last_sync_push_n": last_sync_push_n,
1345 "last_sync_pull_n": last_sync_pull_n,
1346 "last_sync_rejected_n": last_sync_rejected_n,
1347 "stale_sync": config::stale_sync(last_sync_age),
1348 "outbox_count": outbox_count,
1349 "inbox_count": inbox_count,
1350 "pending_push_count": pending_push_count,
1351 "pending_push_breakdown": pending_push_breakdown,
1352 "stream_state": stream_state,
1353 }))
1354}
1355
1356fn tool_send(args: &Value) -> Result<Value, String> {
1357 use crate::config;
1358 use crate::signing::{b64decode, sign_message_v31};
1359
1360 let peer = args
1361 .get("peer")
1362 .and_then(Value::as_str)
1363 .ok_or("missing 'peer'")?;
1364 let peer = crate::agent_card::bare_handle(peer);
1365 let kind = args
1366 .get("kind")
1367 .and_then(Value::as_str)
1368 .ok_or("missing 'kind'")?;
1369 let body = args
1370 .get("body")
1371 .and_then(Value::as_str)
1372 .ok_or("missing 'body'")?;
1373 let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
1374 let queue = args.get("queue").and_then(Value::as_bool).unwrap_or(false);
1379
1380 if !config::is_initialized().map_err(|e| e.to_string())? {
1381 return Err("not initialized — operator must run `wire init <handle>` first".into());
1382 }
1383 let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
1384 let card = config::read_agent_card().map_err(|e| e.to_string())?;
1385 let did = card
1386 .get("did")
1387 .and_then(Value::as_str)
1388 .unwrap_or("")
1389 .to_string();
1390 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1391 let pk_b64 = card
1392 .get("verify_keys")
1393 .and_then(Value::as_object)
1394 .and_then(|m| m.values().next())
1395 .and_then(|v| v.get("key"))
1396 .and_then(Value::as_str)
1397 .ok_or("agent-card missing verify_keys[*].key")?;
1398 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1399
1400 let body_value: Value =
1402 serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1403 let kind_id = parse_kind(kind);
1404
1405 let now = time::OffsetDateTime::now_utc()
1406 .format(&time::format_description::well_known::Rfc3339)
1407 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1408
1409 let trust_for_did = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
1417 let to_did = crate::trust::resolve_peer_did(&trust_for_did, peer);
1418 let mut event = json!({
1419 "timestamp": now,
1420 "from": did,
1421 "to": to_did,
1422 "type": kind,
1423 "kind": kind_id,
1424 "body": body_value,
1425 });
1426 if let Some(deadline) = deadline {
1427 event["time_sensitive_until"] =
1428 json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1429 }
1430 let signed =
1431 sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1432 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1433
1434 if !queue {
1439 let outcome = crate::send::attempt_deliver(peer, &signed).map_err(|e| e.to_string())?;
1440 let mut v = crate::send::delivery_json(&outcome, peer);
1441 let snap = crate::ensure_up::daemon_liveness();
1447 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1448 if let Some(obj) = v.as_object_mut() {
1449 obj.insert("daemon_seen".into(), json!(snap.pidfile_alive));
1450 obj.insert("last_sync_age_seconds".into(), json!(last_sync_age));
1451 obj.insert(
1452 "stale_sync".into(),
1453 json!(config::stale_sync(last_sync_age)),
1454 );
1455 }
1456 return Ok(v);
1457 }
1458
1459 let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1461 let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1462 let snap = crate::ensure_up::daemon_liveness();
1463 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1464 let peer_pinned_in_trust = trust_for_did
1471 .get("agents")
1472 .and_then(Value::as_object)
1473 .map(|a| a.contains_key(peer))
1474 .unwrap_or(false);
1475 let peer_in_relay_state = config::read_relay_state()
1476 .ok()
1477 .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
1478 .map(|peers| peers.contains_key(peer))
1479 .unwrap_or(false);
1480 let pending_outbound = crate::pending_pair::list_pending()
1481 .ok()
1482 .map(|v| {
1483 v.iter().any(|p| {
1484 p.peer_did
1485 .as_deref()
1486 .map(|d| {
1487 crate::agent_card::display_handle_from_did(d)
1488 .to_string()
1489 .eq(peer)
1490 })
1491 .unwrap_or(false)
1492 })
1493 })
1494 .unwrap_or(false);
1495 let pending_inbound = crate::pending_inbound_pair::list_pending_inbound()
1496 .ok()
1497 .map(|v| v.iter().any(|p| p.peer_handle == peer))
1498 .unwrap_or(false);
1499 let unpushable =
1500 !peer_pinned_in_trust && !peer_in_relay_state && !pending_outbound && !pending_inbound;
1501 let mut out = json!({
1502 "event_id": event_id,
1503 "status": "queued",
1504 "peer": peer,
1505 "outbox": outbox.to_string_lossy(),
1506 "daemon_seen": snap.pidfile_alive,
1507 "last_sync_age_seconds": last_sync_age,
1508 "stale_sync": config::stale_sync(last_sync_age),
1509 });
1510 if unpushable {
1511 out["warning"] = json!(format!(
1512 "`{peer}` is not pinned and has no pending pair — the event will sit in outbox forever unless you pair first (wire_dial)."
1513 ));
1514 }
1515 Ok(out)
1516}
1517
1518fn tool_pull() -> Result<Value, String> {
1525 crate::cli::run_sync_pull().map_err(|e| format!("{e:#}"))
1526}
1527
1528fn tool_tail(args: &Value) -> Result<Value, String> {
1529 use crate::config;
1530 use crate::signing::verify_message_v31;
1531
1532 let peer_filter = args.get("peer").and_then(Value::as_str);
1533 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1534 let oldest = args.get("oldest").and_then(Value::as_bool).unwrap_or(false);
1539 let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1540 if !inbox.exists() {
1541 return Ok(json!([]));
1542 }
1543 let trust = config::read_trust().map_err(|e| e.to_string())?;
1544 let entries: Vec<_> = std::fs::read_dir(&inbox)
1545 .map_err(|e| e.to_string())?
1546 .filter_map(|e| e.ok())
1547 .map(|e| e.path())
1548 .filter(|p| {
1549 p.extension().map(|x| x == "jsonl").unwrap_or(false)
1550 && match peer_filter {
1551 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1552 None => true,
1553 }
1554 })
1555 .collect();
1556
1557 let mut collected: Vec<(String, usize, Value)> = Vec::new();
1560 for path in &entries {
1561 let body = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1562 for (idx, line) in body.lines().enumerate() {
1563 let event: Value = match serde_json::from_str(line) {
1564 Ok(v) => v,
1565 Err(_) => continue,
1566 };
1567 let verified = verify_message_v31(&event, &trust).is_ok();
1568 let mut event_with_meta = event.clone();
1569 if let Some(obj) = event_with_meta.as_object_mut() {
1570 obj.insert("verified".into(), json!(verified));
1571 }
1572 let ts = event
1573 .get("timestamp")
1574 .and_then(Value::as_str)
1575 .unwrap_or("")
1576 .to_string();
1577 collected.push((ts, idx, event_with_meta));
1578 }
1579 }
1580 collected.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
1581
1582 let total = collected.len();
1583 let window: Vec<Value> = if limit == 0 {
1584 collected.into_iter().map(|(_, _, e)| e).collect()
1585 } else if oldest {
1586 collected
1587 .into_iter()
1588 .take(limit)
1589 .map(|(_, _, e)| e)
1590 .collect()
1591 } else {
1592 let start = total.saturating_sub(limit);
1593 collected
1594 .into_iter()
1595 .skip(start)
1596 .map(|(_, _, e)| e)
1597 .collect()
1598 };
1599 Ok(Value::Array(window))
1600}
1601
1602fn tool_verify(args: &Value) -> Result<Value, String> {
1603 use crate::config;
1604 use crate::signing::verify_message_v31;
1605
1606 let event_str = args
1607 .get("event")
1608 .and_then(Value::as_str)
1609 .ok_or("missing 'event'")?;
1610 let event: Value =
1611 serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1612 let trust = config::read_trust().map_err(|e| e.to_string())?;
1613 match verify_message_v31(&event, &trust) {
1614 Ok(()) => Ok(json!({"verified": true})),
1615 Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1616 }
1617}
1618
1619fn ensure_session_bootstrapped() {
1628 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
1629 return;
1630 }
1631 if crate::config::is_initialized().unwrap_or(false) {
1632 return; }
1634 let (did, relay_url, slot_id, slot_token) =
1635 match crate::pair_invite::ensure_self_with_relay(None) {
1636 Ok(t) => t,
1637 Err(_) => return, };
1639 if let Ok(card) = crate::config::read_agent_card() {
1640 let persona = crate::agent_card::display_handle_from_did(&did).to_string();
1641 let client = crate::relay_client::RelayClient::new(&relay_url);
1642 let _ = client.handle_claim_v2(&persona, &slot_id, &slot_token, None, &card, None);
1643 }
1644}
1645
1646fn tool_init(args: &Value) -> Result<Value, String> {
1647 let handle = args
1648 .get("handle")
1649 .and_then(Value::as_str)
1650 .ok_or("missing 'handle'")?;
1651 let name = args.get("name").and_then(Value::as_str);
1652 let relay = args.get("relay_url").and_then(Value::as_str);
1653 crate::pair_session::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1654}
1655
1656fn resolve_relay_url(args: &Value) -> Result<String, String> {
1660 if let Some(url) = args.get("relay_url").and_then(Value::as_str) {
1661 return Ok(url.to_string());
1662 }
1663 let state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1664 state["self"]["relay_url"]
1665 .as_str()
1666 .map(str::to_string)
1667 .ok_or_else(|| "no relay_url provided and no relay bound (call wire_init with relay_url, or pass relay_url here)".into())
1668}
1669
1670fn auto_init_if_needed(args: &Value) -> Result<(), String> {
1676 let initialized = crate::config::is_initialized().map_err(|e| e.to_string())?;
1677 if initialized {
1678 return Ok(());
1679 }
1680 let handle = args.get("handle").and_then(Value::as_str).ok_or(
1681 "not initialized — pass `handle` to auto-init, or call wire_init explicitly first",
1682 )?;
1683 let relay = args.get("relay_url").and_then(Value::as_str);
1684 crate::pair_session::init_self_idempotent(handle, None, relay)
1685 .map(|_| ())
1686 .map_err(|e| e.to_string())
1687}
1688
1689fn tool_pair_initiate(args: &Value) -> Result<Value, String> {
1690 use crate::pair_session::{
1691 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1692 };
1693
1694 store_sweep_expired();
1695 auto_init_if_needed(args)?;
1697
1698 let relay_url = resolve_relay_url(args)?;
1699 let max_wait = args
1700 .get("max_wait_secs")
1701 .and_then(Value::as_u64)
1702 .unwrap_or(30)
1703 .min(60);
1704
1705 let mut s = pair_session_open("host", &relay_url, None).map_err(|e| e.to_string())?;
1706 let code = s.code.clone();
1707
1708 let sas_opt = if max_wait > 0 {
1709 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1710 .map_err(|e| e.to_string())?
1711 } else {
1712 None
1713 };
1714
1715 let session_id = store_insert(s);
1716
1717 let mut out = json!({
1718 "session_id": session_id,
1719 "code_phrase": code,
1720 "relay_url": relay_url,
1721 });
1722 match sas_opt {
1723 Some(sas) => {
1724 out["state"] = json!("sas_ready");
1725 out["sas"] = json!(sas);
1726 out["next"] = json!(
1727 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel (voice/text). \
1728 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1729 );
1730 }
1731 None => {
1732 out["state"] = json!("waiting");
1733 out["next"] = json!(
1734 "Share the code_phrase with the user; ask them to read it to their peer (the peer pastes into wire_pair_join). \
1735 Poll wire_pair_check(session_id) until state='sas_ready'."
1736 );
1737 }
1738 }
1739 Ok(out)
1740}
1741
1742fn tool_pair_join(args: &Value) -> Result<Value, String> {
1743 use crate::pair_session::{
1744 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1745 };
1746
1747 store_sweep_expired();
1748 auto_init_if_needed(args)?;
1749
1750 let code = args
1751 .get("code_phrase")
1752 .and_then(Value::as_str)
1753 .ok_or("missing 'code_phrase'")?;
1754 let relay_url = resolve_relay_url(args)?;
1755 let max_wait = args
1756 .get("max_wait_secs")
1757 .and_then(Value::as_u64)
1758 .unwrap_or(30)
1759 .min(60);
1760
1761 let mut s = pair_session_open("guest", &relay_url, Some(code)).map_err(|e| e.to_string())?;
1762
1763 let sas_opt =
1764 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1765 .map_err(|e| e.to_string())?;
1766
1767 let session_id = store_insert(s);
1768
1769 let mut out = json!({
1770 "session_id": session_id,
1771 "relay_url": relay_url,
1772 });
1773 match sas_opt {
1774 Some(sas) => {
1775 out["state"] = json!("sas_ready");
1776 out["sas"] = json!(sas);
1777 out["next"] = json!(
1778 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel. \
1779 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1780 );
1781 }
1782 None => {
1783 out["state"] = json!("waiting");
1784 out["next"] = json!("Poll wire_pair_check(session_id).");
1785 }
1786 }
1787 Ok(out)
1788}
1789
1790fn tool_pair_check(args: &Value) -> Result<Value, String> {
1791 use crate::pair_session::{pair_session_wait_for_sas, store_get, store_sweep_expired};
1792
1793 store_sweep_expired();
1794 let session_id = args
1795 .get("session_id")
1796 .and_then(Value::as_str)
1797 .ok_or("missing 'session_id'")?;
1798 let max_wait = args
1799 .get("max_wait_secs")
1800 .and_then(Value::as_u64)
1801 .unwrap_or(8)
1802 .min(60);
1803
1804 let arc = store_get(session_id)
1805 .ok_or_else(|| format!("no such session_id (expired or never opened): {session_id}"))?;
1806 let mut s = arc.lock().map_err(|e| e.to_string())?;
1807
1808 if s.finalized {
1809 return Ok(json!({
1810 "state": "finalized",
1811 "session_id": session_id,
1812 "sas": s.formatted_sas(),
1813 }));
1814 }
1815 if let Some(reason) = s.aborted.clone() {
1816 return Ok(json!({
1817 "state": "aborted",
1818 "session_id": session_id,
1819 "reason": reason,
1820 }));
1821 }
1822
1823 let sas_opt =
1824 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1825 .map_err(|e| e.to_string())?;
1826
1827 Ok(match sas_opt {
1828 Some(sas) => json!({
1829 "state": "sas_ready",
1830 "session_id": session_id,
1831 "sas": sas,
1832 "next": "Have the user TYPE the 6 SAS digits BACK INTO CHAT, then pass to wire_pair_confirm."
1833 }),
1834 None => json!({
1835 "state": "waiting",
1836 "session_id": session_id,
1837 }),
1838 })
1839}
1840
1841fn tool_pair_confirm(args: &Value, state: &McpState) -> Result<Value, String> {
1842 use crate::pair_session::{
1843 pair_session_confirm_sas, pair_session_finalize, store_get, store_remove,
1844 };
1845
1846 let session_id = args
1847 .get("session_id")
1848 .and_then(Value::as_str)
1849 .ok_or("missing 'session_id'")?;
1850 let typed = args
1851 .get("user_typed_digits")
1852 .and_then(Value::as_str)
1853 .ok_or(
1854 "missing 'user_typed_digits' — the user must type the 6 SAS digits back into chat",
1855 )?;
1856
1857 let arc = store_get(session_id).ok_or_else(|| format!("no such session_id: {session_id}"))?;
1858
1859 let confirm_err = {
1860 let mut s = arc.lock().map_err(|e| e.to_string())?;
1861 match pair_session_confirm_sas(&mut s, typed) {
1862 Ok(()) => None,
1863 Err(e) => Some((s.aborted.is_some(), e.to_string())),
1864 }
1865 };
1866 if let Some((aborted, msg)) = confirm_err {
1867 if aborted {
1868 store_remove(session_id);
1869 }
1870 return Err(msg);
1871 }
1872
1873 let mut result = {
1874 let mut s = arc.lock().map_err(|e| e.to_string())?;
1875 pair_session_finalize(&mut s, 30).map_err(|e| e.to_string())?
1876 };
1877 store_remove(session_id);
1878
1879 let peer_handle = result["peer_handle"].as_str().unwrap_or("").to_string();
1888 let peer_uri = format!("wire://inbox/{peer_handle}");
1889
1890 let mut auto = json!({
1891 "subscribed": false,
1892 "daemon": "unknown",
1893 "notify": "unknown",
1894 "resources_list_changed_emitted": false,
1895 });
1896
1897 if !peer_handle.is_empty()
1898 && let Ok(mut g) = state.subscribed.lock()
1899 {
1900 g.insert(peer_uri.clone());
1901 auto["subscribed"] = json!(true);
1902 }
1903
1904 auto["daemon"] = match crate::ensure_up::ensure_daemon_running() {
1905 Ok(true) => json!("spawned"),
1906 Ok(false) => json!("already_running"),
1907 Err(e) => json!(format!("spawn_error: {e}")),
1908 };
1909 auto["notify"] = match crate::ensure_up::ensure_notify_running() {
1910 Ok(true) => json!("spawned"),
1911 Ok(false) => json!("already_running"),
1912 Err(e) => json!(format!("spawn_error: {e}")),
1913 };
1914
1915 if let Some(tx) = state.notif_tx.lock().ok().and_then(|g| g.clone()) {
1916 let notif = json!({
1917 "jsonrpc": "2.0",
1918 "method": "notifications/resources/list_changed",
1919 });
1920 if tx.send(notif.to_string()).is_ok() {
1921 auto["resources_list_changed_emitted"] = json!(true);
1922 }
1923 }
1924
1925 result["auto"] = auto;
1926 result["next"] = json!(
1927 "Done. Daemon + notify running, subscribed to peer inbox. Use wire_send/wire_tail \
1928 freely; new events arrive via notifications/resources/updated (where supported) and \
1929 OS toasts (always)."
1930 );
1931 Ok(result)
1932}
1933
1934fn tool_pair_initiate_detached(args: &Value) -> Result<Value, String> {
1937 auto_init_if_needed(args)?;
1938 let relay_url = resolve_relay_url(args)?;
1939 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1940 let _ = crate::ensure_up::ensure_daemon_running();
1941 }
1942 let code = crate::sas::generate_code_phrase();
1943 let code_hash = crate::pair_session::derive_code_hash(&code);
1944 let now = time::OffsetDateTime::now_utc()
1945 .format(&time::format_description::well_known::Rfc3339)
1946 .unwrap_or_default();
1947 let p = crate::pending_pair::PendingPair {
1948 code: code.clone(),
1949 code_hash,
1950 role: "host".to_string(),
1951 relay_url: relay_url.clone(),
1952 status: "request_host".to_string(),
1953 sas: None,
1954 peer_did: None,
1955 created_at: now,
1956 last_error: None,
1957 pair_id: None,
1958 our_slot_id: None,
1959 our_slot_token: None,
1960 spake2_seed_b64: None,
1961 };
1962 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1963 Ok(json!({
1964 "code_phrase": code,
1965 "relay_url": relay_url,
1966 "state": "queued",
1967 "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."
1968 }))
1969}
1970
1971fn tool_pair_join_detached(args: &Value) -> Result<Value, String> {
1972 auto_init_if_needed(args)?;
1973 let relay_url = resolve_relay_url(args)?;
1974 let code_phrase = args
1975 .get("code_phrase")
1976 .and_then(Value::as_str)
1977 .ok_or("missing 'code_phrase'")?;
1978 let code = crate::sas::parse_code_phrase(code_phrase)
1979 .map_err(|e| e.to_string())?
1980 .to_string();
1981 let code_hash = crate::pair_session::derive_code_hash(&code);
1982 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1983 let _ = crate::ensure_up::ensure_daemon_running();
1984 }
1985 let now = time::OffsetDateTime::now_utc()
1986 .format(&time::format_description::well_known::Rfc3339)
1987 .unwrap_or_default();
1988 let p = crate::pending_pair::PendingPair {
1989 code: code.clone(),
1990 code_hash,
1991 role: "guest".to_string(),
1992 relay_url: relay_url.clone(),
1993 status: "request_guest".to_string(),
1994 sas: None,
1995 peer_did: None,
1996 created_at: now,
1997 last_error: None,
1998 pair_id: None,
1999 our_slot_id: None,
2000 our_slot_token: None,
2001 spake2_seed_b64: None,
2002 };
2003 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
2004 Ok(json!({
2005 "code_phrase": code,
2006 "relay_url": relay_url,
2007 "state": "queued",
2008 "next": "Subscribe to wire://pending-pair/all; on sas_ready notification, surface digits to user and call wire_pair_confirm_detached."
2009 }))
2010}
2011
2012fn tool_pair_list_pending() -> Result<Value, String> {
2013 let items = crate::pending_pair::list_pending().map_err(|e| e.to_string())?;
2014 Ok(json!({"pending": items}))
2015}
2016
2017fn tool_pair_confirm_detached(args: &Value) -> Result<Value, String> {
2018 let code_phrase = args
2019 .get("code_phrase")
2020 .and_then(Value::as_str)
2021 .ok_or("missing 'code_phrase'")?;
2022 let typed = args
2023 .get("user_typed_digits")
2024 .and_then(Value::as_str)
2025 .ok_or("missing 'user_typed_digits'")?;
2026 let code = crate::sas::parse_code_phrase(code_phrase)
2027 .map_err(|e| e.to_string())?
2028 .to_string();
2029 let typed: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
2030 if typed.len() != 6 {
2031 return Err(format!(
2032 "expected 6 digits (got {} after stripping non-digits)",
2033 typed.len()
2034 ));
2035 }
2036 let mut p = crate::pending_pair::read_pending(&code)
2037 .map_err(|e| e.to_string())?
2038 .ok_or_else(|| format!("no pending pair for code {code}"))?;
2039 if p.status != "sas_ready" {
2040 return Err(format!(
2041 "pair {code} not in sas_ready state (current: {})",
2042 p.status
2043 ));
2044 }
2045 let stored = p
2046 .sas
2047 .as_ref()
2048 .ok_or("pending file has status=sas_ready but no sas field")?
2049 .clone();
2050 if stored == typed {
2051 p.status = "confirmed".to_string();
2052 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
2053 Ok(json!({
2054 "state": "confirmed",
2055 "code_phrase": code,
2056 "next": "Daemon will finalize on its next tick (~1s). Poll wire_peers or watch wire://pending-pair/all for the entry to disappear."
2057 }))
2058 } else {
2059 p.status = "aborted".to_string();
2060 p.last_error = Some(format!(
2061 "SAS digit mismatch (typed {typed}, expected {stored})"
2062 ));
2063 let client = crate::relay_client::RelayClient::new(&p.relay_url);
2064 let _ = client.pair_abandon(&p.code_hash);
2065 let _ = crate::pending_pair::write_pending(&p);
2066 crate::os_notify::toast(
2067 &format!("wire — pair aborted ({code})"),
2068 p.last_error.as_deref().unwrap_or("digits mismatch"),
2069 );
2070 Err(
2071 "digits mismatch — pair aborted. Re-issue with wire_pair_initiate_detached."
2072 .to_string(),
2073 )
2074 }
2075}
2076
2077fn tool_pair_cancel_pending(args: &Value) -> Result<Value, String> {
2078 let code_phrase = args
2079 .get("code_phrase")
2080 .and_then(Value::as_str)
2081 .ok_or("missing 'code_phrase'")?;
2082 let code = crate::sas::parse_code_phrase(code_phrase)
2083 .map_err(|e| e.to_string())?
2084 .to_string();
2085 if let Some(p) = crate::pending_pair::read_pending(&code).map_err(|e| e.to_string())? {
2086 let client = crate::relay_client::RelayClient::new(&p.relay_url);
2087 let _ = client.pair_abandon(&p.code_hash);
2088 }
2089 crate::pending_pair::delete_pending(&code).map_err(|e| e.to_string())?;
2090 Ok(json!({"state": "cancelled", "code_phrase": code}))
2091}
2092
2093fn tool_invite_mint(args: &Value) -> Result<Value, String> {
2096 let relay_url = args.get("relay_url").and_then(Value::as_str);
2097 let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
2098 let uses = args
2099 .get("uses")
2100 .and_then(Value::as_u64)
2101 .map(|u| u as u32)
2102 .unwrap_or(1);
2103 let url =
2104 crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
2105 let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
2106 Ok(json!({
2107 "invite_url": url,
2108 "ttl_secs": ttl_resolved,
2109 "uses": uses,
2110 }))
2111}
2112
2113fn tool_invite_accept(args: &Value) -> Result<Value, String> {
2114 let url = args
2115 .get("url")
2116 .and_then(Value::as_str)
2117 .ok_or("missing 'url'")?;
2118 crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
2119}
2120
2121fn tool_dial(args: &Value) -> Result<Value, String> {
2133 let name = args
2134 .get("name")
2135 .and_then(Value::as_str)
2136 .or_else(|| args.get("handle").and_then(Value::as_str))
2137 .ok_or("missing 'name'")?;
2138
2139 if name.contains('@') {
2140 let mut a = args.clone();
2142 if let Some(obj) = a.as_object_mut() {
2143 obj.insert("handle".into(), Value::String(name.to_string()));
2144 }
2145 return tool_add(&a);
2146 }
2147
2148 let relay_state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
2149 let pinned = relay_state
2150 .get("peers")
2151 .and_then(Value::as_object)
2152 .map(|m| m.contains_key(name))
2153 .unwrap_or(false);
2154 if pinned {
2155 return Ok(json!({
2156 "name_input": name,
2157 "status": "already_pinned",
2158 "peer_handle": name,
2159 }));
2160 }
2161
2162 Err(format!(
2163 "cannot resolve `{name}` over MCP: bare-nickname / local-sister dialling is not yet \
2164 wired into the MCP surface. Use a federation handle `{name}@<relay>`, or `wire_send` \
2165 (it auto-pairs on miss)."
2166 ))
2167}
2168
2169fn tool_add(args: &Value) -> Result<Value, String> {
2170 let handle = args
2171 .get("handle")
2172 .and_then(Value::as_str)
2173 .ok_or("missing 'handle'")?;
2174 let relay_override = args.get("relay_url").and_then(Value::as_str);
2175
2176 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
2177
2178 let (our_did, our_relay, our_slot_id, our_slot_token) =
2180 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
2181
2182 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
2184 .map_err(|e| format!("{e:#}"))?;
2185 let peer_card = resolved
2186 .get("card")
2187 .cloned()
2188 .ok_or("resolved missing card")?;
2189 let peer_did = resolved
2190 .get("did")
2191 .and_then(Value::as_str)
2192 .ok_or("resolved missing did")?
2193 .to_string();
2194 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
2195 let peer_slot_id = resolved
2196 .get("slot_id")
2197 .and_then(Value::as_str)
2198 .ok_or("resolved missing slot_id")?
2199 .to_string();
2200 let peer_relay = resolved
2201 .get("relay_url")
2202 .and_then(Value::as_str)
2203 .map(str::to_string)
2204 .or_else(|| relay_override.map(str::to_string))
2205 .unwrap_or_else(|| format!("https://{}", parsed.domain));
2206
2207 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
2209 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
2210 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
2211 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
2212 let existing_token = relay_state
2213 .get("peers")
2214 .and_then(|p| p.get(&peer_handle))
2215 .and_then(|p| p.get("slot_token"))
2216 .and_then(Value::as_str)
2217 .map(str::to_string)
2218 .unwrap_or_default();
2219 relay_state["peers"][&peer_handle] = json!({
2220 "relay_url": peer_relay,
2221 "slot_id": peer_slot_id,
2222 "slot_token": existing_token,
2223 });
2224 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
2225
2226 let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2228 let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
2229 let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
2230 let pk_b64 = our_card
2231 .get("verify_keys")
2232 .and_then(Value::as_object)
2233 .and_then(|m| m.values().next())
2234 .and_then(|v| v.get("key"))
2235 .and_then(Value::as_str)
2236 .ok_or("our card missing verify_keys[*].key")?;
2237 let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
2238 let now = time::OffsetDateTime::now_utc()
2239 .format(&time::format_description::well_known::Rfc3339)
2240 .unwrap_or_default();
2241 let event = json!({
2242 "timestamp": now,
2243 "from": our_did,
2244 "to": peer_did,
2245 "type": "pair_drop",
2246 "kind": 1100u32,
2247 "body": {
2248 "card": our_card,
2249 "relay_url": our_relay,
2250 "slot_id": our_slot_id,
2251 "slot_token": our_slot_token,
2252 },
2253 });
2254 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
2255 .map_err(|e| format!("{e:#}"))?;
2256
2257 let client = crate::relay_client::RelayClient::new(&peer_relay);
2258 let resp = client
2259 .handle_intro(&parsed.nick, &signed)
2260 .map_err(|e| format!("{e:#}"))?;
2261 let event_id = signed
2262 .get("event_id")
2263 .and_then(Value::as_str)
2264 .unwrap_or("")
2265 .to_string();
2266 Ok(json!({
2267 "handle": handle,
2268 "paired_with": peer_did,
2269 "peer_handle": peer_handle,
2270 "event_id": event_id,
2271 "drop_response": resp,
2272 "status": "drop_sent",
2273 }))
2274}
2275
2276fn tool_pair_accept(args: &Value) -> Result<Value, String> {
2281 let peer = args
2282 .get("peer")
2283 .and_then(Value::as_str)
2284 .ok_or("missing 'peer'")?;
2285 let nick = crate::agent_card::bare_handle(peer);
2286 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
2287 .map_err(|e| format!("{e:#}"))?
2288 .ok_or_else(|| {
2289 format!(
2290 "no pending pair request from {nick}. Call wire_pair_list_inbound to enumerate, \
2291 or wire_add to send a fresh outbound pair request."
2292 )
2293 })?;
2294
2295 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
2298 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
2299 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
2300
2301 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
2303 relay_state["peers"][&pending.peer_handle] = json!({
2304 "relay_url": pending.peer_relay_url,
2305 "slot_id": pending.peer_slot_id,
2306 "slot_token": pending.peer_slot_token,
2307 });
2308 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
2309
2310 let ack_endpoints: Vec<crate::endpoints::Endpoint> = if pending.peer_endpoints.is_empty() {
2317 vec![crate::endpoints::Endpoint::federation(
2318 pending.peer_relay_url.clone(),
2319 pending.peer_slot_id.clone(),
2320 pending.peer_slot_token.clone(),
2321 )]
2322 } else {
2323 pending.peer_endpoints.clone()
2324 };
2325 crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &ack_endpoints).map_err(|e| {
2326 format!(
2327 "pair_drop_ack send to {} (across {} endpoint(s)) failed: {e:#}",
2328 pending.peer_handle,
2329 ack_endpoints.len()
2330 )
2331 })?;
2332
2333 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2334
2335 Ok(json!({
2336 "status": "bilateral_accepted",
2337 "peer_handle": pending.peer_handle,
2338 "peer_did": pending.peer_did,
2339 "peer_relay_url": pending.peer_relay_url,
2340 "via": "pending_inbound",
2341 }))
2342}
2343
2344fn tool_pair_reject(args: &Value) -> Result<Value, String> {
2347 let peer = args
2348 .get("peer")
2349 .and_then(Value::as_str)
2350 .ok_or("missing 'peer'")?;
2351 let nick = crate::agent_card::bare_handle(peer);
2352 let existed =
2353 crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2354 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2355 Ok(json!({
2356 "peer": nick,
2357 "rejected": existed.is_some(),
2358 "had_pending": existed.is_some(),
2359 }))
2360}
2361
2362fn tool_pair_list_inbound() -> Result<Value, String> {
2365 let items =
2366 crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
2367 Ok(json!(items))
2368}
2369
2370fn tool_claim_handle(args: &Value) -> Result<Value, String> {
2371 let typed = args.get("nick").and_then(Value::as_str);
2372 let relay_override = args.get("relay_url").and_then(Value::as_str);
2373 let public_url = args.get("public_url").and_then(Value::as_str);
2374
2375 let (_, our_relay, our_slot_id, our_slot_token) =
2377 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
2378 let claim_relay = relay_override.unwrap_or(&our_relay);
2379 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2380
2381 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
2386 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
2387 let nick = if canonical.is_empty() {
2388 typed.unwrap_or_default().to_string()
2389 } else {
2390 canonical
2391 };
2392 let typed_nick_ignored = typed.map(|t| t != nick).unwrap_or(false);
2393
2394 let client = crate::relay_client::RelayClient::new(claim_relay);
2395 let resp = client
2396 .handle_claim(&nick, &our_slot_id, &our_slot_token, public_url, &card)
2397 .map_err(|e| format!("{e:#}"))?;
2398 Ok(json!({
2399 "nick": nick,
2400 "relay": claim_relay,
2401 "response": resp,
2402 "one_name": true,
2403 "typed_nick_ignored": typed_nick_ignored,
2404 }))
2405}
2406
2407fn tool_whois(args: &Value) -> Result<Value, String> {
2408 if let Some(handle) = args.get("handle").and_then(Value::as_str) {
2409 if !handle.contains('@')
2419 && let Ok(target) = crate::cli::resolve_name_to_target(handle)
2420 {
2421 return Ok(dial_target_to_whois_json(&target));
2422 }
2423 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
2424 let relay_override = args.get("relay_url").and_then(Value::as_str);
2425 crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
2426 } else {
2427 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2431 let mut payload = serde_json::Map::new();
2432 payload.insert(
2433 "did".into(),
2434 card.get("did").cloned().unwrap_or(Value::Null),
2435 );
2436 payload.insert(
2437 "profile".into(),
2438 card.get("profile").cloned().unwrap_or(Value::Null),
2439 );
2440 for (k, v) in crate::cli::op_claims_from_card(&card) {
2441 payload.insert(k, v);
2442 }
2443 Ok(Value::Object(payload))
2444 }
2445}
2446
2447fn dial_target_to_whois_json(target: &crate::cli::DialTarget) -> Value {
2453 use crate::cli::DialTarget;
2454 match target {
2455 DialTarget::PinnedPeer {
2456 handle,
2457 did,
2458 nickname,
2459 emoji,
2460 tier,
2461 } => {
2462 let op_claims = crate::config::read_trust()
2463 .ok()
2464 .and_then(|t| {
2465 t.get("agents")
2466 .and_then(Value::as_object)
2467 .and_then(|m| m.get(handle))
2468 .and_then(|a| a.get("card").cloned())
2469 })
2470 .map(|c| crate::cli::op_claims_from_card(&c))
2471 .unwrap_or_default();
2472 let mut payload = serde_json::Map::new();
2473 payload.insert("kind".into(), json!("pinned_peer"));
2474 payload.insert("handle".into(), json!(handle));
2475 payload.insert("did".into(), json!(did));
2476 payload.insert("nickname".into(), json!(nickname));
2477 payload.insert("emoji".into(), json!(emoji));
2478 payload.insert("tier".into(), json!(tier));
2479 for (k, v) in op_claims {
2480 payload.insert(k, v);
2481 }
2482 Value::Object(payload)
2483 }
2484 DialTarget::LocalSister {
2485 session_name,
2486 handle,
2487 did,
2488 nickname,
2489 emoji,
2490 } => json!({
2491 "kind": "local_sister",
2492 "session_name": session_name,
2493 "handle": handle,
2494 "did": did,
2495 "nickname": nickname,
2496 "emoji": emoji,
2497 }),
2498 }
2499}
2500
2501fn tool_profile_set(args: &Value) -> Result<Value, String> {
2502 let field = args
2503 .get("field")
2504 .and_then(Value::as_str)
2505 .ok_or("missing 'field'")?;
2506 let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
2507 let value = if let Some(s) = raw_value.as_str() {
2511 serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
2512 } else {
2513 raw_value
2514 };
2515 let new_profile =
2516 crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
2517 Ok(json!({
2518 "field": field,
2519 "profile": new_profile,
2520 }))
2521}
2522
2523fn tool_profile_get() -> Result<Value, String> {
2524 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2525 Ok(json!({
2526 "did": card.get("did").cloned().unwrap_or(Value::Null),
2527 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
2528 }))
2529}
2530
2531fn parse_kind(s: &str) -> u32 {
2534 if let Ok(n) = s.parse::<u32>() {
2535 return n;
2536 }
2537 for (id, name) in crate::signing::kinds() {
2538 if *name == s {
2539 return *id;
2540 }
2541 }
2542 1
2543}
2544
2545fn error_response(id: &Value, code: i32, message: &str) -> Value {
2546 json!({
2547 "jsonrpc": "2.0",
2548 "id": id,
2549 "error": {"code": code, "message": message}
2550 })
2551}
2552
2553#[cfg(test)]
2554mod tests {
2555 use super::*;
2556
2557 #[test]
2558 fn unknown_method_returns_jsonrpc_error() {
2559 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
2560 let resp = handle_request(&req, &McpState::default());
2561 assert_eq!(resp["error"]["code"], -32601);
2562 }
2563
2564 #[test]
2565 fn initialize_advertises_tools_capability() {
2566 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
2567 let resp = handle_request(&req, &McpState::default());
2568 assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
2569 assert!(resp["result"]["capabilities"]["tools"].is_object());
2570 assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
2571 }
2572
2573 #[test]
2574 fn tools_list_includes_pairing_and_messaging() {
2575 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2576 let resp = handle_request(&req, &McpState::default());
2577 let names: Vec<&str> = resp["result"]["tools"]
2578 .as_array()
2579 .unwrap()
2580 .iter()
2581 .filter_map(|t| t["name"].as_str())
2582 .collect();
2583 for required in [
2584 "wire_whoami",
2585 "wire_peers",
2586 "wire_send",
2587 "wire_tail",
2588 "wire_verify",
2589 "wire_init",
2590 "wire_pair_initiate",
2591 "wire_pair_join",
2592 "wire_pair_check",
2593 "wire_pair_confirm",
2594 ] {
2595 assert!(
2596 names.contains(&required),
2597 "missing required tool {required}"
2598 );
2599 }
2600 assert!(
2604 !names.contains(&"wire_join"),
2605 "wire_join must not be advertised — superseded by wire_pair_join"
2606 );
2607 }
2608
2609 #[test]
2610 fn legacy_wire_join_call_returns_helpful_error() {
2611 let req = json!({
2612 "jsonrpc": "2.0",
2613 "id": 1,
2614 "method": "tools/call",
2615 "params": {"name": "wire_join", "arguments": {}}
2616 });
2617 let resp = handle_request(&req, &McpState::default());
2618 assert_eq!(resp["result"]["isError"], true);
2619 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2620 assert!(
2621 text.contains("wire_pair_join"),
2622 "expected redirect to wire_pair_join, got: {text}"
2623 );
2624 }
2625
2626 #[test]
2627 fn pair_confirm_missing_session_id_errors_cleanly() {
2628 let req = json!({
2629 "jsonrpc": "2.0",
2630 "id": 1,
2631 "method": "tools/call",
2632 "params": {"name": "wire_pair_confirm", "arguments": {"user_typed_digits": "111111"}}
2633 });
2634 let resp = handle_request(&req, &McpState::default());
2635 assert_eq!(resp["result"]["isError"], true);
2636 }
2637
2638 #[test]
2639 fn pair_confirm_unknown_session_errors_cleanly() {
2640 let req = json!({
2641 "jsonrpc": "2.0",
2642 "id": 1,
2643 "method": "tools/call",
2644 "params": {
2645 "name": "wire_pair_confirm",
2646 "arguments": {"session_id": "definitely-not-real", "user_typed_digits": "111111"}
2647 }
2648 });
2649 let resp = handle_request(&req, &McpState::default());
2650 assert_eq!(resp["result"]["isError"], true);
2651 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2652 assert!(text.contains("no such session_id"), "got: {text}");
2653 }
2654
2655 #[test]
2656 fn initialize_advertises_resources_capability() {
2657 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
2658 let resp = handle_request(&req, &McpState::default());
2659 let caps = &resp["result"]["capabilities"];
2660 assert!(
2661 caps["resources"].is_object(),
2662 "resources capability must be present, got {resp}"
2663 );
2664 assert_eq!(
2665 caps["resources"]["subscribe"], true,
2666 "subscribe shipped in v0.2.1"
2667 );
2668 }
2669
2670 #[test]
2671 fn resources_read_with_bad_uri_errors() {
2672 let req = json!({
2673 "jsonrpc": "2.0",
2674 "id": 1,
2675 "method": "resources/read",
2676 "params": {"uri": "http://example.com/not-a-wire-uri"}
2677 });
2678 let resp = handle_request(&req, &McpState::default());
2679 assert!(resp.get("error").is_some(), "expected error, got {resp}");
2680 }
2681
2682 #[test]
2683 fn parse_inbox_uri_handles_variants() {
2684 assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
2685 assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
2686 assert!(
2687 parse_inbox_uri("wire://inbox/")
2688 .unwrap()
2689 .starts_with("__invalid__"),
2690 "empty peer must be invalid"
2691 );
2692 assert!(
2693 parse_inbox_uri("http://other")
2694 .unwrap()
2695 .starts_with("__invalid__"),
2696 "non-wire scheme must be invalid"
2697 );
2698 }
2699
2700 #[test]
2701 fn ping_returns_empty_result() {
2702 let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2703 let resp = handle_request(&req, &McpState::default());
2704 assert_eq!(resp["id"], 7);
2705 assert!(resp["result"].is_object());
2706 }
2707
2708 #[test]
2709 fn notification_returns_null_no_reply() {
2710 let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2711 let resp = handle_request(&req, &McpState::default());
2712 assert_eq!(resp, Value::Null);
2713 }
2714
2715 #[test]
2722 fn detect_session_wire_home_resolves_registered_cwd() {
2723 crate::config::test_support::with_temp_home(|| {
2724 let wire_home = std::env::var("WIRE_HOME").unwrap();
2728 let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2729 let session_home = sessions_root.join("test-alpha");
2730 std::fs::create_dir_all(&session_home).unwrap();
2731 let fake_cwd = "/tmp/fake-project-cwd-abc123";
2732 let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2733 std::fs::write(
2734 sessions_root.join("registry.json"),
2735 serde_json::to_vec_pretty(®istry).unwrap(),
2736 )
2737 .unwrap();
2738
2739 let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2741 assert_eq!(
2742 got.as_deref(),
2743 Some(session_home.as_path()),
2744 "registered cwd must resolve to session_home"
2745 );
2746
2747 let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2749 "/tmp/cwd-not-in-registry-xyz789",
2750 ));
2751 assert!(nope.is_none(), "unregistered cwd must return None");
2752
2753 let stale_cwd = "/tmp/stale-session-cwd";
2756 let stale_registry =
2757 json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2758 std::fs::write(
2759 sessions_root.join("registry.json"),
2760 serde_json::to_vec_pretty(&stale_registry).unwrap(),
2761 )
2762 .unwrap();
2763 let stale_got =
2764 crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2765 assert!(
2766 stale_got.is_none(),
2767 "registered cwd whose session dir is missing must return None"
2768 );
2769 });
2770 }
2771
2772 #[test]
2779 fn dial_target_to_whois_json_pinned_peer_shape() {
2780 let target = crate::cli::DialTarget::PinnedPeer {
2781 handle: "slate-lotus".into(),
2782 did: "did:wire:slate-lotus-88232017".into(),
2783 nickname: Some("slate-lotus".into()),
2784 emoji: Some("🪴".into()),
2785 tier: "VERIFIED".into(),
2786 };
2787 crate::config::test_support::with_temp_home(|| {
2788 let out = dial_target_to_whois_json(&target);
2789 assert_eq!(out.get("kind").and_then(Value::as_str), Some("pinned_peer"));
2790 assert_eq!(
2791 out.get("handle").and_then(Value::as_str),
2792 Some("slate-lotus")
2793 );
2794 assert_eq!(out.get("tier").and_then(Value::as_str), Some("VERIFIED"));
2795 assert!(out.get("op_did").is_none());
2799 });
2800 }
2801
2802 #[test]
2803 fn dial_target_to_whois_json_local_sister_shape() {
2804 let target = crate::cli::DialTarget::LocalSister {
2805 session_name: "vesper-valley".into(),
2806 handle: "vesper-valley".into(),
2807 did: Some("did:wire:vesper-valley-deadbeef".into()),
2808 nickname: Some("vesper-valley".into()),
2809 emoji: Some("🦌".into()),
2810 };
2811 let out = dial_target_to_whois_json(&target);
2812 assert_eq!(
2813 out.get("kind").and_then(Value::as_str),
2814 Some("local_sister")
2815 );
2816 assert_eq!(
2817 out.get("session_name").and_then(Value::as_str),
2818 Some("vesper-valley")
2819 );
2820 assert_eq!(
2821 out.get("did").and_then(Value::as_str),
2822 Some("did:wire:vesper-valley-deadbeef")
2823 );
2824 assert!(out.get("tier").is_none());
2827 assert!(out.get("op_did").is_none());
2828 }
2829}