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). For cross-machine pairing pass `<handle>@<relay>` to wire_dial. Bilateral: the peer must also accept (wire_accept or wire_dial back) before capability flows in both directions. INBOUND pair requests from strangers land in pending-inbound: call wire_pending to enumerate, surface to operator, then wire_accept or wire_reject. NEVER auto-accept inbound pair requests without operator consent — accepting grants the peer authenticated write access to this agent's inbox. Resources: 'wire://inbox/<peer>' exposes each pinned peer's verified inbox (JSONL). ON SESSION START, DO THIS IMMEDIATELY (every wire session): arm a PERSISTENT stream-watcher on `wire monitor --json` — in Claude Code, the Monitor tool with persistent:true — so peer messages surface live mid-session instead of on next manual poll. WHEN A PEER MESSAGE ARRIVES, reply to it in your own live context WITHOUT waiting for the operator to prompt you; this is how paired agents collaborate immediately. (Default filter strips pair_drop/pair_drop_ack/heartbeat noise.) Legacy MCP tools (wire_pair_accept / wire_pair_reject / wire_pair_list_inbound, wire_pair_initiate/join/confirm) still callable but DEPRECATED — prefer canonical. See docs/AGENT_INTEGRATION.md for the full monitor recipe and THREAT_MODEL.md (T10/T14)."
549 }
550 })
551}
552
553fn handle_tools_list(id: &Value) -> Value {
554 json!({
555 "jsonrpc": "2.0",
556 "id": id,
557 "result": {
558 "tools": tool_defs(),
559 }
560 })
561}
562
563fn tool_defs() -> Vec<Value> {
564 vec![
565 json!({
566 "name": "wire_whoami",
567 "description": "Return this agent's DID, fingerprint, key_id, public key, and capabilities. Read-only.",
568 "inputSchema": {"type": "object", "properties": {}, "required": []}
569 }),
570 json!({
571 "name": "wire_peers",
572 "description": "List pinned peers with their tier (UNTRUSTED/VERIFIED/ATTESTED) and advertised capabilities. Read-only.",
573 "inputSchema": {"type": "object", "properties": {}, "required": []}
574 }),
575 json!({
576 "name": "wire_send",
577 "description": "Sign and queue an event to a peer. Returns event_id (SHA-256 of canonical body — content-addressed, so identical bodies produce identical event_ids and the daemon dedupes). Body may be plain text or a JSON-encoded structured value. Concurrent sends to multiple peers are safe (per-peer outbox files); concurrent sends to the same peer are serialized via a per-path lock.",
578 "inputSchema": {
579 "type": "object",
580 "properties": {
581 "peer": {"type": "string", "description": "Peer handle (without did:wire: prefix). Must be a pinned peer; check wire_peers first."},
582 "kind": {"type": "string", "description": "Event kind: a name (decision, claim, ack, agent_card, trust_add_key, trust_revoke_key, wire_open, wire_close) or a numeric kind id."},
583 "body": {"type": "string", "description": "Event body. Plain text becomes a JSON string; valid JSON is parsed and embedded structurally."},
584 "time_sensitive_until": {"type": "string", "description": "Optional advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp."}
585 },
586 "required": ["peer", "kind", "body"]
587 }
588 }),
589 json!({
590 "name": "wire_tail",
591 "description": "Read recent signed events from this agent's inbox. Each event has a 'verified' field (bool) — the Ed25519 signature was checked against the trust state before the daemon wrote the inbox. **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).",
592 "inputSchema": {
593 "type": "object",
594 "properties": {
595 "peer": {"type": "string", "description": "Optional peer handle to filter inbox by."},
596 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Max events to return."},
597 "oldest": {"type": "boolean", "default": false, "description": "Return the FIRST `limit` events (oldest-N) instead of the default last-N (newest-N)."}
598 },
599 "required": []
600 }
601 }),
602 json!({
603 "name": "wire_verify",
604 "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).",
605 "inputSchema": {
606 "type": "object",
607 "properties": {
608 "event": {"type": "string", "description": "JSON-encoded signed event."}
609 },
610 "required": ["event"]
611 }
612 }),
613 json!({
614 "name": "wire_init",
615 "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.",
616 "inputSchema": {
617 "type": "object",
618 "properties": {
619 "handle": {"type": "string", "description": "Short handle (becomes did:wire:<handle>). ASCII alphanumeric / '-' / '_' only."},
620 "name": {"type": "string", "description": "Optional display name (defaults to capitalized handle)."},
621 "relay_url": {"type": "string", "description": "Optional relay URL — if set, also binds a relay slot."}
622 },
623 "required": ["handle"]
624 }
625 }),
626 json!({
627 "name": "wire_pair_initiate",
628 "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).",
629 "inputSchema": {
630 "type": "object",
631 "properties": {
632 "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
633 "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
634 "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."}
635 },
636 "required": []
637 }
638 }),
639 json!({
640 "name": "wire_pair_join",
641 "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.",
642 "inputSchema": {
643 "type": "object",
644 "properties": {
645 "code_phrase": {"type": "string", "description": "Code phrase from the host (e.g. '73-2QXC4P')."},
646 "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
647 "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
648 "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 30, "description": "How long to block waiting for SPAKE2 exchange to complete."}
649 },
650 "required": ["code_phrase"]
651 }
652 }),
653 json!({
654 "name": "wire_pair_check",
655 "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.",
656 "inputSchema": {
657 "type": "object",
658 "properties": {
659 "session_id": {"type": "string"},
660 "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 8}
661 },
662 "required": ["session_id"]
663 }
664 }),
665 json!({
666 "name": "wire_pair_confirm",
667 "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').",
668 "inputSchema": {
669 "type": "object",
670 "properties": {
671 "session_id": {"type": "string"},
672 "user_typed_digits": {"type": "string", "description": "The 6 SAS digits the user typed back, e.g. '384217' or '384-217'."}
673 },
674 "required": ["session_id", "user_typed_digits"]
675 }
676 }),
677 json!({
678 "name": "wire_pair_initiate_detached",
679 "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.",
680 "inputSchema": {
681 "type": "object",
682 "properties": {
683 "handle": {"type": "string", "description": "Optional handle for auto-init (idempotent)."},
684 "relay_url": {"type": "string"}
685 }
686 }
687 }),
688 json!({
689 "name": "wire_pair_join_detached",
690 "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.",
691 "inputSchema": {
692 "type": "object",
693 "properties": {
694 "handle": {"type": "string"},
695 "code_phrase": {"type": "string"},
696 "relay_url": {"type": "string"}
697 },
698 "required": ["code_phrase"]
699 }
700 }),
701 json!({
702 "name": "wire_pair_list_pending",
703 "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.",
704 "inputSchema": {"type": "object", "properties": {}}
705 }),
706 json!({
707 "name": "wire_pair_confirm_detached",
708 "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.",
709 "inputSchema": {
710 "type": "object",
711 "properties": {
712 "code_phrase": {"type": "string"},
713 "user_typed_digits": {"type": "string"}
714 },
715 "required": ["code_phrase", "user_typed_digits"]
716 }
717 }),
718 json!({
719 "name": "wire_pair_cancel_pending",
720 "description": "Cancel a pending detached pair. Releases the relay slot and removes the local pending file. Safe to call regardless of current status (idempotent).",
721 "inputSchema": {
722 "type": "object",
723 "properties": {"code_phrase": {"type": "string"}},
724 "required": ["code_phrase"]
725 }
726 }),
727 json!({
728 "name": "wire_invite_mint",
729 "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}.",
730 "inputSchema": {
731 "type": "object",
732 "properties": {
733 "relay_url": {"type": "string", "description": "Override relay for first-time auto-allocate."},
734 "ttl_secs": {"type": "integer", "description": "Invite lifetime in seconds (default 86400)."},
735 "uses": {"type": "integer", "description": "Number of distinct peers that can accept before consumption (default 1)."}
736 }
737 }
738 }),
739 json!({
740 "name": "wire_invite_accept",
741 "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}.",
742 "inputSchema": {
743 "type": "object",
744 "properties": {
745 "url": {"type": "string", "description": "Full wire://pair?v=1&inv=... URL."}
746 },
747 "required": ["url"]
748 }
749 }),
750 json!({
752 "name": "wire_add",
753 "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.",
754 "inputSchema": {
755 "type": "object",
756 "properties": {
757 "handle": {"type": "string", "description": "Peer handle like `nick@domain`."},
758 "relay_url": {"type": "string", "description": "Override resolver URL (default: `https://<domain>`)."}
759 },
760 "required": ["handle"]
761 }
762 }),
763 json!({
764 "name": "wire_pair_accept",
765 "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.",
766 "inputSchema": {
767 "type": "object",
768 "properties": {
769 "peer": {"type": "string", "description": "Bare peer handle (without `@<relay>`). Match exactly what `wire_pair_list_inbound` returned in `peer_handle`."}
770 },
771 "required": ["peer"]
772 }
773 }),
774 json!({
775 "name": "wire_pair_reject",
776 "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.",
777 "inputSchema": {
778 "type": "object",
779 "properties": {
780 "peer": {"type": "string", "description": "Bare peer handle (without `@<relay>`)."}
781 },
782 "required": ["peer"]
783 }
784 }),
785 json!({
786 "name": "wire_pair_list_inbound",
787 "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.",
788 "inputSchema": {"type": "object", "properties": {}}
789 }),
790 json!({
795 "name": "wire_dial",
796 "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.",
797 "inputSchema": {
798 "type": "object",
799 "properties": {
800 "name": {"type": "string", "description": "Peer name — character nickname / session / handle / DID / `<handle>@<relay>`."}
801 },
802 "required": ["name"]
803 }
804 }),
805 json!({
806 "name": "wire_accept",
807 "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.",
808 "inputSchema": {
809 "type": "object",
810 "properties": {
811 "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle, from wire_pending)."}
812 },
813 "required": ["peer"]
814 }
815 }),
816 json!({
817 "name": "wire_reject",
818 "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.",
819 "inputSchema": {
820 "type": "object",
821 "properties": {
822 "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle)."}
823 },
824 "required": ["peer"]
825 }
826 }),
827 json!({
828 "name": "wire_pending",
829 "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.",
830 "inputSchema": {"type": "object", "properties": {}}
831 }),
832 json!({
833 "name": "wire_claim",
834 "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).",
835 "inputSchema": {
836 "type": "object",
837 "properties": {
838 "nick": {"type": "string", "description": "Optional + advisory. Ignored if it differs from your DID-derived persona (one-name rule)."},
839 "relay_url": {"type": "string", "description": "Relay to claim on. Default = our relay."},
840 "public_url": {"type": "string", "description": "Public URL the relay should advertise to resolvers."}
841 }
842 }
843 }),
844 json!({
845 "name": "wire_whois",
846 "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.",
847 "inputSchema": {
848 "type": "object",
849 "properties": {
850 "handle": {"type": "string", "description": "Optional `nick@domain`. Omit for self."},
851 "relay_url": {"type": "string", "description": "Override resolver URL."}
852 }
853 }
854 }),
855 json!({
856 "name": "wire_profile_set",
857 "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.",
858 "inputSchema": {
859 "type": "object",
860 "properties": {
861 "field": {"type": "string", "description": "One of: display_name, emoji, motto, vibe, pronouns, avatar_url, handle, now."},
862 "value": {"description": "String for most fields; array for vibe; object for now. Pass JSON null to clear a field."}
863 },
864 "required": ["field", "value"]
865 }
866 }),
867 json!({
868 "name": "wire_profile_get",
869 "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.",
870 "inputSchema": {"type": "object", "properties": {}}
871 }),
872 json!({
877 "name": "wire_group_create",
878 "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.",
879 "inputSchema": {
880 "type": "object",
881 "properties": {"name": {"type": "string", "description": "Human label for the group."}},
882 "required": ["name"]
883 }
884 }),
885 json!({
886 "name": "wire_group_add",
887 "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.",
888 "inputSchema": {
889 "type": "object",
890 "properties": {
891 "group": {"type": "string", "description": "Group id or name."},
892 "peer": {"type": "string", "description": "Handle of a VERIFIED pinned peer."}
893 },
894 "required": ["group", "peer"]
895 }
896 }),
897 json!({
898 "name": "wire_group_send",
899 "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).",
900 "inputSchema": {
901 "type": "object",
902 "properties": {
903 "group": {"type": "string", "description": "Group id or name."},
904 "message": {"type": "string", "description": "Message text."}
905 },
906 "required": ["group", "message"]
907 }
908 }),
909 json!({
910 "name": "wire_group_tail",
911 "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.",
912 "inputSchema": {
913 "type": "object",
914 "properties": {
915 "group": {"type": "string", "description": "Group id or name."},
916 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 20, "description": "Max timeline entries to return."}
917 },
918 "required": ["group"]
919 }
920 }),
921 json!({
922 "name": "wire_group_list",
923 "description": "List the groups this agent is in, with each group's members and their GroupTiers (creator/member/introduced). Read-only, local.",
924 "inputSchema": {"type": "object", "properties": {}, "required": []}
925 }),
926 json!({
927 "name": "wire_group_invite",
928 "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.",
929 "inputSchema": {
930 "type": "object",
931 "properties": {"group": {"type": "string", "description": "Group id or name."}},
932 "required": ["group"]
933 }
934 }),
935 json!({
936 "name": "wire_group_join",
937 "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.",
938 "inputSchema": {
939 "type": "object",
940 "properties": {"code": {"type": "string", "description": "The `wire-group:` join code."}},
941 "required": ["code"]
942 }
943 }),
944 ]
945}
946
947fn handle_tools_call(id: &Value, params: &Value, state: &McpState) -> Value {
948 let name = match params.get("name").and_then(Value::as_str) {
949 Some(n) => n,
950 None => return error_response(id, -32602, "missing tool name"),
951 };
952 let args = params
953 .get("arguments")
954 .cloned()
955 .unwrap_or_else(|| json!({}));
956
957 let result = match name {
958 "wire_whoami" => tool_whoami(),
959 "wire_peers" => tool_peers(),
960 "wire_send" => tool_send(&args),
961 "wire_tail" => tool_tail(&args),
962 "wire_verify" => tool_verify(&args),
963 "wire_init" => tool_init(&args),
964 "wire_pair_initiate" => tool_pair_initiate(&args),
965 "wire_pair_join" => tool_pair_join(&args),
966 "wire_pair_check" => tool_pair_check(&args),
967 "wire_pair_confirm" => tool_pair_confirm(&args, state),
968 "wire_pair_initiate_detached" => tool_pair_initiate_detached(&args),
969 "wire_pair_join_detached" => tool_pair_join_detached(&args),
970 "wire_pair_list_pending" => tool_pair_list_pending(),
971 "wire_pair_confirm_detached" => tool_pair_confirm_detached(&args),
972 "wire_pair_cancel_pending" => tool_pair_cancel_pending(&args),
973 "wire_invite_mint" => tool_invite_mint(&args),
974 "wire_invite_accept" => tool_invite_accept(&args),
975 "wire_add" => tool_add(&args),
977 "wire_pair_accept" | "wire_accept" => tool_pair_accept(&args),
983 "wire_pair_reject" | "wire_reject" => tool_pair_reject(&args),
984 "wire_pair_list_inbound" | "wire_pending" => tool_pair_list_inbound(),
985 "wire_dial" => tool_dial(&args),
986 "wire_claim" => tool_claim_handle(&args),
987 "wire_whois" => tool_whois(&args),
988 "wire_profile_set" => tool_profile_set(&args),
989 "wire_profile_get" => tool_profile_get(),
990 "wire_group_create" => tool_group_create(&args),
992 "wire_group_add" => tool_group_add(&args),
993 "wire_group_send" => tool_group_send(&args),
994 "wire_group_tail" => tool_group_tail(&args),
995 "wire_group_list" => tool_group_list(),
996 "wire_group_invite" => tool_group_invite(&args),
997 "wire_group_join" => tool_group_join(&args),
998 "wire_join" => Err(
1001 "wire_join was renamed to wire_pair_join (use code_phrase argument). \
1002 See docs/AGENT_INTEGRATION.md."
1003 .into(),
1004 ),
1005 other => Err(format!("unknown tool: {other}")),
1006 };
1007
1008 match result {
1009 Ok(value) => json!({
1010 "jsonrpc": "2.0",
1011 "id": id,
1012 "result": {
1013 "content": [{
1014 "type": "text",
1015 "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
1016 }],
1017 "isError": false
1018 }
1019 }),
1020 Err(message) => json!({
1021 "jsonrpc": "2.0",
1022 "id": id,
1023 "result": {
1024 "content": [{"type": "text", "text": message}],
1025 "isError": true
1026 }
1027 }),
1028 }
1029}
1030
1031fn tool_whoami() -> Result<Value, String> {
1034 use crate::config;
1035 use crate::signing::{b64decode, fingerprint, make_key_id};
1036
1037 if !config::is_initialized().map_err(|e| e.to_string())? {
1038 return Err("not initialized — operator must run `wire init <handle>` first".into());
1039 }
1040 let card = config::read_agent_card().map_err(|e| e.to_string())?;
1041 let did = card
1042 .get("did")
1043 .and_then(Value::as_str)
1044 .unwrap_or("")
1045 .to_string();
1046 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1047 let pk_b64 = card
1048 .get("verify_keys")
1049 .and_then(Value::as_object)
1050 .and_then(|m| m.values().next())
1051 .and_then(|v| v.get("key"))
1052 .and_then(Value::as_str)
1053 .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
1054 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1055 let fp = fingerprint(&pk_bytes);
1056 let key_id = make_key_id(&handle, &pk_bytes);
1057 let capabilities = card
1058 .get("capabilities")
1059 .cloned()
1060 .unwrap_or_else(|| json!(["wire/v3.2"]));
1061 let persona =
1065 serde_json::to_value(crate::character::Character::from_card(&card)).unwrap_or(Value::Null);
1066 Ok(json!({
1067 "did": did,
1068 "handle": handle,
1069 "persona": persona,
1070 "fingerprint": fp,
1071 "key_id": key_id,
1072 "public_key_b64": pk_b64,
1073 "capabilities": capabilities,
1074 }))
1075}
1076
1077fn tool_peers() -> Result<Value, String> {
1078 use crate::config;
1079 use crate::trust::get_tier;
1080
1081 let trust = config::read_trust().map_err(|e| e.to_string())?;
1082 let agents = trust
1083 .get("agents")
1084 .and_then(Value::as_object)
1085 .cloned()
1086 .unwrap_or_default();
1087 let mut self_did: Option<String> = None;
1088 if let Ok(card) = config::read_agent_card() {
1089 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
1090 }
1091 let mut peers = Vec::new();
1092 for (handle, agent) in agents.iter() {
1093 let did = agent
1094 .get("did")
1095 .and_then(Value::as_str)
1096 .unwrap_or("")
1097 .to_string();
1098 if Some(did.as_str()) == self_did.as_deref() {
1099 continue;
1100 }
1101 let persona = match agent.get("card") {
1105 Some(c) => crate::character::Character::from_card(c),
1106 None => crate::character::Character::from_did(&did),
1107 };
1108 peers.push(json!({
1109 "handle": handle,
1110 "persona": serde_json::to_value(&persona).unwrap_or(Value::Null),
1111 "did": did,
1112 "tier": get_tier(&trust, handle),
1113 "capabilities": agent.get("card").and_then(|c| c.get("capabilities")).cloned().unwrap_or_else(|| json!([])),
1114 }));
1115 }
1116 Ok(json!(peers))
1117}
1118
1119fn group_cli_json(args: &[&str]) -> Result<Value, String> {
1124 let exe = std::env::current_exe().map_err(|e| format!("locating wire binary: {e}"))?;
1125 let out = std::process::Command::new(exe)
1126 .arg("group")
1127 .args(args)
1128 .arg("--json")
1129 .env("WIRE_QUIET_AUTOSESSION", "1") .output()
1131 .map_err(|e| format!("spawning `wire group`: {e}"))?;
1132 if !out.status.success() {
1133 let err = String::from_utf8_lossy(&out.stderr);
1134 return Err(err.trim().to_string());
1135 }
1136 let s = String::from_utf8_lossy(&out.stdout);
1137 let line = s
1139 .lines()
1140 .rev()
1141 .find(|l| l.trim_start().starts_with('{'))
1142 .unwrap_or("{}");
1143 serde_json::from_str(line).map_err(|e| format!("parsing `wire group` output: {e}"))
1144}
1145
1146fn tool_group_create(args: &Value) -> Result<Value, String> {
1147 let name = args
1148 .get("name")
1149 .and_then(Value::as_str)
1150 .ok_or("missing 'name'")?;
1151 group_cli_json(&["create", name])
1152}
1153
1154fn tool_group_add(args: &Value) -> Result<Value, String> {
1155 let group = args
1156 .get("group")
1157 .and_then(Value::as_str)
1158 .ok_or("missing 'group'")?;
1159 let peer = args
1160 .get("peer")
1161 .and_then(Value::as_str)
1162 .ok_or("missing 'peer'")?;
1163 group_cli_json(&["add", group, peer])
1164}
1165
1166fn tool_group_send(args: &Value) -> Result<Value, String> {
1167 let group = args
1168 .get("group")
1169 .and_then(Value::as_str)
1170 .ok_or("missing 'group'")?;
1171 let message = args
1172 .get("message")
1173 .and_then(Value::as_str)
1174 .ok_or("missing 'message'")?;
1175 group_cli_json(&["send", group, message])
1176}
1177
1178fn tool_group_tail(args: &Value) -> Result<Value, String> {
1179 let group = args
1180 .get("group")
1181 .and_then(Value::as_str)
1182 .ok_or("missing 'group'")?;
1183 if let Some(n) = args.get("limit").and_then(Value::as_u64) {
1184 group_cli_json(&["tail", group, "--limit", &n.to_string()])
1185 } else {
1186 group_cli_json(&["tail", group])
1187 }
1188}
1189
1190fn tool_group_list() -> Result<Value, String> {
1191 group_cli_json(&["list"])
1192}
1193
1194fn tool_group_invite(args: &Value) -> Result<Value, String> {
1195 let group = args
1196 .get("group")
1197 .and_then(Value::as_str)
1198 .ok_or("missing 'group'")?;
1199 group_cli_json(&["invite", group])
1200}
1201
1202fn tool_group_join(args: &Value) -> Result<Value, String> {
1203 let code = args
1204 .get("code")
1205 .and_then(Value::as_str)
1206 .ok_or("missing 'code'")?;
1207 group_cli_json(&["join", code])
1208}
1209
1210fn tool_send(args: &Value) -> Result<Value, String> {
1211 use crate::config;
1212 use crate::signing::{b64decode, sign_message_v31};
1213
1214 let peer = args
1215 .get("peer")
1216 .and_then(Value::as_str)
1217 .ok_or("missing 'peer'")?;
1218 let peer = crate::agent_card::bare_handle(peer);
1219 let kind = args
1220 .get("kind")
1221 .and_then(Value::as_str)
1222 .ok_or("missing 'kind'")?;
1223 let body = args
1224 .get("body")
1225 .and_then(Value::as_str)
1226 .ok_or("missing 'body'")?;
1227 let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
1228
1229 if !config::is_initialized().map_err(|e| e.to_string())? {
1230 return Err("not initialized — operator must run `wire init <handle>` first".into());
1231 }
1232 let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
1233 let card = config::read_agent_card().map_err(|e| e.to_string())?;
1234 let did = card
1235 .get("did")
1236 .and_then(Value::as_str)
1237 .unwrap_or("")
1238 .to_string();
1239 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1240 let pk_b64 = card
1241 .get("verify_keys")
1242 .and_then(Value::as_object)
1243 .and_then(|m| m.values().next())
1244 .and_then(|v| v.get("key"))
1245 .and_then(Value::as_str)
1246 .ok_or("agent-card missing verify_keys[*].key")?;
1247 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1248
1249 let body_value: Value =
1251 serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1252 let kind_id = parse_kind(kind);
1253
1254 let now = time::OffsetDateTime::now_utc()
1255 .format(&time::format_description::well_known::Rfc3339)
1256 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1257
1258 let mut event = json!({
1259 "timestamp": now,
1260 "from": did,
1261 "to": format!("did:wire:{peer}"),
1262 "type": kind,
1263 "kind": kind_id,
1264 "body": body_value,
1265 });
1266 if let Some(deadline) = deadline {
1267 event["time_sensitive_until"] =
1268 json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1269 }
1270 let signed =
1271 sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1272 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1273
1274 let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1275 let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1276
1277 Ok(json!({
1278 "event_id": event_id,
1279 "status": "queued",
1280 "peer": peer,
1281 "outbox": outbox.to_string_lossy(),
1282 }))
1283}
1284
1285fn tool_tail(args: &Value) -> Result<Value, String> {
1286 use crate::config;
1287 use crate::signing::verify_message_v31;
1288
1289 let peer_filter = args.get("peer").and_then(Value::as_str);
1290 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1291 let oldest = args.get("oldest").and_then(Value::as_bool).unwrap_or(false);
1296 let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1297 if !inbox.exists() {
1298 return Ok(json!([]));
1299 }
1300 let trust = config::read_trust().map_err(|e| e.to_string())?;
1301 let entries: Vec<_> = std::fs::read_dir(&inbox)
1302 .map_err(|e| e.to_string())?
1303 .filter_map(|e| e.ok())
1304 .map(|e| e.path())
1305 .filter(|p| {
1306 p.extension().map(|x| x == "jsonl").unwrap_or(false)
1307 && match peer_filter {
1308 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1309 None => true,
1310 }
1311 })
1312 .collect();
1313
1314 let mut collected: Vec<(String, usize, Value)> = Vec::new();
1317 for path in &entries {
1318 let body = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1319 for (idx, line) in body.lines().enumerate() {
1320 let event: Value = match serde_json::from_str(line) {
1321 Ok(v) => v,
1322 Err(_) => continue,
1323 };
1324 let verified = verify_message_v31(&event, &trust).is_ok();
1325 let mut event_with_meta = event.clone();
1326 if let Some(obj) = event_with_meta.as_object_mut() {
1327 obj.insert("verified".into(), json!(verified));
1328 }
1329 let ts = event
1330 .get("timestamp")
1331 .and_then(Value::as_str)
1332 .unwrap_or("")
1333 .to_string();
1334 collected.push((ts, idx, event_with_meta));
1335 }
1336 }
1337 collected.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
1338
1339 let total = collected.len();
1340 let window: Vec<Value> = if limit == 0 {
1341 collected.into_iter().map(|(_, _, e)| e).collect()
1342 } else if oldest {
1343 collected
1344 .into_iter()
1345 .take(limit)
1346 .map(|(_, _, e)| e)
1347 .collect()
1348 } else {
1349 let start = total.saturating_sub(limit);
1350 collected
1351 .into_iter()
1352 .skip(start)
1353 .map(|(_, _, e)| e)
1354 .collect()
1355 };
1356 Ok(Value::Array(window))
1357}
1358
1359fn tool_verify(args: &Value) -> Result<Value, String> {
1360 use crate::config;
1361 use crate::signing::verify_message_v31;
1362
1363 let event_str = args
1364 .get("event")
1365 .and_then(Value::as_str)
1366 .ok_or("missing 'event'")?;
1367 let event: Value =
1368 serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1369 let trust = config::read_trust().map_err(|e| e.to_string())?;
1370 match verify_message_v31(&event, &trust) {
1371 Ok(()) => Ok(json!({"verified": true})),
1372 Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1373 }
1374}
1375
1376fn ensure_session_bootstrapped() {
1385 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
1386 return;
1387 }
1388 if crate::config::is_initialized().unwrap_or(false) {
1389 return; }
1391 let (did, relay_url, slot_id, slot_token) =
1392 match crate::pair_invite::ensure_self_with_relay(None) {
1393 Ok(t) => t,
1394 Err(_) => return, };
1396 if let Ok(card) = crate::config::read_agent_card() {
1397 let persona = crate::agent_card::display_handle_from_did(&did).to_string();
1398 let client = crate::relay_client::RelayClient::new(&relay_url);
1399 let _ = client.handle_claim_v2(&persona, &slot_id, &slot_token, None, &card, None);
1400 }
1401}
1402
1403fn tool_init(args: &Value) -> Result<Value, String> {
1404 let handle = args
1405 .get("handle")
1406 .and_then(Value::as_str)
1407 .ok_or("missing 'handle'")?;
1408 let name = args.get("name").and_then(Value::as_str);
1409 let relay = args.get("relay_url").and_then(Value::as_str);
1410 crate::pair_session::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1411}
1412
1413fn resolve_relay_url(args: &Value) -> Result<String, String> {
1417 if let Some(url) = args.get("relay_url").and_then(Value::as_str) {
1418 return Ok(url.to_string());
1419 }
1420 let state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1421 state["self"]["relay_url"]
1422 .as_str()
1423 .map(str::to_string)
1424 .ok_or_else(|| "no relay_url provided and no relay bound (call wire_init with relay_url, or pass relay_url here)".into())
1425}
1426
1427fn auto_init_if_needed(args: &Value) -> Result<(), String> {
1433 let initialized = crate::config::is_initialized().map_err(|e| e.to_string())?;
1434 if initialized {
1435 return Ok(());
1436 }
1437 let handle = args.get("handle").and_then(Value::as_str).ok_or(
1438 "not initialized — pass `handle` to auto-init, or call wire_init explicitly first",
1439 )?;
1440 let relay = args.get("relay_url").and_then(Value::as_str);
1441 crate::pair_session::init_self_idempotent(handle, None, relay)
1442 .map(|_| ())
1443 .map_err(|e| e.to_string())
1444}
1445
1446fn tool_pair_initiate(args: &Value) -> Result<Value, String> {
1447 use crate::pair_session::{
1448 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1449 };
1450
1451 store_sweep_expired();
1452 auto_init_if_needed(args)?;
1454
1455 let relay_url = resolve_relay_url(args)?;
1456 let max_wait = args
1457 .get("max_wait_secs")
1458 .and_then(Value::as_u64)
1459 .unwrap_or(30)
1460 .min(60);
1461
1462 let mut s = pair_session_open("host", &relay_url, None).map_err(|e| e.to_string())?;
1463 let code = s.code.clone();
1464
1465 let sas_opt = if max_wait > 0 {
1466 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1467 .map_err(|e| e.to_string())?
1468 } else {
1469 None
1470 };
1471
1472 let session_id = store_insert(s);
1473
1474 let mut out = json!({
1475 "session_id": session_id,
1476 "code_phrase": code,
1477 "relay_url": relay_url,
1478 });
1479 match sas_opt {
1480 Some(sas) => {
1481 out["state"] = json!("sas_ready");
1482 out["sas"] = json!(sas);
1483 out["next"] = json!(
1484 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel (voice/text). \
1485 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1486 );
1487 }
1488 None => {
1489 out["state"] = json!("waiting");
1490 out["next"] = json!(
1491 "Share the code_phrase with the user; ask them to read it to their peer (the peer pastes into wire_pair_join). \
1492 Poll wire_pair_check(session_id) until state='sas_ready'."
1493 );
1494 }
1495 }
1496 Ok(out)
1497}
1498
1499fn tool_pair_join(args: &Value) -> Result<Value, String> {
1500 use crate::pair_session::{
1501 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1502 };
1503
1504 store_sweep_expired();
1505 auto_init_if_needed(args)?;
1506
1507 let code = args
1508 .get("code_phrase")
1509 .and_then(Value::as_str)
1510 .ok_or("missing 'code_phrase'")?;
1511 let relay_url = resolve_relay_url(args)?;
1512 let max_wait = args
1513 .get("max_wait_secs")
1514 .and_then(Value::as_u64)
1515 .unwrap_or(30)
1516 .min(60);
1517
1518 let mut s = pair_session_open("guest", &relay_url, Some(code)).map_err(|e| e.to_string())?;
1519
1520 let sas_opt =
1521 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1522 .map_err(|e| e.to_string())?;
1523
1524 let session_id = store_insert(s);
1525
1526 let mut out = json!({
1527 "session_id": session_id,
1528 "relay_url": relay_url,
1529 });
1530 match sas_opt {
1531 Some(sas) => {
1532 out["state"] = json!("sas_ready");
1533 out["sas"] = json!(sas);
1534 out["next"] = json!(
1535 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel. \
1536 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1537 );
1538 }
1539 None => {
1540 out["state"] = json!("waiting");
1541 out["next"] = json!("Poll wire_pair_check(session_id).");
1542 }
1543 }
1544 Ok(out)
1545}
1546
1547fn tool_pair_check(args: &Value) -> Result<Value, String> {
1548 use crate::pair_session::{pair_session_wait_for_sas, store_get, store_sweep_expired};
1549
1550 store_sweep_expired();
1551 let session_id = args
1552 .get("session_id")
1553 .and_then(Value::as_str)
1554 .ok_or("missing 'session_id'")?;
1555 let max_wait = args
1556 .get("max_wait_secs")
1557 .and_then(Value::as_u64)
1558 .unwrap_or(8)
1559 .min(60);
1560
1561 let arc = store_get(session_id)
1562 .ok_or_else(|| format!("no such session_id (expired or never opened): {session_id}"))?;
1563 let mut s = arc.lock().map_err(|e| e.to_string())?;
1564
1565 if s.finalized {
1566 return Ok(json!({
1567 "state": "finalized",
1568 "session_id": session_id,
1569 "sas": s.formatted_sas(),
1570 }));
1571 }
1572 if let Some(reason) = s.aborted.clone() {
1573 return Ok(json!({
1574 "state": "aborted",
1575 "session_id": session_id,
1576 "reason": reason,
1577 }));
1578 }
1579
1580 let sas_opt =
1581 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1582 .map_err(|e| e.to_string())?;
1583
1584 Ok(match sas_opt {
1585 Some(sas) => json!({
1586 "state": "sas_ready",
1587 "session_id": session_id,
1588 "sas": sas,
1589 "next": "Have the user TYPE the 6 SAS digits BACK INTO CHAT, then pass to wire_pair_confirm."
1590 }),
1591 None => json!({
1592 "state": "waiting",
1593 "session_id": session_id,
1594 }),
1595 })
1596}
1597
1598fn tool_pair_confirm(args: &Value, state: &McpState) -> Result<Value, String> {
1599 use crate::pair_session::{
1600 pair_session_confirm_sas, pair_session_finalize, store_get, store_remove,
1601 };
1602
1603 let session_id = args
1604 .get("session_id")
1605 .and_then(Value::as_str)
1606 .ok_or("missing 'session_id'")?;
1607 let typed = args
1608 .get("user_typed_digits")
1609 .and_then(Value::as_str)
1610 .ok_or(
1611 "missing 'user_typed_digits' — the user must type the 6 SAS digits back into chat",
1612 )?;
1613
1614 let arc = store_get(session_id).ok_or_else(|| format!("no such session_id: {session_id}"))?;
1615
1616 let confirm_err = {
1617 let mut s = arc.lock().map_err(|e| e.to_string())?;
1618 match pair_session_confirm_sas(&mut s, typed) {
1619 Ok(()) => None,
1620 Err(e) => Some((s.aborted.is_some(), e.to_string())),
1621 }
1622 };
1623 if let Some((aborted, msg)) = confirm_err {
1624 if aborted {
1625 store_remove(session_id);
1626 }
1627 return Err(msg);
1628 }
1629
1630 let mut result = {
1631 let mut s = arc.lock().map_err(|e| e.to_string())?;
1632 pair_session_finalize(&mut s, 30).map_err(|e| e.to_string())?
1633 };
1634 store_remove(session_id);
1635
1636 let peer_handle = result["peer_handle"].as_str().unwrap_or("").to_string();
1645 let peer_uri = format!("wire://inbox/{peer_handle}");
1646
1647 let mut auto = json!({
1648 "subscribed": false,
1649 "daemon": "unknown",
1650 "notify": "unknown",
1651 "resources_list_changed_emitted": false,
1652 });
1653
1654 if !peer_handle.is_empty()
1655 && let Ok(mut g) = state.subscribed.lock()
1656 {
1657 g.insert(peer_uri.clone());
1658 auto["subscribed"] = json!(true);
1659 }
1660
1661 auto["daemon"] = match crate::ensure_up::ensure_daemon_running() {
1662 Ok(true) => json!("spawned"),
1663 Ok(false) => json!("already_running"),
1664 Err(e) => json!(format!("spawn_error: {e}")),
1665 };
1666 auto["notify"] = match crate::ensure_up::ensure_notify_running() {
1667 Ok(true) => json!("spawned"),
1668 Ok(false) => json!("already_running"),
1669 Err(e) => json!(format!("spawn_error: {e}")),
1670 };
1671
1672 if let Some(tx) = state.notif_tx.lock().ok().and_then(|g| g.clone()) {
1673 let notif = json!({
1674 "jsonrpc": "2.0",
1675 "method": "notifications/resources/list_changed",
1676 });
1677 if tx.send(notif.to_string()).is_ok() {
1678 auto["resources_list_changed_emitted"] = json!(true);
1679 }
1680 }
1681
1682 result["auto"] = auto;
1683 result["next"] = json!(
1684 "Done. Daemon + notify running, subscribed to peer inbox. Use wire_send/wire_tail \
1685 freely; new events arrive via notifications/resources/updated (where supported) and \
1686 OS toasts (always)."
1687 );
1688 Ok(result)
1689}
1690
1691fn tool_pair_initiate_detached(args: &Value) -> Result<Value, String> {
1694 auto_init_if_needed(args)?;
1695 let relay_url = resolve_relay_url(args)?;
1696 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1697 let _ = crate::ensure_up::ensure_daemon_running();
1698 }
1699 let code = crate::sas::generate_code_phrase();
1700 let code_hash = crate::pair_session::derive_code_hash(&code);
1701 let now = time::OffsetDateTime::now_utc()
1702 .format(&time::format_description::well_known::Rfc3339)
1703 .unwrap_or_default();
1704 let p = crate::pending_pair::PendingPair {
1705 code: code.clone(),
1706 code_hash,
1707 role: "host".to_string(),
1708 relay_url: relay_url.clone(),
1709 status: "request_host".to_string(),
1710 sas: None,
1711 peer_did: None,
1712 created_at: now,
1713 last_error: None,
1714 pair_id: None,
1715 our_slot_id: None,
1716 our_slot_token: None,
1717 spake2_seed_b64: None,
1718 };
1719 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1720 Ok(json!({
1721 "code_phrase": code,
1722 "relay_url": relay_url,
1723 "state": "queued",
1724 "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."
1725 }))
1726}
1727
1728fn tool_pair_join_detached(args: &Value) -> Result<Value, String> {
1729 auto_init_if_needed(args)?;
1730 let relay_url = resolve_relay_url(args)?;
1731 let code_phrase = args
1732 .get("code_phrase")
1733 .and_then(Value::as_str)
1734 .ok_or("missing 'code_phrase'")?;
1735 let code = crate::sas::parse_code_phrase(code_phrase)
1736 .map_err(|e| e.to_string())?
1737 .to_string();
1738 let code_hash = crate::pair_session::derive_code_hash(&code);
1739 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1740 let _ = crate::ensure_up::ensure_daemon_running();
1741 }
1742 let now = time::OffsetDateTime::now_utc()
1743 .format(&time::format_description::well_known::Rfc3339)
1744 .unwrap_or_default();
1745 let p = crate::pending_pair::PendingPair {
1746 code: code.clone(),
1747 code_hash,
1748 role: "guest".to_string(),
1749 relay_url: relay_url.clone(),
1750 status: "request_guest".to_string(),
1751 sas: None,
1752 peer_did: None,
1753 created_at: now,
1754 last_error: None,
1755 pair_id: None,
1756 our_slot_id: None,
1757 our_slot_token: None,
1758 spake2_seed_b64: None,
1759 };
1760 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1761 Ok(json!({
1762 "code_phrase": code,
1763 "relay_url": relay_url,
1764 "state": "queued",
1765 "next": "Subscribe to wire://pending-pair/all; on sas_ready notification, surface digits to user and call wire_pair_confirm_detached."
1766 }))
1767}
1768
1769fn tool_pair_list_pending() -> Result<Value, String> {
1770 let items = crate::pending_pair::list_pending().map_err(|e| e.to_string())?;
1771 Ok(json!({"pending": items}))
1772}
1773
1774fn tool_pair_confirm_detached(args: &Value) -> Result<Value, String> {
1775 let code_phrase = args
1776 .get("code_phrase")
1777 .and_then(Value::as_str)
1778 .ok_or("missing 'code_phrase'")?;
1779 let typed = args
1780 .get("user_typed_digits")
1781 .and_then(Value::as_str)
1782 .ok_or("missing 'user_typed_digits'")?;
1783 let code = crate::sas::parse_code_phrase(code_phrase)
1784 .map_err(|e| e.to_string())?
1785 .to_string();
1786 let typed: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
1787 if typed.len() != 6 {
1788 return Err(format!(
1789 "expected 6 digits (got {} after stripping non-digits)",
1790 typed.len()
1791 ));
1792 }
1793 let mut p = crate::pending_pair::read_pending(&code)
1794 .map_err(|e| e.to_string())?
1795 .ok_or_else(|| format!("no pending pair for code {code}"))?;
1796 if p.status != "sas_ready" {
1797 return Err(format!(
1798 "pair {code} not in sas_ready state (current: {})",
1799 p.status
1800 ));
1801 }
1802 let stored = p
1803 .sas
1804 .as_ref()
1805 .ok_or("pending file has status=sas_ready but no sas field")?
1806 .clone();
1807 if stored == typed {
1808 p.status = "confirmed".to_string();
1809 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1810 Ok(json!({
1811 "state": "confirmed",
1812 "code_phrase": code,
1813 "next": "Daemon will finalize on its next tick (~1s). Poll wire_peers or watch wire://pending-pair/all for the entry to disappear."
1814 }))
1815 } else {
1816 p.status = "aborted".to_string();
1817 p.last_error = Some(format!(
1818 "SAS digit mismatch (typed {typed}, expected {stored})"
1819 ));
1820 let client = crate::relay_client::RelayClient::new(&p.relay_url);
1821 let _ = client.pair_abandon(&p.code_hash);
1822 let _ = crate::pending_pair::write_pending(&p);
1823 crate::os_notify::toast(
1824 &format!("wire — pair aborted ({code})"),
1825 p.last_error.as_deref().unwrap_or("digits mismatch"),
1826 );
1827 Err(
1828 "digits mismatch — pair aborted. Re-issue with wire_pair_initiate_detached."
1829 .to_string(),
1830 )
1831 }
1832}
1833
1834fn tool_pair_cancel_pending(args: &Value) -> Result<Value, String> {
1835 let code_phrase = args
1836 .get("code_phrase")
1837 .and_then(Value::as_str)
1838 .ok_or("missing 'code_phrase'")?;
1839 let code = crate::sas::parse_code_phrase(code_phrase)
1840 .map_err(|e| e.to_string())?
1841 .to_string();
1842 if let Some(p) = crate::pending_pair::read_pending(&code).map_err(|e| e.to_string())? {
1843 let client = crate::relay_client::RelayClient::new(&p.relay_url);
1844 let _ = client.pair_abandon(&p.code_hash);
1845 }
1846 crate::pending_pair::delete_pending(&code).map_err(|e| e.to_string())?;
1847 Ok(json!({"state": "cancelled", "code_phrase": code}))
1848}
1849
1850fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1853 let relay_url = args.get("relay_url").and_then(Value::as_str);
1854 let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1855 let uses = args
1856 .get("uses")
1857 .and_then(Value::as_u64)
1858 .map(|u| u as u32)
1859 .unwrap_or(1);
1860 let url =
1861 crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1862 let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1863 Ok(json!({
1864 "invite_url": url,
1865 "ttl_secs": ttl_resolved,
1866 "uses": uses,
1867 }))
1868}
1869
1870fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1871 let url = args
1872 .get("url")
1873 .and_then(Value::as_str)
1874 .ok_or("missing 'url'")?;
1875 crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1876}
1877
1878fn tool_dial(args: &Value) -> Result<Value, String> {
1890 let name = args
1891 .get("name")
1892 .and_then(Value::as_str)
1893 .or_else(|| args.get("handle").and_then(Value::as_str))
1894 .ok_or("missing 'name'")?;
1895
1896 if name.contains('@') {
1897 let mut a = args.clone();
1899 if let Some(obj) = a.as_object_mut() {
1900 obj.insert("handle".into(), Value::String(name.to_string()));
1901 }
1902 return tool_add(&a);
1903 }
1904
1905 let relay_state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1906 let pinned = relay_state
1907 .get("peers")
1908 .and_then(Value::as_object)
1909 .map(|m| m.contains_key(name))
1910 .unwrap_or(false);
1911 if pinned {
1912 return Ok(json!({
1913 "name_input": name,
1914 "status": "already_pinned",
1915 "peer_handle": name,
1916 }));
1917 }
1918
1919 Err(format!(
1920 "cannot resolve `{name}` over MCP: bare-nickname / local-sister dialling is not yet \
1921 wired into the MCP surface. Use a federation handle `{name}@<relay>`, or `wire_send` \
1922 (it auto-pairs on miss)."
1923 ))
1924}
1925
1926fn tool_add(args: &Value) -> Result<Value, String> {
1927 let handle = args
1928 .get("handle")
1929 .and_then(Value::as_str)
1930 .ok_or("missing 'handle'")?;
1931 let relay_override = args.get("relay_url").and_then(Value::as_str);
1932
1933 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1934
1935 let (our_did, our_relay, our_slot_id, our_slot_token) =
1937 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1938
1939 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1941 .map_err(|e| format!("{e:#}"))?;
1942 let peer_card = resolved
1943 .get("card")
1944 .cloned()
1945 .ok_or("resolved missing card")?;
1946 let peer_did = resolved
1947 .get("did")
1948 .and_then(Value::as_str)
1949 .ok_or("resolved missing did")?
1950 .to_string();
1951 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1952 let peer_slot_id = resolved
1953 .get("slot_id")
1954 .and_then(Value::as_str)
1955 .ok_or("resolved missing slot_id")?
1956 .to_string();
1957 let peer_relay = resolved
1958 .get("relay_url")
1959 .and_then(Value::as_str)
1960 .map(str::to_string)
1961 .or_else(|| relay_override.map(str::to_string))
1962 .unwrap_or_else(|| format!("https://{}", parsed.domain));
1963
1964 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1966 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1967 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1968 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1969 let existing_token = relay_state
1970 .get("peers")
1971 .and_then(|p| p.get(&peer_handle))
1972 .and_then(|p| p.get("slot_token"))
1973 .and_then(Value::as_str)
1974 .map(str::to_string)
1975 .unwrap_or_default();
1976 relay_state["peers"][&peer_handle] = json!({
1977 "relay_url": peer_relay,
1978 "slot_id": peer_slot_id,
1979 "slot_token": existing_token,
1980 });
1981 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1982
1983 let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1985 let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1986 let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1987 let pk_b64 = our_card
1988 .get("verify_keys")
1989 .and_then(Value::as_object)
1990 .and_then(|m| m.values().next())
1991 .and_then(|v| v.get("key"))
1992 .and_then(Value::as_str)
1993 .ok_or("our card missing verify_keys[*].key")?;
1994 let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1995 let now = time::OffsetDateTime::now_utc()
1996 .format(&time::format_description::well_known::Rfc3339)
1997 .unwrap_or_default();
1998 let event = json!({
1999 "timestamp": now,
2000 "from": our_did,
2001 "to": peer_did,
2002 "type": "pair_drop",
2003 "kind": 1100u32,
2004 "body": {
2005 "card": our_card,
2006 "relay_url": our_relay,
2007 "slot_id": our_slot_id,
2008 "slot_token": our_slot_token,
2009 },
2010 });
2011 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
2012 .map_err(|e| format!("{e:#}"))?;
2013
2014 let client = crate::relay_client::RelayClient::new(&peer_relay);
2015 let resp = client
2016 .handle_intro(&parsed.nick, &signed)
2017 .map_err(|e| format!("{e:#}"))?;
2018 let event_id = signed
2019 .get("event_id")
2020 .and_then(Value::as_str)
2021 .unwrap_or("")
2022 .to_string();
2023 Ok(json!({
2024 "handle": handle,
2025 "paired_with": peer_did,
2026 "peer_handle": peer_handle,
2027 "event_id": event_id,
2028 "drop_response": resp,
2029 "status": "drop_sent",
2030 }))
2031}
2032
2033fn tool_pair_accept(args: &Value) -> Result<Value, String> {
2038 let peer = args
2039 .get("peer")
2040 .and_then(Value::as_str)
2041 .ok_or("missing 'peer'")?;
2042 let nick = crate::agent_card::bare_handle(peer);
2043 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
2044 .map_err(|e| format!("{e:#}"))?
2045 .ok_or_else(|| {
2046 format!(
2047 "no pending pair request from {nick}. Call wire_pair_list_inbound to enumerate, \
2048 or wire_add to send a fresh outbound pair request."
2049 )
2050 })?;
2051
2052 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
2055 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
2056 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
2057
2058 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
2060 relay_state["peers"][&pending.peer_handle] = json!({
2061 "relay_url": pending.peer_relay_url,
2062 "slot_id": pending.peer_slot_id,
2063 "slot_token": pending.peer_slot_token,
2064 });
2065 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
2066
2067 let ack_endpoints: Vec<crate::endpoints::Endpoint> = if pending.peer_endpoints.is_empty() {
2074 vec![crate::endpoints::Endpoint::federation(
2075 pending.peer_relay_url.clone(),
2076 pending.peer_slot_id.clone(),
2077 pending.peer_slot_token.clone(),
2078 )]
2079 } else {
2080 pending.peer_endpoints.clone()
2081 };
2082 crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &ack_endpoints).map_err(|e| {
2083 format!(
2084 "pair_drop_ack send to {} (across {} endpoint(s)) failed: {e:#}",
2085 pending.peer_handle,
2086 ack_endpoints.len()
2087 )
2088 })?;
2089
2090 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2091
2092 Ok(json!({
2093 "status": "bilateral_accepted",
2094 "peer_handle": pending.peer_handle,
2095 "peer_did": pending.peer_did,
2096 "peer_relay_url": pending.peer_relay_url,
2097 "via": "pending_inbound",
2098 }))
2099}
2100
2101fn tool_pair_reject(args: &Value) -> Result<Value, String> {
2104 let peer = args
2105 .get("peer")
2106 .and_then(Value::as_str)
2107 .ok_or("missing 'peer'")?;
2108 let nick = crate::agent_card::bare_handle(peer);
2109 let existed =
2110 crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2111 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2112 Ok(json!({
2113 "peer": nick,
2114 "rejected": existed.is_some(),
2115 "had_pending": existed.is_some(),
2116 }))
2117}
2118
2119fn tool_pair_list_inbound() -> Result<Value, String> {
2122 let items =
2123 crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
2124 Ok(json!(items))
2125}
2126
2127fn tool_claim_handle(args: &Value) -> Result<Value, String> {
2128 let typed = args.get("nick").and_then(Value::as_str);
2129 let relay_override = args.get("relay_url").and_then(Value::as_str);
2130 let public_url = args.get("public_url").and_then(Value::as_str);
2131
2132 let (_, our_relay, our_slot_id, our_slot_token) =
2134 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
2135 let claim_relay = relay_override.unwrap_or(&our_relay);
2136 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2137
2138 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
2143 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
2144 let nick = if canonical.is_empty() {
2145 typed.unwrap_or_default().to_string()
2146 } else {
2147 canonical
2148 };
2149 let typed_nick_ignored = typed.map(|t| t != nick).unwrap_or(false);
2150
2151 let client = crate::relay_client::RelayClient::new(claim_relay);
2152 let resp = client
2153 .handle_claim(&nick, &our_slot_id, &our_slot_token, public_url, &card)
2154 .map_err(|e| format!("{e:#}"))?;
2155 Ok(json!({
2156 "nick": nick,
2157 "relay": claim_relay,
2158 "response": resp,
2159 "one_name": true,
2160 "typed_nick_ignored": typed_nick_ignored,
2161 }))
2162}
2163
2164fn tool_whois(args: &Value) -> Result<Value, String> {
2165 if let Some(handle) = args.get("handle").and_then(Value::as_str) {
2166 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
2167 let relay_override = args.get("relay_url").and_then(Value::as_str);
2168 crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
2169 } else {
2170 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2172 Ok(json!({
2173 "did": card.get("did").cloned().unwrap_or(Value::Null),
2174 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
2175 }))
2176 }
2177}
2178
2179fn tool_profile_set(args: &Value) -> Result<Value, String> {
2180 let field = args
2181 .get("field")
2182 .and_then(Value::as_str)
2183 .ok_or("missing 'field'")?;
2184 let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
2185 let value = if let Some(s) = raw_value.as_str() {
2189 serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
2190 } else {
2191 raw_value
2192 };
2193 let new_profile =
2194 crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
2195 Ok(json!({
2196 "field": field,
2197 "profile": new_profile,
2198 }))
2199}
2200
2201fn tool_profile_get() -> Result<Value, String> {
2202 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2203 Ok(json!({
2204 "did": card.get("did").cloned().unwrap_or(Value::Null),
2205 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
2206 }))
2207}
2208
2209fn parse_kind(s: &str) -> u32 {
2212 if let Ok(n) = s.parse::<u32>() {
2213 return n;
2214 }
2215 for (id, name) in crate::signing::kinds() {
2216 if *name == s {
2217 return *id;
2218 }
2219 }
2220 1
2221}
2222
2223fn error_response(id: &Value, code: i32, message: &str) -> Value {
2224 json!({
2225 "jsonrpc": "2.0",
2226 "id": id,
2227 "error": {"code": code, "message": message}
2228 })
2229}
2230
2231#[cfg(test)]
2232mod tests {
2233 use super::*;
2234
2235 #[test]
2236 fn unknown_method_returns_jsonrpc_error() {
2237 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
2238 let resp = handle_request(&req, &McpState::default());
2239 assert_eq!(resp["error"]["code"], -32601);
2240 }
2241
2242 #[test]
2243 fn initialize_advertises_tools_capability() {
2244 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
2245 let resp = handle_request(&req, &McpState::default());
2246 assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
2247 assert!(resp["result"]["capabilities"]["tools"].is_object());
2248 assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
2249 }
2250
2251 #[test]
2252 fn tools_list_includes_pairing_and_messaging() {
2253 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2254 let resp = handle_request(&req, &McpState::default());
2255 let names: Vec<&str> = resp["result"]["tools"]
2256 .as_array()
2257 .unwrap()
2258 .iter()
2259 .filter_map(|t| t["name"].as_str())
2260 .collect();
2261 for required in [
2262 "wire_whoami",
2263 "wire_peers",
2264 "wire_send",
2265 "wire_tail",
2266 "wire_verify",
2267 "wire_init",
2268 "wire_pair_initiate",
2269 "wire_pair_join",
2270 "wire_pair_check",
2271 "wire_pair_confirm",
2272 ] {
2273 assert!(
2274 names.contains(&required),
2275 "missing required tool {required}"
2276 );
2277 }
2278 assert!(
2282 !names.contains(&"wire_join"),
2283 "wire_join must not be advertised — superseded by wire_pair_join"
2284 );
2285 }
2286
2287 #[test]
2288 fn legacy_wire_join_call_returns_helpful_error() {
2289 let req = json!({
2290 "jsonrpc": "2.0",
2291 "id": 1,
2292 "method": "tools/call",
2293 "params": {"name": "wire_join", "arguments": {}}
2294 });
2295 let resp = handle_request(&req, &McpState::default());
2296 assert_eq!(resp["result"]["isError"], true);
2297 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2298 assert!(
2299 text.contains("wire_pair_join"),
2300 "expected redirect to wire_pair_join, got: {text}"
2301 );
2302 }
2303
2304 #[test]
2305 fn pair_confirm_missing_session_id_errors_cleanly() {
2306 let req = json!({
2307 "jsonrpc": "2.0",
2308 "id": 1,
2309 "method": "tools/call",
2310 "params": {"name": "wire_pair_confirm", "arguments": {"user_typed_digits": "111111"}}
2311 });
2312 let resp = handle_request(&req, &McpState::default());
2313 assert_eq!(resp["result"]["isError"], true);
2314 }
2315
2316 #[test]
2317 fn pair_confirm_unknown_session_errors_cleanly() {
2318 let req = json!({
2319 "jsonrpc": "2.0",
2320 "id": 1,
2321 "method": "tools/call",
2322 "params": {
2323 "name": "wire_pair_confirm",
2324 "arguments": {"session_id": "definitely-not-real", "user_typed_digits": "111111"}
2325 }
2326 });
2327 let resp = handle_request(&req, &McpState::default());
2328 assert_eq!(resp["result"]["isError"], true);
2329 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2330 assert!(text.contains("no such session_id"), "got: {text}");
2331 }
2332
2333 #[test]
2334 fn initialize_advertises_resources_capability() {
2335 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
2336 let resp = handle_request(&req, &McpState::default());
2337 let caps = &resp["result"]["capabilities"];
2338 assert!(
2339 caps["resources"].is_object(),
2340 "resources capability must be present, got {resp}"
2341 );
2342 assert_eq!(
2343 caps["resources"]["subscribe"], true,
2344 "subscribe shipped in v0.2.1"
2345 );
2346 }
2347
2348 #[test]
2349 fn resources_read_with_bad_uri_errors() {
2350 let req = json!({
2351 "jsonrpc": "2.0",
2352 "id": 1,
2353 "method": "resources/read",
2354 "params": {"uri": "http://example.com/not-a-wire-uri"}
2355 });
2356 let resp = handle_request(&req, &McpState::default());
2357 assert!(resp.get("error").is_some(), "expected error, got {resp}");
2358 }
2359
2360 #[test]
2361 fn parse_inbox_uri_handles_variants() {
2362 assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
2363 assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
2364 assert!(
2365 parse_inbox_uri("wire://inbox/")
2366 .unwrap()
2367 .starts_with("__invalid__"),
2368 "empty peer must be invalid"
2369 );
2370 assert!(
2371 parse_inbox_uri("http://other")
2372 .unwrap()
2373 .starts_with("__invalid__"),
2374 "non-wire scheme must be invalid"
2375 );
2376 }
2377
2378 #[test]
2379 fn ping_returns_empty_result() {
2380 let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2381 let resp = handle_request(&req, &McpState::default());
2382 assert_eq!(resp["id"], 7);
2383 assert!(resp["result"].is_object());
2384 }
2385
2386 #[test]
2387 fn notification_returns_null_no_reply() {
2388 let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2389 let resp = handle_request(&req, &McpState::default());
2390 assert_eq!(resp, Value::Null);
2391 }
2392
2393 #[test]
2400 fn detect_session_wire_home_resolves_registered_cwd() {
2401 crate::config::test_support::with_temp_home(|| {
2402 let wire_home = std::env::var("WIRE_HOME").unwrap();
2406 let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2407 let session_home = sessions_root.join("test-alpha");
2408 std::fs::create_dir_all(&session_home).unwrap();
2409 let fake_cwd = "/tmp/fake-project-cwd-abc123";
2410 let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2411 std::fs::write(
2412 sessions_root.join("registry.json"),
2413 serde_json::to_vec_pretty(®istry).unwrap(),
2414 )
2415 .unwrap();
2416
2417 let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2419 assert_eq!(
2420 got.as_deref(),
2421 Some(session_home.as_path()),
2422 "registered cwd must resolve to session_home"
2423 );
2424
2425 let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2427 "/tmp/cwd-not-in-registry-xyz789",
2428 ));
2429 assert!(nope.is_none(), "unregistered cwd must return None");
2430
2431 let stale_cwd = "/tmp/stale-session-cwd";
2434 let stale_registry =
2435 json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2436 std::fs::write(
2437 sessions_root.join("registry.json"),
2438 serde_json::to_vec_pretty(&stale_registry).unwrap(),
2439 )
2440 .unwrap();
2441 let stale_got =
2442 crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2443 assert!(
2444 stale_got.is_none(),
2445 "registered cwd whose session dir is missing must return None"
2446 );
2447 });
2448 }
2449}