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