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 let mut payload = serde_json::Map::new();
1072 payload.insert("did".into(), json!(did));
1073 payload.insert("handle".into(), json!(handle));
1074 payload.insert("persona".into(), persona);
1075 payload.insert("fingerprint".into(), json!(fp));
1076 payload.insert("key_id".into(), json!(key_id));
1077 payload.insert("public_key_b64".into(), json!(pk_b64));
1078 payload.insert("capabilities".into(), capabilities);
1079 for (k, v) in crate::cli::op_claims_from_card(&card) {
1080 payload.insert(k, v);
1081 }
1082 Ok(Value::Object(payload))
1083}
1084
1085fn tool_peers() -> Result<Value, String> {
1086 use crate::config;
1087 use crate::trust::get_tier;
1088
1089 let trust = config::read_trust().map_err(|e| e.to_string())?;
1090 let agents = trust
1091 .get("agents")
1092 .and_then(Value::as_object)
1093 .cloned()
1094 .unwrap_or_default();
1095 let mut self_did: Option<String> = None;
1096 if let Ok(card) = config::read_agent_card() {
1097 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
1098 }
1099 let mut peers = Vec::new();
1100 for (handle, agent) in agents.iter() {
1101 let did = agent
1102 .get("did")
1103 .and_then(Value::as_str)
1104 .unwrap_or("")
1105 .to_string();
1106 if Some(did.as_str()) == self_did.as_deref() {
1107 continue;
1108 }
1109 let persona = match agent.get("card") {
1113 Some(c) => crate::character::Character::from_card(c),
1114 None => crate::character::Character::from_did(&did),
1115 };
1116 let peer_op_claims = agent
1121 .get("card")
1122 .map(crate::cli::op_claims_from_card)
1123 .unwrap_or_default();
1124 let mut row = serde_json::Map::new();
1125 row.insert("handle".into(), json!(handle));
1126 row.insert(
1127 "persona".into(),
1128 serde_json::to_value(&persona).unwrap_or(Value::Null),
1129 );
1130 row.insert("did".into(), json!(did));
1131 row.insert("tier".into(), json!(get_tier(&trust, handle)));
1132 row.insert(
1133 "capabilities".into(),
1134 agent
1135 .get("card")
1136 .and_then(|c| c.get("capabilities"))
1137 .cloned()
1138 .unwrap_or_else(|| json!([])),
1139 );
1140 for (k, v) in peer_op_claims {
1141 row.insert(k, v);
1142 }
1143 peers.push(Value::Object(row));
1144 }
1145 Ok(json!(peers))
1146}
1147
1148fn group_cli_json(args: &[&str]) -> Result<Value, String> {
1153 let exe = std::env::current_exe().map_err(|e| format!("locating wire binary: {e}"))?;
1154 let out = std::process::Command::new(exe)
1155 .arg("group")
1156 .args(args)
1157 .arg("--json")
1158 .env("WIRE_QUIET_AUTOSESSION", "1") .output()
1160 .map_err(|e| format!("spawning `wire group`: {e}"))?;
1161 if !out.status.success() {
1162 let err = String::from_utf8_lossy(&out.stderr);
1163 return Err(err.trim().to_string());
1164 }
1165 let s = String::from_utf8_lossy(&out.stdout);
1166 let line = s
1168 .lines()
1169 .rev()
1170 .find(|l| l.trim_start().starts_with('{'))
1171 .unwrap_or("{}");
1172 serde_json::from_str(line).map_err(|e| format!("parsing `wire group` output: {e}"))
1173}
1174
1175fn tool_group_create(args: &Value) -> Result<Value, String> {
1176 let name = args
1177 .get("name")
1178 .and_then(Value::as_str)
1179 .ok_or("missing 'name'")?;
1180 group_cli_json(&["create", name])
1181}
1182
1183fn tool_group_add(args: &Value) -> Result<Value, String> {
1184 let group = args
1185 .get("group")
1186 .and_then(Value::as_str)
1187 .ok_or("missing 'group'")?;
1188 let peer = args
1189 .get("peer")
1190 .and_then(Value::as_str)
1191 .ok_or("missing 'peer'")?;
1192 group_cli_json(&["add", group, peer])
1193}
1194
1195fn tool_group_send(args: &Value) -> Result<Value, String> {
1196 let group = args
1197 .get("group")
1198 .and_then(Value::as_str)
1199 .ok_or("missing 'group'")?;
1200 let message = args
1201 .get("message")
1202 .and_then(Value::as_str)
1203 .ok_or("missing 'message'")?;
1204 group_cli_json(&["send", group, message])
1205}
1206
1207fn tool_group_tail(args: &Value) -> Result<Value, String> {
1208 let group = args
1209 .get("group")
1210 .and_then(Value::as_str)
1211 .ok_or("missing 'group'")?;
1212 if let Some(n) = args.get("limit").and_then(Value::as_u64) {
1213 group_cli_json(&["tail", group, "--limit", &n.to_string()])
1214 } else {
1215 group_cli_json(&["tail", group])
1216 }
1217}
1218
1219fn tool_group_list() -> Result<Value, String> {
1220 group_cli_json(&["list"])
1221}
1222
1223fn tool_group_invite(args: &Value) -> Result<Value, String> {
1224 let group = args
1225 .get("group")
1226 .and_then(Value::as_str)
1227 .ok_or("missing 'group'")?;
1228 group_cli_json(&["invite", group])
1229}
1230
1231fn tool_group_join(args: &Value) -> Result<Value, String> {
1232 let code = args
1233 .get("code")
1234 .and_then(Value::as_str)
1235 .ok_or("missing 'code'")?;
1236 group_cli_json(&["join", code])
1237}
1238
1239fn tool_send(args: &Value) -> Result<Value, String> {
1240 use crate::config;
1241 use crate::signing::{b64decode, sign_message_v31};
1242
1243 let peer = args
1244 .get("peer")
1245 .and_then(Value::as_str)
1246 .ok_or("missing 'peer'")?;
1247 let peer = crate::agent_card::bare_handle(peer);
1248 let kind = args
1249 .get("kind")
1250 .and_then(Value::as_str)
1251 .ok_or("missing 'kind'")?;
1252 let body = args
1253 .get("body")
1254 .and_then(Value::as_str)
1255 .ok_or("missing 'body'")?;
1256 let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
1257
1258 if !config::is_initialized().map_err(|e| e.to_string())? {
1259 return Err("not initialized — operator must run `wire init <handle>` first".into());
1260 }
1261 let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
1262 let card = config::read_agent_card().map_err(|e| e.to_string())?;
1263 let did = card
1264 .get("did")
1265 .and_then(Value::as_str)
1266 .unwrap_or("")
1267 .to_string();
1268 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1269 let pk_b64 = card
1270 .get("verify_keys")
1271 .and_then(Value::as_object)
1272 .and_then(|m| m.values().next())
1273 .and_then(|v| v.get("key"))
1274 .and_then(Value::as_str)
1275 .ok_or("agent-card missing verify_keys[*].key")?;
1276 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1277
1278 let body_value: Value =
1280 serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1281 let kind_id = parse_kind(kind);
1282
1283 let now = time::OffsetDateTime::now_utc()
1284 .format(&time::format_description::well_known::Rfc3339)
1285 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1286
1287 let mut event = json!({
1288 "timestamp": now,
1289 "from": did,
1290 "to": format!("did:wire:{peer}"),
1291 "type": kind,
1292 "kind": kind_id,
1293 "body": body_value,
1294 });
1295 if let Some(deadline) = deadline {
1296 event["time_sensitive_until"] =
1297 json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1298 }
1299 let signed =
1300 sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1301 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1302
1303 let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1304 let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1305
1306 Ok(json!({
1307 "event_id": event_id,
1308 "status": "queued",
1309 "peer": peer,
1310 "outbox": outbox.to_string_lossy(),
1311 }))
1312}
1313
1314fn tool_tail(args: &Value) -> Result<Value, String> {
1315 use crate::config;
1316 use crate::signing::verify_message_v31;
1317
1318 let peer_filter = args.get("peer").and_then(Value::as_str);
1319 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1320 let oldest = args.get("oldest").and_then(Value::as_bool).unwrap_or(false);
1325 let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1326 if !inbox.exists() {
1327 return Ok(json!([]));
1328 }
1329 let trust = config::read_trust().map_err(|e| e.to_string())?;
1330 let entries: Vec<_> = std::fs::read_dir(&inbox)
1331 .map_err(|e| e.to_string())?
1332 .filter_map(|e| e.ok())
1333 .map(|e| e.path())
1334 .filter(|p| {
1335 p.extension().map(|x| x == "jsonl").unwrap_or(false)
1336 && match peer_filter {
1337 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1338 None => true,
1339 }
1340 })
1341 .collect();
1342
1343 let mut collected: Vec<(String, usize, Value)> = Vec::new();
1346 for path in &entries {
1347 let body = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1348 for (idx, line) in body.lines().enumerate() {
1349 let event: Value = match serde_json::from_str(line) {
1350 Ok(v) => v,
1351 Err(_) => continue,
1352 };
1353 let verified = verify_message_v31(&event, &trust).is_ok();
1354 let mut event_with_meta = event.clone();
1355 if let Some(obj) = event_with_meta.as_object_mut() {
1356 obj.insert("verified".into(), json!(verified));
1357 }
1358 let ts = event
1359 .get("timestamp")
1360 .and_then(Value::as_str)
1361 .unwrap_or("")
1362 .to_string();
1363 collected.push((ts, idx, event_with_meta));
1364 }
1365 }
1366 collected.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
1367
1368 let total = collected.len();
1369 let window: Vec<Value> = if limit == 0 {
1370 collected.into_iter().map(|(_, _, e)| e).collect()
1371 } else if oldest {
1372 collected
1373 .into_iter()
1374 .take(limit)
1375 .map(|(_, _, e)| e)
1376 .collect()
1377 } else {
1378 let start = total.saturating_sub(limit);
1379 collected
1380 .into_iter()
1381 .skip(start)
1382 .map(|(_, _, e)| e)
1383 .collect()
1384 };
1385 Ok(Value::Array(window))
1386}
1387
1388fn tool_verify(args: &Value) -> Result<Value, String> {
1389 use crate::config;
1390 use crate::signing::verify_message_v31;
1391
1392 let event_str = args
1393 .get("event")
1394 .and_then(Value::as_str)
1395 .ok_or("missing 'event'")?;
1396 let event: Value =
1397 serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1398 let trust = config::read_trust().map_err(|e| e.to_string())?;
1399 match verify_message_v31(&event, &trust) {
1400 Ok(()) => Ok(json!({"verified": true})),
1401 Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1402 }
1403}
1404
1405fn ensure_session_bootstrapped() {
1414 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
1415 return;
1416 }
1417 if crate::config::is_initialized().unwrap_or(false) {
1418 return; }
1420 let (did, relay_url, slot_id, slot_token) =
1421 match crate::pair_invite::ensure_self_with_relay(None) {
1422 Ok(t) => t,
1423 Err(_) => return, };
1425 if let Ok(card) = crate::config::read_agent_card() {
1426 let persona = crate::agent_card::display_handle_from_did(&did).to_string();
1427 let client = crate::relay_client::RelayClient::new(&relay_url);
1428 let _ = client.handle_claim_v2(&persona, &slot_id, &slot_token, None, &card, None);
1429 }
1430}
1431
1432fn tool_init(args: &Value) -> Result<Value, String> {
1433 let handle = args
1434 .get("handle")
1435 .and_then(Value::as_str)
1436 .ok_or("missing 'handle'")?;
1437 let name = args.get("name").and_then(Value::as_str);
1438 let relay = args.get("relay_url").and_then(Value::as_str);
1439 crate::pair_session::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1440}
1441
1442fn resolve_relay_url(args: &Value) -> Result<String, String> {
1446 if let Some(url) = args.get("relay_url").and_then(Value::as_str) {
1447 return Ok(url.to_string());
1448 }
1449 let state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1450 state["self"]["relay_url"]
1451 .as_str()
1452 .map(str::to_string)
1453 .ok_or_else(|| "no relay_url provided and no relay bound (call wire_init with relay_url, or pass relay_url here)".into())
1454}
1455
1456fn auto_init_if_needed(args: &Value) -> Result<(), String> {
1462 let initialized = crate::config::is_initialized().map_err(|e| e.to_string())?;
1463 if initialized {
1464 return Ok(());
1465 }
1466 let handle = args.get("handle").and_then(Value::as_str).ok_or(
1467 "not initialized — pass `handle` to auto-init, or call wire_init explicitly first",
1468 )?;
1469 let relay = args.get("relay_url").and_then(Value::as_str);
1470 crate::pair_session::init_self_idempotent(handle, None, relay)
1471 .map(|_| ())
1472 .map_err(|e| e.to_string())
1473}
1474
1475fn tool_pair_initiate(args: &Value) -> Result<Value, String> {
1476 use crate::pair_session::{
1477 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1478 };
1479
1480 store_sweep_expired();
1481 auto_init_if_needed(args)?;
1483
1484 let relay_url = resolve_relay_url(args)?;
1485 let max_wait = args
1486 .get("max_wait_secs")
1487 .and_then(Value::as_u64)
1488 .unwrap_or(30)
1489 .min(60);
1490
1491 let mut s = pair_session_open("host", &relay_url, None).map_err(|e| e.to_string())?;
1492 let code = s.code.clone();
1493
1494 let sas_opt = if max_wait > 0 {
1495 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1496 .map_err(|e| e.to_string())?
1497 } else {
1498 None
1499 };
1500
1501 let session_id = store_insert(s);
1502
1503 let mut out = json!({
1504 "session_id": session_id,
1505 "code_phrase": code,
1506 "relay_url": relay_url,
1507 });
1508 match sas_opt {
1509 Some(sas) => {
1510 out["state"] = json!("sas_ready");
1511 out["sas"] = json!(sas);
1512 out["next"] = json!(
1513 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel (voice/text). \
1514 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1515 );
1516 }
1517 None => {
1518 out["state"] = json!("waiting");
1519 out["next"] = json!(
1520 "Share the code_phrase with the user; ask them to read it to their peer (the peer pastes into wire_pair_join). \
1521 Poll wire_pair_check(session_id) until state='sas_ready'."
1522 );
1523 }
1524 }
1525 Ok(out)
1526}
1527
1528fn tool_pair_join(args: &Value) -> Result<Value, String> {
1529 use crate::pair_session::{
1530 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1531 };
1532
1533 store_sweep_expired();
1534 auto_init_if_needed(args)?;
1535
1536 let code = args
1537 .get("code_phrase")
1538 .and_then(Value::as_str)
1539 .ok_or("missing 'code_phrase'")?;
1540 let relay_url = resolve_relay_url(args)?;
1541 let max_wait = args
1542 .get("max_wait_secs")
1543 .and_then(Value::as_u64)
1544 .unwrap_or(30)
1545 .min(60);
1546
1547 let mut s = pair_session_open("guest", &relay_url, Some(code)).map_err(|e| e.to_string())?;
1548
1549 let sas_opt =
1550 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1551 .map_err(|e| e.to_string())?;
1552
1553 let session_id = store_insert(s);
1554
1555 let mut out = json!({
1556 "session_id": session_id,
1557 "relay_url": relay_url,
1558 });
1559 match sas_opt {
1560 Some(sas) => {
1561 out["state"] = json!("sas_ready");
1562 out["sas"] = json!(sas);
1563 out["next"] = json!(
1564 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel. \
1565 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1566 );
1567 }
1568 None => {
1569 out["state"] = json!("waiting");
1570 out["next"] = json!("Poll wire_pair_check(session_id).");
1571 }
1572 }
1573 Ok(out)
1574}
1575
1576fn tool_pair_check(args: &Value) -> Result<Value, String> {
1577 use crate::pair_session::{pair_session_wait_for_sas, store_get, store_sweep_expired};
1578
1579 store_sweep_expired();
1580 let session_id = args
1581 .get("session_id")
1582 .and_then(Value::as_str)
1583 .ok_or("missing 'session_id'")?;
1584 let max_wait = args
1585 .get("max_wait_secs")
1586 .and_then(Value::as_u64)
1587 .unwrap_or(8)
1588 .min(60);
1589
1590 let arc = store_get(session_id)
1591 .ok_or_else(|| format!("no such session_id (expired or never opened): {session_id}"))?;
1592 let mut s = arc.lock().map_err(|e| e.to_string())?;
1593
1594 if s.finalized {
1595 return Ok(json!({
1596 "state": "finalized",
1597 "session_id": session_id,
1598 "sas": s.formatted_sas(),
1599 }));
1600 }
1601 if let Some(reason) = s.aborted.clone() {
1602 return Ok(json!({
1603 "state": "aborted",
1604 "session_id": session_id,
1605 "reason": reason,
1606 }));
1607 }
1608
1609 let sas_opt =
1610 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1611 .map_err(|e| e.to_string())?;
1612
1613 Ok(match sas_opt {
1614 Some(sas) => json!({
1615 "state": "sas_ready",
1616 "session_id": session_id,
1617 "sas": sas,
1618 "next": "Have the user TYPE the 6 SAS digits BACK INTO CHAT, then pass to wire_pair_confirm."
1619 }),
1620 None => json!({
1621 "state": "waiting",
1622 "session_id": session_id,
1623 }),
1624 })
1625}
1626
1627fn tool_pair_confirm(args: &Value, state: &McpState) -> Result<Value, String> {
1628 use crate::pair_session::{
1629 pair_session_confirm_sas, pair_session_finalize, store_get, store_remove,
1630 };
1631
1632 let session_id = args
1633 .get("session_id")
1634 .and_then(Value::as_str)
1635 .ok_or("missing 'session_id'")?;
1636 let typed = args
1637 .get("user_typed_digits")
1638 .and_then(Value::as_str)
1639 .ok_or(
1640 "missing 'user_typed_digits' — the user must type the 6 SAS digits back into chat",
1641 )?;
1642
1643 let arc = store_get(session_id).ok_or_else(|| format!("no such session_id: {session_id}"))?;
1644
1645 let confirm_err = {
1646 let mut s = arc.lock().map_err(|e| e.to_string())?;
1647 match pair_session_confirm_sas(&mut s, typed) {
1648 Ok(()) => None,
1649 Err(e) => Some((s.aborted.is_some(), e.to_string())),
1650 }
1651 };
1652 if let Some((aborted, msg)) = confirm_err {
1653 if aborted {
1654 store_remove(session_id);
1655 }
1656 return Err(msg);
1657 }
1658
1659 let mut result = {
1660 let mut s = arc.lock().map_err(|e| e.to_string())?;
1661 pair_session_finalize(&mut s, 30).map_err(|e| e.to_string())?
1662 };
1663 store_remove(session_id);
1664
1665 let peer_handle = result["peer_handle"].as_str().unwrap_or("").to_string();
1674 let peer_uri = format!("wire://inbox/{peer_handle}");
1675
1676 let mut auto = json!({
1677 "subscribed": false,
1678 "daemon": "unknown",
1679 "notify": "unknown",
1680 "resources_list_changed_emitted": false,
1681 });
1682
1683 if !peer_handle.is_empty()
1684 && let Ok(mut g) = state.subscribed.lock()
1685 {
1686 g.insert(peer_uri.clone());
1687 auto["subscribed"] = json!(true);
1688 }
1689
1690 auto["daemon"] = match crate::ensure_up::ensure_daemon_running() {
1691 Ok(true) => json!("spawned"),
1692 Ok(false) => json!("already_running"),
1693 Err(e) => json!(format!("spawn_error: {e}")),
1694 };
1695 auto["notify"] = match crate::ensure_up::ensure_notify_running() {
1696 Ok(true) => json!("spawned"),
1697 Ok(false) => json!("already_running"),
1698 Err(e) => json!(format!("spawn_error: {e}")),
1699 };
1700
1701 if let Some(tx) = state.notif_tx.lock().ok().and_then(|g| g.clone()) {
1702 let notif = json!({
1703 "jsonrpc": "2.0",
1704 "method": "notifications/resources/list_changed",
1705 });
1706 if tx.send(notif.to_string()).is_ok() {
1707 auto["resources_list_changed_emitted"] = json!(true);
1708 }
1709 }
1710
1711 result["auto"] = auto;
1712 result["next"] = json!(
1713 "Done. Daemon + notify running, subscribed to peer inbox. Use wire_send/wire_tail \
1714 freely; new events arrive via notifications/resources/updated (where supported) and \
1715 OS toasts (always)."
1716 );
1717 Ok(result)
1718}
1719
1720fn tool_pair_initiate_detached(args: &Value) -> Result<Value, String> {
1723 auto_init_if_needed(args)?;
1724 let relay_url = resolve_relay_url(args)?;
1725 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1726 let _ = crate::ensure_up::ensure_daemon_running();
1727 }
1728 let code = crate::sas::generate_code_phrase();
1729 let code_hash = crate::pair_session::derive_code_hash(&code);
1730 let now = time::OffsetDateTime::now_utc()
1731 .format(&time::format_description::well_known::Rfc3339)
1732 .unwrap_or_default();
1733 let p = crate::pending_pair::PendingPair {
1734 code: code.clone(),
1735 code_hash,
1736 role: "host".to_string(),
1737 relay_url: relay_url.clone(),
1738 status: "request_host".to_string(),
1739 sas: None,
1740 peer_did: None,
1741 created_at: now,
1742 last_error: None,
1743 pair_id: None,
1744 our_slot_id: None,
1745 our_slot_token: None,
1746 spake2_seed_b64: None,
1747 };
1748 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1749 Ok(json!({
1750 "code_phrase": code,
1751 "relay_url": relay_url,
1752 "state": "queued",
1753 "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."
1754 }))
1755}
1756
1757fn tool_pair_join_detached(args: &Value) -> Result<Value, String> {
1758 auto_init_if_needed(args)?;
1759 let relay_url = resolve_relay_url(args)?;
1760 let code_phrase = args
1761 .get("code_phrase")
1762 .and_then(Value::as_str)
1763 .ok_or("missing 'code_phrase'")?;
1764 let code = crate::sas::parse_code_phrase(code_phrase)
1765 .map_err(|e| e.to_string())?
1766 .to_string();
1767 let code_hash = crate::pair_session::derive_code_hash(&code);
1768 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1769 let _ = crate::ensure_up::ensure_daemon_running();
1770 }
1771 let now = time::OffsetDateTime::now_utc()
1772 .format(&time::format_description::well_known::Rfc3339)
1773 .unwrap_or_default();
1774 let p = crate::pending_pair::PendingPair {
1775 code: code.clone(),
1776 code_hash,
1777 role: "guest".to_string(),
1778 relay_url: relay_url.clone(),
1779 status: "request_guest".to_string(),
1780 sas: None,
1781 peer_did: None,
1782 created_at: now,
1783 last_error: None,
1784 pair_id: None,
1785 our_slot_id: None,
1786 our_slot_token: None,
1787 spake2_seed_b64: None,
1788 };
1789 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1790 Ok(json!({
1791 "code_phrase": code,
1792 "relay_url": relay_url,
1793 "state": "queued",
1794 "next": "Subscribe to wire://pending-pair/all; on sas_ready notification, surface digits to user and call wire_pair_confirm_detached."
1795 }))
1796}
1797
1798fn tool_pair_list_pending() -> Result<Value, String> {
1799 let items = crate::pending_pair::list_pending().map_err(|e| e.to_string())?;
1800 Ok(json!({"pending": items}))
1801}
1802
1803fn tool_pair_confirm_detached(args: &Value) -> Result<Value, String> {
1804 let code_phrase = args
1805 .get("code_phrase")
1806 .and_then(Value::as_str)
1807 .ok_or("missing 'code_phrase'")?;
1808 let typed = args
1809 .get("user_typed_digits")
1810 .and_then(Value::as_str)
1811 .ok_or("missing 'user_typed_digits'")?;
1812 let code = crate::sas::parse_code_phrase(code_phrase)
1813 .map_err(|e| e.to_string())?
1814 .to_string();
1815 let typed: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
1816 if typed.len() != 6 {
1817 return Err(format!(
1818 "expected 6 digits (got {} after stripping non-digits)",
1819 typed.len()
1820 ));
1821 }
1822 let mut p = crate::pending_pair::read_pending(&code)
1823 .map_err(|e| e.to_string())?
1824 .ok_or_else(|| format!("no pending pair for code {code}"))?;
1825 if p.status != "sas_ready" {
1826 return Err(format!(
1827 "pair {code} not in sas_ready state (current: {})",
1828 p.status
1829 ));
1830 }
1831 let stored = p
1832 .sas
1833 .as_ref()
1834 .ok_or("pending file has status=sas_ready but no sas field")?
1835 .clone();
1836 if stored == typed {
1837 p.status = "confirmed".to_string();
1838 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1839 Ok(json!({
1840 "state": "confirmed",
1841 "code_phrase": code,
1842 "next": "Daemon will finalize on its next tick (~1s). Poll wire_peers or watch wire://pending-pair/all for the entry to disappear."
1843 }))
1844 } else {
1845 p.status = "aborted".to_string();
1846 p.last_error = Some(format!(
1847 "SAS digit mismatch (typed {typed}, expected {stored})"
1848 ));
1849 let client = crate::relay_client::RelayClient::new(&p.relay_url);
1850 let _ = client.pair_abandon(&p.code_hash);
1851 let _ = crate::pending_pair::write_pending(&p);
1852 crate::os_notify::toast(
1853 &format!("wire — pair aborted ({code})"),
1854 p.last_error.as_deref().unwrap_or("digits mismatch"),
1855 );
1856 Err(
1857 "digits mismatch — pair aborted. Re-issue with wire_pair_initiate_detached."
1858 .to_string(),
1859 )
1860 }
1861}
1862
1863fn tool_pair_cancel_pending(args: &Value) -> Result<Value, String> {
1864 let code_phrase = args
1865 .get("code_phrase")
1866 .and_then(Value::as_str)
1867 .ok_or("missing 'code_phrase'")?;
1868 let code = crate::sas::parse_code_phrase(code_phrase)
1869 .map_err(|e| e.to_string())?
1870 .to_string();
1871 if let Some(p) = crate::pending_pair::read_pending(&code).map_err(|e| e.to_string())? {
1872 let client = crate::relay_client::RelayClient::new(&p.relay_url);
1873 let _ = client.pair_abandon(&p.code_hash);
1874 }
1875 crate::pending_pair::delete_pending(&code).map_err(|e| e.to_string())?;
1876 Ok(json!({"state": "cancelled", "code_phrase": code}))
1877}
1878
1879fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1882 let relay_url = args.get("relay_url").and_then(Value::as_str);
1883 let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1884 let uses = args
1885 .get("uses")
1886 .and_then(Value::as_u64)
1887 .map(|u| u as u32)
1888 .unwrap_or(1);
1889 let url =
1890 crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1891 let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1892 Ok(json!({
1893 "invite_url": url,
1894 "ttl_secs": ttl_resolved,
1895 "uses": uses,
1896 }))
1897}
1898
1899fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1900 let url = args
1901 .get("url")
1902 .and_then(Value::as_str)
1903 .ok_or("missing 'url'")?;
1904 crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1905}
1906
1907fn tool_dial(args: &Value) -> Result<Value, String> {
1919 let name = args
1920 .get("name")
1921 .and_then(Value::as_str)
1922 .or_else(|| args.get("handle").and_then(Value::as_str))
1923 .ok_or("missing 'name'")?;
1924
1925 if name.contains('@') {
1926 let mut a = args.clone();
1928 if let Some(obj) = a.as_object_mut() {
1929 obj.insert("handle".into(), Value::String(name.to_string()));
1930 }
1931 return tool_add(&a);
1932 }
1933
1934 let relay_state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1935 let pinned = relay_state
1936 .get("peers")
1937 .and_then(Value::as_object)
1938 .map(|m| m.contains_key(name))
1939 .unwrap_or(false);
1940 if pinned {
1941 return Ok(json!({
1942 "name_input": name,
1943 "status": "already_pinned",
1944 "peer_handle": name,
1945 }));
1946 }
1947
1948 Err(format!(
1949 "cannot resolve `{name}` over MCP: bare-nickname / local-sister dialling is not yet \
1950 wired into the MCP surface. Use a federation handle `{name}@<relay>`, or `wire_send` \
1951 (it auto-pairs on miss)."
1952 ))
1953}
1954
1955fn tool_add(args: &Value) -> Result<Value, String> {
1956 let handle = args
1957 .get("handle")
1958 .and_then(Value::as_str)
1959 .ok_or("missing 'handle'")?;
1960 let relay_override = args.get("relay_url").and_then(Value::as_str);
1961
1962 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1963
1964 let (our_did, our_relay, our_slot_id, our_slot_token) =
1966 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1967
1968 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1970 .map_err(|e| format!("{e:#}"))?;
1971 let peer_card = resolved
1972 .get("card")
1973 .cloned()
1974 .ok_or("resolved missing card")?;
1975 let peer_did = resolved
1976 .get("did")
1977 .and_then(Value::as_str)
1978 .ok_or("resolved missing did")?
1979 .to_string();
1980 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1981 let peer_slot_id = resolved
1982 .get("slot_id")
1983 .and_then(Value::as_str)
1984 .ok_or("resolved missing slot_id")?
1985 .to_string();
1986 let peer_relay = resolved
1987 .get("relay_url")
1988 .and_then(Value::as_str)
1989 .map(str::to_string)
1990 .or_else(|| relay_override.map(str::to_string))
1991 .unwrap_or_else(|| format!("https://{}", parsed.domain));
1992
1993 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1995 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1996 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1997 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1998 let existing_token = relay_state
1999 .get("peers")
2000 .and_then(|p| p.get(&peer_handle))
2001 .and_then(|p| p.get("slot_token"))
2002 .and_then(Value::as_str)
2003 .map(str::to_string)
2004 .unwrap_or_default();
2005 relay_state["peers"][&peer_handle] = json!({
2006 "relay_url": peer_relay,
2007 "slot_id": peer_slot_id,
2008 "slot_token": existing_token,
2009 });
2010 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
2011
2012 let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2014 let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
2015 let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
2016 let pk_b64 = our_card
2017 .get("verify_keys")
2018 .and_then(Value::as_object)
2019 .and_then(|m| m.values().next())
2020 .and_then(|v| v.get("key"))
2021 .and_then(Value::as_str)
2022 .ok_or("our card missing verify_keys[*].key")?;
2023 let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
2024 let now = time::OffsetDateTime::now_utc()
2025 .format(&time::format_description::well_known::Rfc3339)
2026 .unwrap_or_default();
2027 let event = json!({
2028 "timestamp": now,
2029 "from": our_did,
2030 "to": peer_did,
2031 "type": "pair_drop",
2032 "kind": 1100u32,
2033 "body": {
2034 "card": our_card,
2035 "relay_url": our_relay,
2036 "slot_id": our_slot_id,
2037 "slot_token": our_slot_token,
2038 },
2039 });
2040 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
2041 .map_err(|e| format!("{e:#}"))?;
2042
2043 let client = crate::relay_client::RelayClient::new(&peer_relay);
2044 let resp = client
2045 .handle_intro(&parsed.nick, &signed)
2046 .map_err(|e| format!("{e:#}"))?;
2047 let event_id = signed
2048 .get("event_id")
2049 .and_then(Value::as_str)
2050 .unwrap_or("")
2051 .to_string();
2052 Ok(json!({
2053 "handle": handle,
2054 "paired_with": peer_did,
2055 "peer_handle": peer_handle,
2056 "event_id": event_id,
2057 "drop_response": resp,
2058 "status": "drop_sent",
2059 }))
2060}
2061
2062fn tool_pair_accept(args: &Value) -> Result<Value, String> {
2067 let peer = args
2068 .get("peer")
2069 .and_then(Value::as_str)
2070 .ok_or("missing 'peer'")?;
2071 let nick = crate::agent_card::bare_handle(peer);
2072 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
2073 .map_err(|e| format!("{e:#}"))?
2074 .ok_or_else(|| {
2075 format!(
2076 "no pending pair request from {nick}. Call wire_pair_list_inbound to enumerate, \
2077 or wire_add to send a fresh outbound pair request."
2078 )
2079 })?;
2080
2081 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
2084 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
2085 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
2086
2087 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
2089 relay_state["peers"][&pending.peer_handle] = json!({
2090 "relay_url": pending.peer_relay_url,
2091 "slot_id": pending.peer_slot_id,
2092 "slot_token": pending.peer_slot_token,
2093 });
2094 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
2095
2096 let ack_endpoints: Vec<crate::endpoints::Endpoint> = if pending.peer_endpoints.is_empty() {
2103 vec![crate::endpoints::Endpoint::federation(
2104 pending.peer_relay_url.clone(),
2105 pending.peer_slot_id.clone(),
2106 pending.peer_slot_token.clone(),
2107 )]
2108 } else {
2109 pending.peer_endpoints.clone()
2110 };
2111 crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &ack_endpoints).map_err(|e| {
2112 format!(
2113 "pair_drop_ack send to {} (across {} endpoint(s)) failed: {e:#}",
2114 pending.peer_handle,
2115 ack_endpoints.len()
2116 )
2117 })?;
2118
2119 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2120
2121 Ok(json!({
2122 "status": "bilateral_accepted",
2123 "peer_handle": pending.peer_handle,
2124 "peer_did": pending.peer_did,
2125 "peer_relay_url": pending.peer_relay_url,
2126 "via": "pending_inbound",
2127 }))
2128}
2129
2130fn tool_pair_reject(args: &Value) -> Result<Value, String> {
2133 let peer = args
2134 .get("peer")
2135 .and_then(Value::as_str)
2136 .ok_or("missing 'peer'")?;
2137 let nick = crate::agent_card::bare_handle(peer);
2138 let existed =
2139 crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2140 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
2141 Ok(json!({
2142 "peer": nick,
2143 "rejected": existed.is_some(),
2144 "had_pending": existed.is_some(),
2145 }))
2146}
2147
2148fn tool_pair_list_inbound() -> Result<Value, String> {
2151 let items =
2152 crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
2153 Ok(json!(items))
2154}
2155
2156fn tool_claim_handle(args: &Value) -> Result<Value, String> {
2157 let typed = args.get("nick").and_then(Value::as_str);
2158 let relay_override = args.get("relay_url").and_then(Value::as_str);
2159 let public_url = args.get("public_url").and_then(Value::as_str);
2160
2161 let (_, our_relay, our_slot_id, our_slot_token) =
2163 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
2164 let claim_relay = relay_override.unwrap_or(&our_relay);
2165 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2166
2167 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
2172 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
2173 let nick = if canonical.is_empty() {
2174 typed.unwrap_or_default().to_string()
2175 } else {
2176 canonical
2177 };
2178 let typed_nick_ignored = typed.map(|t| t != nick).unwrap_or(false);
2179
2180 let client = crate::relay_client::RelayClient::new(claim_relay);
2181 let resp = client
2182 .handle_claim(&nick, &our_slot_id, &our_slot_token, public_url, &card)
2183 .map_err(|e| format!("{e:#}"))?;
2184 Ok(json!({
2185 "nick": nick,
2186 "relay": claim_relay,
2187 "response": resp,
2188 "one_name": true,
2189 "typed_nick_ignored": typed_nick_ignored,
2190 }))
2191}
2192
2193fn tool_whois(args: &Value) -> Result<Value, String> {
2194 if let Some(handle) = args.get("handle").and_then(Value::as_str) {
2195 if !handle.contains('@')
2205 && let Ok(target) = crate::cli::resolve_name_to_target(handle)
2206 {
2207 return Ok(dial_target_to_whois_json(&target));
2208 }
2209 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
2210 let relay_override = args.get("relay_url").and_then(Value::as_str);
2211 crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
2212 } else {
2213 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2217 let mut payload = serde_json::Map::new();
2218 payload.insert(
2219 "did".into(),
2220 card.get("did").cloned().unwrap_or(Value::Null),
2221 );
2222 payload.insert(
2223 "profile".into(),
2224 card.get("profile").cloned().unwrap_or(Value::Null),
2225 );
2226 for (k, v) in crate::cli::op_claims_from_card(&card) {
2227 payload.insert(k, v);
2228 }
2229 Ok(Value::Object(payload))
2230 }
2231}
2232
2233fn dial_target_to_whois_json(target: &crate::cli::DialTarget) -> Value {
2239 use crate::cli::DialTarget;
2240 match target {
2241 DialTarget::PinnedPeer {
2242 handle,
2243 did,
2244 nickname,
2245 emoji,
2246 tier,
2247 } => {
2248 let op_claims = crate::config::read_trust()
2249 .ok()
2250 .and_then(|t| {
2251 t.get("agents")
2252 .and_then(Value::as_object)
2253 .and_then(|m| m.get(handle))
2254 .and_then(|a| a.get("card").cloned())
2255 })
2256 .map(|c| crate::cli::op_claims_from_card(&c))
2257 .unwrap_or_default();
2258 let mut payload = serde_json::Map::new();
2259 payload.insert("kind".into(), json!("pinned_peer"));
2260 payload.insert("handle".into(), json!(handle));
2261 payload.insert("did".into(), json!(did));
2262 payload.insert("nickname".into(), json!(nickname));
2263 payload.insert("emoji".into(), json!(emoji));
2264 payload.insert("tier".into(), json!(tier));
2265 for (k, v) in op_claims {
2266 payload.insert(k, v);
2267 }
2268 Value::Object(payload)
2269 }
2270 DialTarget::LocalSister {
2271 session_name,
2272 handle,
2273 did,
2274 nickname,
2275 emoji,
2276 } => json!({
2277 "kind": "local_sister",
2278 "session_name": session_name,
2279 "handle": handle,
2280 "did": did,
2281 "nickname": nickname,
2282 "emoji": emoji,
2283 }),
2284 }
2285}
2286
2287fn tool_profile_set(args: &Value) -> Result<Value, String> {
2288 let field = args
2289 .get("field")
2290 .and_then(Value::as_str)
2291 .ok_or("missing 'field'")?;
2292 let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
2293 let value = if let Some(s) = raw_value.as_str() {
2297 serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
2298 } else {
2299 raw_value
2300 };
2301 let new_profile =
2302 crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
2303 Ok(json!({
2304 "field": field,
2305 "profile": new_profile,
2306 }))
2307}
2308
2309fn tool_profile_get() -> Result<Value, String> {
2310 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2311 Ok(json!({
2312 "did": card.get("did").cloned().unwrap_or(Value::Null),
2313 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
2314 }))
2315}
2316
2317fn parse_kind(s: &str) -> u32 {
2320 if let Ok(n) = s.parse::<u32>() {
2321 return n;
2322 }
2323 for (id, name) in crate::signing::kinds() {
2324 if *name == s {
2325 return *id;
2326 }
2327 }
2328 1
2329}
2330
2331fn error_response(id: &Value, code: i32, message: &str) -> Value {
2332 json!({
2333 "jsonrpc": "2.0",
2334 "id": id,
2335 "error": {"code": code, "message": message}
2336 })
2337}
2338
2339#[cfg(test)]
2340mod tests {
2341 use super::*;
2342
2343 #[test]
2344 fn unknown_method_returns_jsonrpc_error() {
2345 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
2346 let resp = handle_request(&req, &McpState::default());
2347 assert_eq!(resp["error"]["code"], -32601);
2348 }
2349
2350 #[test]
2351 fn initialize_advertises_tools_capability() {
2352 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
2353 let resp = handle_request(&req, &McpState::default());
2354 assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
2355 assert!(resp["result"]["capabilities"]["tools"].is_object());
2356 assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
2357 }
2358
2359 #[test]
2360 fn tools_list_includes_pairing_and_messaging() {
2361 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2362 let resp = handle_request(&req, &McpState::default());
2363 let names: Vec<&str> = resp["result"]["tools"]
2364 .as_array()
2365 .unwrap()
2366 .iter()
2367 .filter_map(|t| t["name"].as_str())
2368 .collect();
2369 for required in [
2370 "wire_whoami",
2371 "wire_peers",
2372 "wire_send",
2373 "wire_tail",
2374 "wire_verify",
2375 "wire_init",
2376 "wire_pair_initiate",
2377 "wire_pair_join",
2378 "wire_pair_check",
2379 "wire_pair_confirm",
2380 ] {
2381 assert!(
2382 names.contains(&required),
2383 "missing required tool {required}"
2384 );
2385 }
2386 assert!(
2390 !names.contains(&"wire_join"),
2391 "wire_join must not be advertised — superseded by wire_pair_join"
2392 );
2393 }
2394
2395 #[test]
2396 fn legacy_wire_join_call_returns_helpful_error() {
2397 let req = json!({
2398 "jsonrpc": "2.0",
2399 "id": 1,
2400 "method": "tools/call",
2401 "params": {"name": "wire_join", "arguments": {}}
2402 });
2403 let resp = handle_request(&req, &McpState::default());
2404 assert_eq!(resp["result"]["isError"], true);
2405 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2406 assert!(
2407 text.contains("wire_pair_join"),
2408 "expected redirect to wire_pair_join, got: {text}"
2409 );
2410 }
2411
2412 #[test]
2413 fn pair_confirm_missing_session_id_errors_cleanly() {
2414 let req = json!({
2415 "jsonrpc": "2.0",
2416 "id": 1,
2417 "method": "tools/call",
2418 "params": {"name": "wire_pair_confirm", "arguments": {"user_typed_digits": "111111"}}
2419 });
2420 let resp = handle_request(&req, &McpState::default());
2421 assert_eq!(resp["result"]["isError"], true);
2422 }
2423
2424 #[test]
2425 fn pair_confirm_unknown_session_errors_cleanly() {
2426 let req = json!({
2427 "jsonrpc": "2.0",
2428 "id": 1,
2429 "method": "tools/call",
2430 "params": {
2431 "name": "wire_pair_confirm",
2432 "arguments": {"session_id": "definitely-not-real", "user_typed_digits": "111111"}
2433 }
2434 });
2435 let resp = handle_request(&req, &McpState::default());
2436 assert_eq!(resp["result"]["isError"], true);
2437 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2438 assert!(text.contains("no such session_id"), "got: {text}");
2439 }
2440
2441 #[test]
2442 fn initialize_advertises_resources_capability() {
2443 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
2444 let resp = handle_request(&req, &McpState::default());
2445 let caps = &resp["result"]["capabilities"];
2446 assert!(
2447 caps["resources"].is_object(),
2448 "resources capability must be present, got {resp}"
2449 );
2450 assert_eq!(
2451 caps["resources"]["subscribe"], true,
2452 "subscribe shipped in v0.2.1"
2453 );
2454 }
2455
2456 #[test]
2457 fn resources_read_with_bad_uri_errors() {
2458 let req = json!({
2459 "jsonrpc": "2.0",
2460 "id": 1,
2461 "method": "resources/read",
2462 "params": {"uri": "http://example.com/not-a-wire-uri"}
2463 });
2464 let resp = handle_request(&req, &McpState::default());
2465 assert!(resp.get("error").is_some(), "expected error, got {resp}");
2466 }
2467
2468 #[test]
2469 fn parse_inbox_uri_handles_variants() {
2470 assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
2471 assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
2472 assert!(
2473 parse_inbox_uri("wire://inbox/")
2474 .unwrap()
2475 .starts_with("__invalid__"),
2476 "empty peer must be invalid"
2477 );
2478 assert!(
2479 parse_inbox_uri("http://other")
2480 .unwrap()
2481 .starts_with("__invalid__"),
2482 "non-wire scheme must be invalid"
2483 );
2484 }
2485
2486 #[test]
2487 fn ping_returns_empty_result() {
2488 let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2489 let resp = handle_request(&req, &McpState::default());
2490 assert_eq!(resp["id"], 7);
2491 assert!(resp["result"].is_object());
2492 }
2493
2494 #[test]
2495 fn notification_returns_null_no_reply() {
2496 let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2497 let resp = handle_request(&req, &McpState::default());
2498 assert_eq!(resp, Value::Null);
2499 }
2500
2501 #[test]
2508 fn detect_session_wire_home_resolves_registered_cwd() {
2509 crate::config::test_support::with_temp_home(|| {
2510 let wire_home = std::env::var("WIRE_HOME").unwrap();
2514 let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2515 let session_home = sessions_root.join("test-alpha");
2516 std::fs::create_dir_all(&session_home).unwrap();
2517 let fake_cwd = "/tmp/fake-project-cwd-abc123";
2518 let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2519 std::fs::write(
2520 sessions_root.join("registry.json"),
2521 serde_json::to_vec_pretty(®istry).unwrap(),
2522 )
2523 .unwrap();
2524
2525 let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2527 assert_eq!(
2528 got.as_deref(),
2529 Some(session_home.as_path()),
2530 "registered cwd must resolve to session_home"
2531 );
2532
2533 let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2535 "/tmp/cwd-not-in-registry-xyz789",
2536 ));
2537 assert!(nope.is_none(), "unregistered cwd must return None");
2538
2539 let stale_cwd = "/tmp/stale-session-cwd";
2542 let stale_registry =
2543 json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2544 std::fs::write(
2545 sessions_root.join("registry.json"),
2546 serde_json::to_vec_pretty(&stale_registry).unwrap(),
2547 )
2548 .unwrap();
2549 let stale_got =
2550 crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2551 assert!(
2552 stale_got.is_none(),
2553 "registered cwd whose session dir is missing must return None"
2554 );
2555 });
2556 }
2557
2558 #[test]
2565 fn dial_target_to_whois_json_pinned_peer_shape() {
2566 let target = crate::cli::DialTarget::PinnedPeer {
2567 handle: "slate-lotus".into(),
2568 did: "did:wire:slate-lotus-88232017".into(),
2569 nickname: Some("slate-lotus".into()),
2570 emoji: Some("🪴".into()),
2571 tier: "VERIFIED".into(),
2572 };
2573 crate::config::test_support::with_temp_home(|| {
2574 let out = dial_target_to_whois_json(&target);
2575 assert_eq!(out.get("kind").and_then(Value::as_str), Some("pinned_peer"));
2576 assert_eq!(
2577 out.get("handle").and_then(Value::as_str),
2578 Some("slate-lotus")
2579 );
2580 assert_eq!(out.get("tier").and_then(Value::as_str), Some("VERIFIED"));
2581 assert!(out.get("op_did").is_none());
2585 });
2586 }
2587
2588 #[test]
2589 fn dial_target_to_whois_json_local_sister_shape() {
2590 let target = crate::cli::DialTarget::LocalSister {
2591 session_name: "vesper-valley".into(),
2592 handle: "vesper-valley".into(),
2593 did: Some("did:wire:vesper-valley-deadbeef".into()),
2594 nickname: Some("vesper-valley".into()),
2595 emoji: Some("🦌".into()),
2596 };
2597 let out = dial_target_to_whois_json(&target);
2598 assert_eq!(
2599 out.get("kind").and_then(Value::as_str),
2600 Some("local_sister")
2601 );
2602 assert_eq!(
2603 out.get("session_name").and_then(Value::as_str),
2604 Some("vesper-valley")
2605 );
2606 assert_eq!(
2607 out.get("did").and_then(Value::as_str),
2608 Some("did:wire:vesper-valley-deadbeef")
2609 );
2610 assert!(out.get("tier").is_none());
2613 assert!(out.get("op_did").is_none());
2614 }
2615}