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 crate::session::warn_on_identity_collision(std::process::id());
127
128 let state = McpState::default();
129 let shutdown = Arc::new(AtomicBool::new(false));
130
131 let (tx, rx) = mpsc::channel::<String>();
132
133 if let Ok(mut g) = state.notif_tx.lock() {
136 *g = Some(tx.clone());
137 }
138
139 let writer_handle = std::thread::spawn(move || {
141 let stdout = std::io::stdout();
142 let mut w = stdout.lock();
143 while let Ok(line) = rx.recv() {
144 if writeln!(w, "{line}").is_err() {
145 break;
146 }
147 if w.flush().is_err() {
148 break;
149 }
150 }
151 });
152
153 let subs_w = state.subscribed.clone();
158 let tx_w = tx.clone();
159 let shutdown_w = shutdown.clone();
160 let watcher_handle = std::thread::spawn(move || {
161 let mut watcher = match crate::inbox_watch::InboxWatcher::from_head() {
162 Ok(w) => w,
163 Err(_) => return,
164 };
165 let mut prev_pending: std::collections::HashMap<String, String> =
169 std::collections::HashMap::new();
170 let poll_interval = Duration::from_secs(2);
171 let mut next_poll = Instant::now() + poll_interval;
172 loop {
173 if shutdown_w.load(Ordering::SeqCst) {
174 return;
175 }
176 std::thread::sleep(Duration::from_millis(100));
177 if Instant::now() < next_poll {
178 continue;
179 }
180 next_poll = Instant::now() + poll_interval;
181 let subs_snapshot = match subs_w.lock() {
182 Ok(g) => g.clone(),
183 Err(_) => return,
184 };
185
186 let mut affected: HashSet<String> = HashSet::new();
187
188 if !subs_snapshot.is_empty()
190 && let Ok(events) = watcher.poll()
191 {
192 for ev in &events {
193 if subs_snapshot.contains("wire://inbox/all") {
194 affected.insert("wire://inbox/all".to_string());
195 }
196 let peer_uri = format!("wire://inbox/{}", ev.peer);
197 if subs_snapshot.contains(&peer_uri) {
198 affected.insert(peer_uri);
199 }
200 }
201 }
202
203 if let Ok(items) = crate::pending_pair::list_pending() {
206 let mut cur: std::collections::HashMap<String, String> =
207 std::collections::HashMap::new();
208 for p in &items {
209 cur.insert(p.code.clone(), p.status.clone());
210 }
211 let changed = cur.len() != prev_pending.len()
214 || cur.iter().any(|(k, v)| prev_pending.get(k) != Some(v))
215 || prev_pending.keys().any(|k| !cur.contains_key(k));
216 if changed && subs_snapshot.contains("wire://pending-pair/all") {
217 affected.insert("wire://pending-pair/all".to_string());
218 }
219 prev_pending = cur;
220 }
221
222 for uri in affected {
223 let notif = json!({
224 "jsonrpc": "2.0",
225 "method": "notifications/resources/updated",
226 "params": {"uri": uri}
227 });
228 if tx_w.send(notif.to_string()).is_err() {
229 return;
230 }
231 }
232 }
233 });
234
235 let stdin = std::io::stdin();
236 let mut reader = BufReader::new(stdin.lock());
237 let mut line = String::new();
238 loop {
239 line.clear();
240 let n = reader.read_line(&mut line)?;
241 if n == 0 {
242 shutdown.store(true, Ordering::SeqCst);
246 if let Ok(mut g) = state.notif_tx.lock() {
247 *g = None;
248 }
249 drop(tx);
250 let _ = watcher_handle.join();
251 let _ = writer_handle.join();
252 return Ok(());
253 }
254 let trimmed = line.trim();
255 if trimmed.is_empty() {
256 continue;
257 }
258 let request: Value = match serde_json::from_str(trimmed) {
259 Ok(v) => v,
260 Err(e) => {
261 let err = error_response(&Value::Null, -32700, &format!("parse error: {e}"));
262 let _ = tx.send(err.to_string());
263 continue;
264 }
265 };
266 let response = handle_request(&request, &state);
267 if response.get("id").is_some() || response.get("error").is_some() {
269 let _ = tx.send(response.to_string());
270 }
271 }
272}
273
274fn handle_request(req: &Value, state: &McpState) -> Value {
275 let id = req.get("id").cloned().unwrap_or(Value::Null);
276 let method = match req.get("method").and_then(Value::as_str) {
277 Some(m) => m,
278 None => return error_response(&id, -32600, "missing method"),
279 };
280 match method {
281 "initialize" => handle_initialize(&id),
282 "notifications/initialized" => Value::Null, "tools/list" => handle_tools_list(&id),
284 "tools/call" => handle_tools_call(&id, req.get("params").unwrap_or(&Value::Null), state),
285 "resources/list" => handle_resources_list(&id),
286 "resources/read" => handle_resources_read(&id, req.get("params").unwrap_or(&Value::Null)),
287 "resources/subscribe" => {
288 handle_resources_subscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
289 }
290 "resources/unsubscribe" => {
291 handle_resources_unsubscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
292 }
293 "ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}),
294 other => error_response(&id, -32601, &format!("method not found: {other}")),
295 }
296}
297
298fn handle_resources_list(id: &Value) -> Value {
310 let mut resources = vec![
311 json!({
312 "uri": "wire://inbox/all",
313 "name": "wire inbox (all peers)",
314 "description": "Most recent verified events from all pinned peers, JSONL.",
315 "mimeType": "application/x-ndjson"
316 }),
317 json!({
318 "uri": "wire://pending-pair/all",
319 "name": "wire pending pair sessions",
320 "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).",
321 "mimeType": "application/json"
322 }),
323 ];
324
325 if let Ok(trust) = crate::config::read_trust() {
326 let agents = trust
327 .get("agents")
328 .and_then(Value::as_object)
329 .cloned()
330 .unwrap_or_default();
331 let self_did = crate::config::read_agent_card()
332 .ok()
333 .and_then(|c| c.get("did").and_then(Value::as_str).map(str::to_string));
334 for (handle, agent) in agents.iter() {
335 let did = agent
336 .get("did")
337 .and_then(Value::as_str)
338 .unwrap_or("")
339 .to_string();
340 if Some(did.as_str()) == self_did.as_deref() {
341 continue;
342 }
343 resources.push(json!({
344 "uri": format!("wire://inbox/{handle}"),
345 "name": format!("inbox from {handle}"),
346 "description": format!("Recent verified events from did:wire:{handle}."),
347 "mimeType": "application/x-ndjson"
348 }));
349 }
350 }
351
352 json!({
353 "jsonrpc": "2.0",
354 "id": id,
355 "result": {
356 "resources": resources
357 }
358 })
359}
360
361fn handle_resources_subscribe(id: &Value, params: &Value, state: &McpState) -> Value {
362 let uri = match params.get("uri").and_then(Value::as_str) {
363 Some(u) => u.to_string(),
364 None => return error_response(id, -32602, "missing 'uri'"),
365 };
366 let inbox_peer = parse_inbox_uri(&uri);
370 let is_pending = uri == "wire://pending-pair/all";
371 if let Some(ref p) = inbox_peer
372 && p.starts_with("__invalid__")
373 && !is_pending
374 {
375 return error_response(
376 id,
377 -32602,
378 "subscribe URI must be wire://inbox/<peer>, wire://inbox/all, or wire://pending-pair/all",
379 );
380 }
381 if let Ok(mut g) = state.subscribed.lock() {
382 g.insert(uri);
383 }
384 json!({"jsonrpc": "2.0", "id": id, "result": {}})
385}
386
387fn handle_resources_unsubscribe(id: &Value, params: &Value, state: &McpState) -> Value {
388 let uri = match params.get("uri").and_then(Value::as_str) {
389 Some(u) => u.to_string(),
390 None => return error_response(id, -32602, "missing 'uri'"),
391 };
392 if let Ok(mut g) = state.subscribed.lock() {
393 g.remove(&uri);
394 }
395 json!({"jsonrpc": "2.0", "id": id, "result": {}})
396}
397
398fn handle_resources_read(id: &Value, params: &Value) -> Value {
399 let uri = match params.get("uri").and_then(Value::as_str) {
400 Some(u) => u,
401 None => return error_response(id, -32602, "missing 'uri'"),
402 };
403 if uri == "wire://pending-pair/all" {
405 return match crate::pending_pair::list_pending() {
406 Ok(items) => {
407 let body = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string());
408 json!({
409 "jsonrpc": "2.0",
410 "id": id,
411 "result": {
412 "contents": [{
413 "uri": uri,
414 "mimeType": "application/json",
415 "text": body,
416 }]
417 }
418 })
419 }
420 Err(e) => error_response(id, -32603, &e.to_string()),
421 };
422 }
423 let peer_opt = parse_inbox_uri(uri);
424 match read_inbox_resource(peer_opt) {
425 Ok(payload) => json!({
426 "jsonrpc": "2.0",
427 "id": id,
428 "result": {
429 "contents": [{
430 "uri": uri,
431 "mimeType": "application/x-ndjson",
432 "text": payload,
433 }]
434 }
435 }),
436 Err(e) => error_response(id, -32603, &e.to_string()),
437 }
438}
439
440fn parse_inbox_uri(uri: &str) -> Option<String> {
443 if let Some(rest) = uri.strip_prefix("wire://inbox/") {
444 if rest == "all" {
445 return None;
446 }
447 if !rest.is_empty() {
448 return Some(rest.to_string());
449 }
450 }
451 Some(format!("__invalid__{uri}"))
452}
453
454fn read_inbox_resource(peer_opt: Option<String>) -> Result<String, String> {
455 const LIMIT: usize = 50;
456 if let Some(ref p) = peer_opt
459 && p.starts_with("__invalid__")
460 {
461 return Err(
462 "unknown resource URI (must be wire://inbox/<peer> or wire://inbox/all)".into(),
463 );
464 }
465 let inbox = crate::config::inbox_dir().map_err(|e| e.to_string())?;
466 if !inbox.exists() {
467 return Ok(String::new());
468 }
469 let trust = crate::config::read_trust().map_err(|e| e.to_string())?;
470
471 let paths: Vec<std::path::PathBuf> = match peer_opt {
472 Some(p) => {
473 let path = inbox.join(format!("{p}.jsonl"));
474 if !path.exists() {
475 return Ok(String::new());
476 }
477 vec![path]
478 }
479 None => std::fs::read_dir(&inbox)
480 .map_err(|e| e.to_string())?
481 .flatten()
482 .map(|e| e.path())
483 .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
484 .collect(),
485 };
486
487 let mut events: Vec<(String, bool, Value)> = Vec::new();
488 for path in paths {
489 let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
490 let peer = path
491 .file_stem()
492 .and_then(|s| s.to_str())
493 .unwrap_or("")
494 .to_string();
495 for line in body.lines() {
496 let event: Value = match serde_json::from_str(line) {
497 Ok(v) => v,
498 Err(_) => continue,
499 };
500 let verified = crate::signing::verify_message_v31(&event, &trust).is_ok();
501 events.push((peer.clone(), verified, event));
502 }
503 }
504 let take_from = events.len().saturating_sub(LIMIT);
506 let tail = &events[take_from..];
507
508 let mut out = String::new();
509 for (_peer, verified, mut event) in tail.iter().cloned() {
510 if let Some(obj) = event.as_object_mut() {
511 obj.insert("verified".into(), json!(verified));
512 }
513 out.push_str(&serde_json::to_string(&event).map_err(|e| e.to_string())?);
514 out.push('\n');
515 }
516 Ok(out)
517}
518
519fn handle_initialize(id: &Value) -> Value {
520 json!({
521 "jsonrpc": "2.0",
522 "id": id,
523 "result": {
524 "protocolVersion": PROTOCOL_VERSION,
525 "capabilities": {
526 "tools": {"listChanged": false},
527 "resources": {
528 "listChanged": false,
529 "subscribe": true
534 }
535 },
536 "serverInfo": {
537 "name": SERVER_NAME,
538 "version": SERVER_VERSION,
539 },
540 "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). RECOMMENDED ON SESSION START: arm a persistent stream-watcher on `wire monitor --json` so peer messages surface mid-session instead of on next manual poll. In Claude Code that's the Monitor tool with persistent:true. 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)."
541 }
542 })
543}
544
545fn handle_tools_list(id: &Value) -> Value {
546 json!({
547 "jsonrpc": "2.0",
548 "id": id,
549 "result": {
550 "tools": tool_defs(),
551 }
552 })
553}
554
555fn tool_defs() -> Vec<Value> {
556 vec![
557 json!({
558 "name": "wire_whoami",
559 "description": "Return this agent's DID, fingerprint, key_id, public key, and capabilities. Read-only.",
560 "inputSchema": {"type": "object", "properties": {}, "required": []}
561 }),
562 json!({
563 "name": "wire_peers",
564 "description": "List pinned peers with their tier (UNTRUSTED/VERIFIED/ATTESTED) and advertised capabilities. Read-only.",
565 "inputSchema": {"type": "object", "properties": {}, "required": []}
566 }),
567 json!({
568 "name": "wire_send",
569 "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.",
570 "inputSchema": {
571 "type": "object",
572 "properties": {
573 "peer": {"type": "string", "description": "Peer handle (without did:wire: prefix). Must be a pinned peer; check wire_peers first."},
574 "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."},
575 "body": {"type": "string", "description": "Event body. Plain text becomes a JSON string; valid JSON is parsed and embedded structurally."},
576 "time_sensitive_until": {"type": "string", "description": "Optional advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp."}
577 },
578 "required": ["peer", "kind", "body"]
579 }
580 }),
581 json!({
582 "name": "wire_tail",
583 "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.",
584 "inputSchema": {
585 "type": "object",
586 "properties": {
587 "peer": {"type": "string", "description": "Optional peer handle to filter inbox by."},
588 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Max events to return."}
589 },
590 "required": []
591 }
592 }),
593 json!({
594 "name": "wire_verify",
595 "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).",
596 "inputSchema": {
597 "type": "object",
598 "properties": {
599 "event": {"type": "string", "description": "JSON-encoded signed event."}
600 },
601 "required": ["event"]
602 }
603 }),
604 json!({
605 "name": "wire_init",
606 "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.",
607 "inputSchema": {
608 "type": "object",
609 "properties": {
610 "handle": {"type": "string", "description": "Short handle (becomes did:wire:<handle>). ASCII alphanumeric / '-' / '_' only."},
611 "name": {"type": "string", "description": "Optional display name (defaults to capitalized handle)."},
612 "relay_url": {"type": "string", "description": "Optional relay URL — if set, also binds a relay slot."}
613 },
614 "required": ["handle"]
615 }
616 }),
617 json!({
618 "name": "wire_pair_initiate",
619 "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).",
620 "inputSchema": {
621 "type": "object",
622 "properties": {
623 "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
624 "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
625 "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."}
626 },
627 "required": []
628 }
629 }),
630 json!({
631 "name": "wire_pair_join",
632 "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.",
633 "inputSchema": {
634 "type": "object",
635 "properties": {
636 "code_phrase": {"type": "string", "description": "Code phrase from the host (e.g. '73-2QXC4P')."},
637 "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
638 "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
639 "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 30, "description": "How long to block waiting for SPAKE2 exchange to complete."}
640 },
641 "required": ["code_phrase"]
642 }
643 }),
644 json!({
645 "name": "wire_pair_check",
646 "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.",
647 "inputSchema": {
648 "type": "object",
649 "properties": {
650 "session_id": {"type": "string"},
651 "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 8}
652 },
653 "required": ["session_id"]
654 }
655 }),
656 json!({
657 "name": "wire_pair_confirm",
658 "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').",
659 "inputSchema": {
660 "type": "object",
661 "properties": {
662 "session_id": {"type": "string"},
663 "user_typed_digits": {"type": "string", "description": "The 6 SAS digits the user typed back, e.g. '384217' or '384-217'."}
664 },
665 "required": ["session_id", "user_typed_digits"]
666 }
667 }),
668 json!({
669 "name": "wire_pair_initiate_detached",
670 "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.",
671 "inputSchema": {
672 "type": "object",
673 "properties": {
674 "handle": {"type": "string", "description": "Optional handle for auto-init (idempotent)."},
675 "relay_url": {"type": "string"}
676 }
677 }
678 }),
679 json!({
680 "name": "wire_pair_join_detached",
681 "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.",
682 "inputSchema": {
683 "type": "object",
684 "properties": {
685 "handle": {"type": "string"},
686 "code_phrase": {"type": "string"},
687 "relay_url": {"type": "string"}
688 },
689 "required": ["code_phrase"]
690 }
691 }),
692 json!({
693 "name": "wire_pair_list_pending",
694 "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.",
695 "inputSchema": {"type": "object", "properties": {}}
696 }),
697 json!({
698 "name": "wire_pair_confirm_detached",
699 "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.",
700 "inputSchema": {
701 "type": "object",
702 "properties": {
703 "code_phrase": {"type": "string"},
704 "user_typed_digits": {"type": "string"}
705 },
706 "required": ["code_phrase", "user_typed_digits"]
707 }
708 }),
709 json!({
710 "name": "wire_pair_cancel_pending",
711 "description": "Cancel a pending detached pair. Releases the relay slot and removes the local pending file. Safe to call regardless of current status (idempotent).",
712 "inputSchema": {
713 "type": "object",
714 "properties": {"code_phrase": {"type": "string"}},
715 "required": ["code_phrase"]
716 }
717 }),
718 json!({
719 "name": "wire_invite_mint",
720 "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}.",
721 "inputSchema": {
722 "type": "object",
723 "properties": {
724 "relay_url": {"type": "string", "description": "Override relay for first-time auto-allocate."},
725 "ttl_secs": {"type": "integer", "description": "Invite lifetime in seconds (default 86400)."},
726 "uses": {"type": "integer", "description": "Number of distinct peers that can accept before consumption (default 1)."}
727 }
728 }
729 }),
730 json!({
731 "name": "wire_invite_accept",
732 "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}.",
733 "inputSchema": {
734 "type": "object",
735 "properties": {
736 "url": {"type": "string", "description": "Full wire://pair?v=1&inv=... URL."}
737 },
738 "required": ["url"]
739 }
740 }),
741 json!({
743 "name": "wire_add",
744 "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.",
745 "inputSchema": {
746 "type": "object",
747 "properties": {
748 "handle": {"type": "string", "description": "Peer handle like `nick@domain`."},
749 "relay_url": {"type": "string", "description": "Override resolver URL (default: `https://<domain>`)."}
750 },
751 "required": ["handle"]
752 }
753 }),
754 json!({
755 "name": "wire_pair_accept",
756 "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.",
757 "inputSchema": {
758 "type": "object",
759 "properties": {
760 "peer": {"type": "string", "description": "Bare peer handle (without `@<relay>`). Match exactly what `wire_pair_list_inbound` returned in `peer_handle`."}
761 },
762 "required": ["peer"]
763 }
764 }),
765 json!({
766 "name": "wire_pair_reject",
767 "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.",
768 "inputSchema": {
769 "type": "object",
770 "properties": {
771 "peer": {"type": "string", "description": "Bare peer handle (without `@<relay>`)."}
772 },
773 "required": ["peer"]
774 }
775 }),
776 json!({
777 "name": "wire_pair_list_inbound",
778 "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.",
779 "inputSchema": {"type": "object", "properties": {}}
780 }),
781 json!({
786 "name": "wire_dial",
787 "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.",
788 "inputSchema": {
789 "type": "object",
790 "properties": {
791 "name": {"type": "string", "description": "Peer name — character nickname / session / handle / DID / `<handle>@<relay>`."}
792 },
793 "required": ["name"]
794 }
795 }),
796 json!({
797 "name": "wire_accept",
798 "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.",
799 "inputSchema": {
800 "type": "object",
801 "properties": {
802 "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle, from wire_pending)."}
803 },
804 "required": ["peer"]
805 }
806 }),
807 json!({
808 "name": "wire_reject",
809 "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.",
810 "inputSchema": {
811 "type": "object",
812 "properties": {
813 "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle)."}
814 },
815 "required": ["peer"]
816 }
817 }),
818 json!({
819 "name": "wire_pending",
820 "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.",
821 "inputSchema": {"type": "object", "properties": {}}
822 }),
823 json!({
824 "name": "wire_claim",
825 "description": "Claim a nick on a relay's handle directory so other agents can reach this agent by `<nick>@<relay-domain>`. Auto-inits + auto-allocates a relay slot if needed. FCFS — same-DID re-claims allowed (used for profile/slot updates).",
826 "inputSchema": {
827 "type": "object",
828 "properties": {
829 "nick": {"type": "string", "description": "2-32 chars, [a-z0-9_-], not in the reserved set."},
830 "relay_url": {"type": "string", "description": "Relay to claim on. Default = our relay."},
831 "public_url": {"type": "string", "description": "Public URL the relay should advertise to resolvers."}
832 },
833 "required": ["nick"]
834 }
835 }),
836 json!({
837 "name": "wire_whois",
838 "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.",
839 "inputSchema": {
840 "type": "object",
841 "properties": {
842 "handle": {"type": "string", "description": "Optional `nick@domain`. Omit for self."},
843 "relay_url": {"type": "string", "description": "Override resolver URL."}
844 }
845 }
846 }),
847 json!({
848 "name": "wire_profile_set",
849 "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.",
850 "inputSchema": {
851 "type": "object",
852 "properties": {
853 "field": {"type": "string", "description": "One of: display_name, emoji, motto, vibe, pronouns, avatar_url, handle, now."},
854 "value": {"description": "String for most fields; array for vibe; object for now. Pass JSON null to clear a field."}
855 },
856 "required": ["field", "value"]
857 }
858 }),
859 json!({
860 "name": "wire_profile_get",
861 "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.",
862 "inputSchema": {"type": "object", "properties": {}}
863 }),
864 ]
865}
866
867fn handle_tools_call(id: &Value, params: &Value, state: &McpState) -> Value {
868 let name = match params.get("name").and_then(Value::as_str) {
869 Some(n) => n,
870 None => return error_response(id, -32602, "missing tool name"),
871 };
872 let args = params
873 .get("arguments")
874 .cloned()
875 .unwrap_or_else(|| json!({}));
876
877 let result = match name {
878 "wire_whoami" => tool_whoami(),
879 "wire_peers" => tool_peers(),
880 "wire_send" => tool_send(&args),
881 "wire_tail" => tool_tail(&args),
882 "wire_verify" => tool_verify(&args),
883 "wire_init" => tool_init(&args),
884 "wire_pair_initiate" => tool_pair_initiate(&args),
885 "wire_pair_join" => tool_pair_join(&args),
886 "wire_pair_check" => tool_pair_check(&args),
887 "wire_pair_confirm" => tool_pair_confirm(&args, state),
888 "wire_pair_initiate_detached" => tool_pair_initiate_detached(&args),
889 "wire_pair_join_detached" => tool_pair_join_detached(&args),
890 "wire_pair_list_pending" => tool_pair_list_pending(),
891 "wire_pair_confirm_detached" => tool_pair_confirm_detached(&args),
892 "wire_pair_cancel_pending" => tool_pair_cancel_pending(&args),
893 "wire_invite_mint" => tool_invite_mint(&args),
894 "wire_invite_accept" => tool_invite_accept(&args),
895 "wire_add" => tool_add(&args),
897 "wire_pair_accept" | "wire_accept" => tool_pair_accept(&args),
903 "wire_pair_reject" | "wire_reject" => tool_pair_reject(&args),
904 "wire_pair_list_inbound" | "wire_pending" => tool_pair_list_inbound(),
905 "wire_dial" => tool_dial(&args),
906 "wire_claim" => tool_claim_handle(&args),
907 "wire_whois" => tool_whois(&args),
908 "wire_profile_set" => tool_profile_set(&args),
909 "wire_profile_get" => tool_profile_get(),
910 "wire_join" => Err(
913 "wire_join was renamed to wire_pair_join (use code_phrase argument). \
914 See docs/AGENT_INTEGRATION.md."
915 .into(),
916 ),
917 other => Err(format!("unknown tool: {other}")),
918 };
919
920 match result {
921 Ok(value) => json!({
922 "jsonrpc": "2.0",
923 "id": id,
924 "result": {
925 "content": [{
926 "type": "text",
927 "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
928 }],
929 "isError": false
930 }
931 }),
932 Err(message) => json!({
933 "jsonrpc": "2.0",
934 "id": id,
935 "result": {
936 "content": [{"type": "text", "text": message}],
937 "isError": true
938 }
939 }),
940 }
941}
942
943fn tool_whoami() -> Result<Value, String> {
946 use crate::config;
947 use crate::signing::{b64decode, fingerprint, make_key_id};
948
949 if !config::is_initialized().map_err(|e| e.to_string())? {
950 return Err("not initialized — operator must run `wire init <handle>` first".into());
951 }
952 let card = config::read_agent_card().map_err(|e| e.to_string())?;
953 let did = card
954 .get("did")
955 .and_then(Value::as_str)
956 .unwrap_or("")
957 .to_string();
958 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
959 let pk_b64 = card
960 .get("verify_keys")
961 .and_then(Value::as_object)
962 .and_then(|m| m.values().next())
963 .and_then(|v| v.get("key"))
964 .and_then(Value::as_str)
965 .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
966 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
967 let fp = fingerprint(&pk_bytes);
968 let key_id = make_key_id(&handle, &pk_bytes);
969 let capabilities = card
970 .get("capabilities")
971 .cloned()
972 .unwrap_or_else(|| json!(["wire/v3.1"]));
973 let persona =
977 serde_json::to_value(crate::character::Character::from_card(&card)).unwrap_or(Value::Null);
978 Ok(json!({
979 "did": did,
980 "handle": handle,
981 "persona": persona,
982 "fingerprint": fp,
983 "key_id": key_id,
984 "public_key_b64": pk_b64,
985 "capabilities": capabilities,
986 }))
987}
988
989fn tool_peers() -> Result<Value, String> {
990 use crate::config;
991 use crate::trust::get_tier;
992
993 let trust = config::read_trust().map_err(|e| e.to_string())?;
994 let agents = trust
995 .get("agents")
996 .and_then(Value::as_object)
997 .cloned()
998 .unwrap_or_default();
999 let mut self_did: Option<String> = None;
1000 if let Ok(card) = config::read_agent_card() {
1001 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
1002 }
1003 let mut peers = Vec::new();
1004 for (handle, agent) in agents.iter() {
1005 let did = agent
1006 .get("did")
1007 .and_then(Value::as_str)
1008 .unwrap_or("")
1009 .to_string();
1010 if Some(did.as_str()) == self_did.as_deref() {
1011 continue;
1012 }
1013 let persona = match agent.get("card") {
1017 Some(c) => crate::character::Character::from_card(c),
1018 None => crate::character::Character::from_did(&did),
1019 };
1020 peers.push(json!({
1021 "handle": handle,
1022 "persona": serde_json::to_value(&persona).unwrap_or(Value::Null),
1023 "did": did,
1024 "tier": get_tier(&trust, handle),
1025 "capabilities": agent.get("card").and_then(|c| c.get("capabilities")).cloned().unwrap_or_else(|| json!([])),
1026 }));
1027 }
1028 Ok(json!(peers))
1029}
1030
1031fn tool_send(args: &Value) -> Result<Value, String> {
1032 use crate::config;
1033 use crate::signing::{b64decode, sign_message_v31};
1034
1035 let peer = args
1036 .get("peer")
1037 .and_then(Value::as_str)
1038 .ok_or("missing 'peer'")?;
1039 let peer = crate::agent_card::bare_handle(peer);
1040 let kind = args
1041 .get("kind")
1042 .and_then(Value::as_str)
1043 .ok_or("missing 'kind'")?;
1044 let body = args
1045 .get("body")
1046 .and_then(Value::as_str)
1047 .ok_or("missing 'body'")?;
1048 let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
1049
1050 if !config::is_initialized().map_err(|e| e.to_string())? {
1051 return Err("not initialized — operator must run `wire init <handle>` first".into());
1052 }
1053 let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
1054 let card = config::read_agent_card().map_err(|e| e.to_string())?;
1055 let did = card
1056 .get("did")
1057 .and_then(Value::as_str)
1058 .unwrap_or("")
1059 .to_string();
1060 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1061 let pk_b64 = card
1062 .get("verify_keys")
1063 .and_then(Value::as_object)
1064 .and_then(|m| m.values().next())
1065 .and_then(|v| v.get("key"))
1066 .and_then(Value::as_str)
1067 .ok_or("agent-card missing verify_keys[*].key")?;
1068 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1069
1070 let body_value: Value =
1072 serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1073 let kind_id = parse_kind(kind);
1074
1075 let now = time::OffsetDateTime::now_utc()
1076 .format(&time::format_description::well_known::Rfc3339)
1077 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1078
1079 let mut event = json!({
1080 "timestamp": now,
1081 "from": did,
1082 "to": format!("did:wire:{peer}"),
1083 "type": kind,
1084 "kind": kind_id,
1085 "body": body_value,
1086 });
1087 if let Some(deadline) = deadline {
1088 event["time_sensitive_until"] =
1089 json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1090 }
1091 let signed =
1092 sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1093 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1094
1095 let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1096 let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1097
1098 Ok(json!({
1099 "event_id": event_id,
1100 "status": "queued",
1101 "peer": peer,
1102 "outbox": outbox.to_string_lossy(),
1103 }))
1104}
1105
1106fn tool_tail(args: &Value) -> Result<Value, String> {
1107 use crate::config;
1108 use crate::signing::verify_message_v31;
1109
1110 let peer_filter = args.get("peer").and_then(Value::as_str);
1111 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1112 let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1113 if !inbox.exists() {
1114 return Ok(json!([]));
1115 }
1116 let trust = config::read_trust().map_err(|e| e.to_string())?;
1117 let mut events = Vec::new();
1118 let entries: Vec<_> = std::fs::read_dir(&inbox)
1119 .map_err(|e| e.to_string())?
1120 .filter_map(|e| e.ok())
1121 .map(|e| e.path())
1122 .filter(|p| {
1123 p.extension().map(|x| x == "jsonl").unwrap_or(false)
1124 && match peer_filter {
1125 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1126 None => true,
1127 }
1128 })
1129 .collect();
1130 for path in entries {
1131 let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
1132 for line in body.lines() {
1133 let event: Value = match serde_json::from_str(line) {
1134 Ok(v) => v,
1135 Err(_) => continue,
1136 };
1137 let verified = verify_message_v31(&event, &trust).is_ok();
1138 let mut event_with_meta = event.clone();
1139 if let Some(obj) = event_with_meta.as_object_mut() {
1140 obj.insert("verified".into(), json!(verified));
1141 }
1142 events.push(event_with_meta);
1143 if events.len() >= limit {
1144 return Ok(Value::Array(events));
1145 }
1146 }
1147 }
1148 Ok(Value::Array(events))
1149}
1150
1151fn tool_verify(args: &Value) -> Result<Value, String> {
1152 use crate::config;
1153 use crate::signing::verify_message_v31;
1154
1155 let event_str = args
1156 .get("event")
1157 .and_then(Value::as_str)
1158 .ok_or("missing 'event'")?;
1159 let event: Value =
1160 serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1161 let trust = config::read_trust().map_err(|e| e.to_string())?;
1162 match verify_message_v31(&event, &trust) {
1163 Ok(()) => Ok(json!({"verified": true})),
1164 Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1165 }
1166}
1167
1168fn tool_init(args: &Value) -> Result<Value, String> {
1171 let handle = args
1172 .get("handle")
1173 .and_then(Value::as_str)
1174 .ok_or("missing 'handle'")?;
1175 let name = args.get("name").and_then(Value::as_str);
1176 let relay = args.get("relay_url").and_then(Value::as_str);
1177 crate::pair_session::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1178}
1179
1180fn resolve_relay_url(args: &Value) -> Result<String, String> {
1184 if let Some(url) = args.get("relay_url").and_then(Value::as_str) {
1185 return Ok(url.to_string());
1186 }
1187 let state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1188 state["self"]["relay_url"]
1189 .as_str()
1190 .map(str::to_string)
1191 .ok_or_else(|| "no relay_url provided and no relay bound (call wire_init with relay_url, or pass relay_url here)".into())
1192}
1193
1194fn auto_init_if_needed(args: &Value) -> Result<(), String> {
1200 let initialized = crate::config::is_initialized().map_err(|e| e.to_string())?;
1201 if initialized {
1202 return Ok(());
1203 }
1204 let handle = args.get("handle").and_then(Value::as_str).ok_or(
1205 "not initialized — pass `handle` to auto-init, or call wire_init explicitly first",
1206 )?;
1207 let relay = args.get("relay_url").and_then(Value::as_str);
1208 crate::pair_session::init_self_idempotent(handle, None, relay)
1209 .map(|_| ())
1210 .map_err(|e| e.to_string())
1211}
1212
1213fn tool_pair_initiate(args: &Value) -> Result<Value, String> {
1214 use crate::pair_session::{
1215 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1216 };
1217
1218 store_sweep_expired();
1219 auto_init_if_needed(args)?;
1221
1222 let relay_url = resolve_relay_url(args)?;
1223 let max_wait = args
1224 .get("max_wait_secs")
1225 .and_then(Value::as_u64)
1226 .unwrap_or(30)
1227 .min(60);
1228
1229 let mut s = pair_session_open("host", &relay_url, None).map_err(|e| e.to_string())?;
1230 let code = s.code.clone();
1231
1232 let sas_opt = if max_wait > 0 {
1233 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1234 .map_err(|e| e.to_string())?
1235 } else {
1236 None
1237 };
1238
1239 let session_id = store_insert(s);
1240
1241 let mut out = json!({
1242 "session_id": session_id,
1243 "code_phrase": code,
1244 "relay_url": relay_url,
1245 });
1246 match sas_opt {
1247 Some(sas) => {
1248 out["state"] = json!("sas_ready");
1249 out["sas"] = json!(sas);
1250 out["next"] = json!(
1251 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel (voice/text). \
1252 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1253 );
1254 }
1255 None => {
1256 out["state"] = json!("waiting");
1257 out["next"] = json!(
1258 "Share the code_phrase with the user; ask them to read it to their peer (the peer pastes into wire_pair_join). \
1259 Poll wire_pair_check(session_id) until state='sas_ready'."
1260 );
1261 }
1262 }
1263 Ok(out)
1264}
1265
1266fn tool_pair_join(args: &Value) -> Result<Value, String> {
1267 use crate::pair_session::{
1268 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1269 };
1270
1271 store_sweep_expired();
1272 auto_init_if_needed(args)?;
1273
1274 let code = args
1275 .get("code_phrase")
1276 .and_then(Value::as_str)
1277 .ok_or("missing 'code_phrase'")?;
1278 let relay_url = resolve_relay_url(args)?;
1279 let max_wait = args
1280 .get("max_wait_secs")
1281 .and_then(Value::as_u64)
1282 .unwrap_or(30)
1283 .min(60);
1284
1285 let mut s = pair_session_open("guest", &relay_url, Some(code)).map_err(|e| e.to_string())?;
1286
1287 let sas_opt =
1288 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1289 .map_err(|e| e.to_string())?;
1290
1291 let session_id = store_insert(s);
1292
1293 let mut out = json!({
1294 "session_id": session_id,
1295 "relay_url": relay_url,
1296 });
1297 match sas_opt {
1298 Some(sas) => {
1299 out["state"] = json!("sas_ready");
1300 out["sas"] = json!(sas);
1301 out["next"] = json!(
1302 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel. \
1303 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1304 );
1305 }
1306 None => {
1307 out["state"] = json!("waiting");
1308 out["next"] = json!("Poll wire_pair_check(session_id).");
1309 }
1310 }
1311 Ok(out)
1312}
1313
1314fn tool_pair_check(args: &Value) -> Result<Value, String> {
1315 use crate::pair_session::{pair_session_wait_for_sas, store_get, store_sweep_expired};
1316
1317 store_sweep_expired();
1318 let session_id = args
1319 .get("session_id")
1320 .and_then(Value::as_str)
1321 .ok_or("missing 'session_id'")?;
1322 let max_wait = args
1323 .get("max_wait_secs")
1324 .and_then(Value::as_u64)
1325 .unwrap_or(8)
1326 .min(60);
1327
1328 let arc = store_get(session_id)
1329 .ok_or_else(|| format!("no such session_id (expired or never opened): {session_id}"))?;
1330 let mut s = arc.lock().map_err(|e| e.to_string())?;
1331
1332 if s.finalized {
1333 return Ok(json!({
1334 "state": "finalized",
1335 "session_id": session_id,
1336 "sas": s.formatted_sas(),
1337 }));
1338 }
1339 if let Some(reason) = s.aborted.clone() {
1340 return Ok(json!({
1341 "state": "aborted",
1342 "session_id": session_id,
1343 "reason": reason,
1344 }));
1345 }
1346
1347 let sas_opt =
1348 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1349 .map_err(|e| e.to_string())?;
1350
1351 Ok(match sas_opt {
1352 Some(sas) => json!({
1353 "state": "sas_ready",
1354 "session_id": session_id,
1355 "sas": sas,
1356 "next": "Have the user TYPE the 6 SAS digits BACK INTO CHAT, then pass to wire_pair_confirm."
1357 }),
1358 None => json!({
1359 "state": "waiting",
1360 "session_id": session_id,
1361 }),
1362 })
1363}
1364
1365fn tool_pair_confirm(args: &Value, state: &McpState) -> Result<Value, String> {
1366 use crate::pair_session::{
1367 pair_session_confirm_sas, pair_session_finalize, store_get, store_remove,
1368 };
1369
1370 let session_id = args
1371 .get("session_id")
1372 .and_then(Value::as_str)
1373 .ok_or("missing 'session_id'")?;
1374 let typed = args
1375 .get("user_typed_digits")
1376 .and_then(Value::as_str)
1377 .ok_or(
1378 "missing 'user_typed_digits' — the user must type the 6 SAS digits back into chat",
1379 )?;
1380
1381 let arc = store_get(session_id).ok_or_else(|| format!("no such session_id: {session_id}"))?;
1382
1383 let confirm_err = {
1384 let mut s = arc.lock().map_err(|e| e.to_string())?;
1385 match pair_session_confirm_sas(&mut s, typed) {
1386 Ok(()) => None,
1387 Err(e) => Some((s.aborted.is_some(), e.to_string())),
1388 }
1389 };
1390 if let Some((aborted, msg)) = confirm_err {
1391 if aborted {
1392 store_remove(session_id);
1393 }
1394 return Err(msg);
1395 }
1396
1397 let mut result = {
1398 let mut s = arc.lock().map_err(|e| e.to_string())?;
1399 pair_session_finalize(&mut s, 30).map_err(|e| e.to_string())?
1400 };
1401 store_remove(session_id);
1402
1403 let peer_handle = result["peer_handle"].as_str().unwrap_or("").to_string();
1412 let peer_uri = format!("wire://inbox/{peer_handle}");
1413
1414 let mut auto = json!({
1415 "subscribed": false,
1416 "daemon": "unknown",
1417 "notify": "unknown",
1418 "resources_list_changed_emitted": false,
1419 });
1420
1421 if !peer_handle.is_empty()
1422 && let Ok(mut g) = state.subscribed.lock()
1423 {
1424 g.insert(peer_uri.clone());
1425 auto["subscribed"] = json!(true);
1426 }
1427
1428 auto["daemon"] = match crate::ensure_up::ensure_daemon_running() {
1429 Ok(true) => json!("spawned"),
1430 Ok(false) => json!("already_running"),
1431 Err(e) => json!(format!("spawn_error: {e}")),
1432 };
1433 auto["notify"] = match crate::ensure_up::ensure_notify_running() {
1434 Ok(true) => json!("spawned"),
1435 Ok(false) => json!("already_running"),
1436 Err(e) => json!(format!("spawn_error: {e}")),
1437 };
1438
1439 if let Some(tx) = state.notif_tx.lock().ok().and_then(|g| g.clone()) {
1440 let notif = json!({
1441 "jsonrpc": "2.0",
1442 "method": "notifications/resources/list_changed",
1443 });
1444 if tx.send(notif.to_string()).is_ok() {
1445 auto["resources_list_changed_emitted"] = json!(true);
1446 }
1447 }
1448
1449 result["auto"] = auto;
1450 result["next"] = json!(
1451 "Done. Daemon + notify running, subscribed to peer inbox. Use wire_send/wire_tail \
1452 freely; new events arrive via notifications/resources/updated (where supported) and \
1453 OS toasts (always)."
1454 );
1455 Ok(result)
1456}
1457
1458fn tool_pair_initiate_detached(args: &Value) -> Result<Value, String> {
1461 auto_init_if_needed(args)?;
1462 let relay_url = resolve_relay_url(args)?;
1463 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1464 let _ = crate::ensure_up::ensure_daemon_running();
1465 }
1466 let code = crate::sas::generate_code_phrase();
1467 let code_hash = crate::pair_session::derive_code_hash(&code);
1468 let now = time::OffsetDateTime::now_utc()
1469 .format(&time::format_description::well_known::Rfc3339)
1470 .unwrap_or_default();
1471 let p = crate::pending_pair::PendingPair {
1472 code: code.clone(),
1473 code_hash,
1474 role: "host".to_string(),
1475 relay_url: relay_url.clone(),
1476 status: "request_host".to_string(),
1477 sas: None,
1478 peer_did: None,
1479 created_at: now,
1480 last_error: None,
1481 pair_id: None,
1482 our_slot_id: None,
1483 our_slot_token: None,
1484 spake2_seed_b64: None,
1485 };
1486 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1487 Ok(json!({
1488 "code_phrase": code,
1489 "relay_url": relay_url,
1490 "state": "queued",
1491 "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."
1492 }))
1493}
1494
1495fn tool_pair_join_detached(args: &Value) -> Result<Value, String> {
1496 auto_init_if_needed(args)?;
1497 let relay_url = resolve_relay_url(args)?;
1498 let code_phrase = args
1499 .get("code_phrase")
1500 .and_then(Value::as_str)
1501 .ok_or("missing 'code_phrase'")?;
1502 let code = crate::sas::parse_code_phrase(code_phrase)
1503 .map_err(|e| e.to_string())?
1504 .to_string();
1505 let code_hash = crate::pair_session::derive_code_hash(&code);
1506 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1507 let _ = crate::ensure_up::ensure_daemon_running();
1508 }
1509 let now = time::OffsetDateTime::now_utc()
1510 .format(&time::format_description::well_known::Rfc3339)
1511 .unwrap_or_default();
1512 let p = crate::pending_pair::PendingPair {
1513 code: code.clone(),
1514 code_hash,
1515 role: "guest".to_string(),
1516 relay_url: relay_url.clone(),
1517 status: "request_guest".to_string(),
1518 sas: None,
1519 peer_did: None,
1520 created_at: now,
1521 last_error: None,
1522 pair_id: None,
1523 our_slot_id: None,
1524 our_slot_token: None,
1525 spake2_seed_b64: None,
1526 };
1527 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1528 Ok(json!({
1529 "code_phrase": code,
1530 "relay_url": relay_url,
1531 "state": "queued",
1532 "next": "Subscribe to wire://pending-pair/all; on sas_ready notification, surface digits to user and call wire_pair_confirm_detached."
1533 }))
1534}
1535
1536fn tool_pair_list_pending() -> Result<Value, String> {
1537 let items = crate::pending_pair::list_pending().map_err(|e| e.to_string())?;
1538 Ok(json!({"pending": items}))
1539}
1540
1541fn tool_pair_confirm_detached(args: &Value) -> Result<Value, String> {
1542 let code_phrase = args
1543 .get("code_phrase")
1544 .and_then(Value::as_str)
1545 .ok_or("missing 'code_phrase'")?;
1546 let typed = args
1547 .get("user_typed_digits")
1548 .and_then(Value::as_str)
1549 .ok_or("missing 'user_typed_digits'")?;
1550 let code = crate::sas::parse_code_phrase(code_phrase)
1551 .map_err(|e| e.to_string())?
1552 .to_string();
1553 let typed: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
1554 if typed.len() != 6 {
1555 return Err(format!(
1556 "expected 6 digits (got {} after stripping non-digits)",
1557 typed.len()
1558 ));
1559 }
1560 let mut p = crate::pending_pair::read_pending(&code)
1561 .map_err(|e| e.to_string())?
1562 .ok_or_else(|| format!("no pending pair for code {code}"))?;
1563 if p.status != "sas_ready" {
1564 return Err(format!(
1565 "pair {code} not in sas_ready state (current: {})",
1566 p.status
1567 ));
1568 }
1569 let stored = p
1570 .sas
1571 .as_ref()
1572 .ok_or("pending file has status=sas_ready but no sas field")?
1573 .clone();
1574 if stored == typed {
1575 p.status = "confirmed".to_string();
1576 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1577 Ok(json!({
1578 "state": "confirmed",
1579 "code_phrase": code,
1580 "next": "Daemon will finalize on its next tick (~1s). Poll wire_peers or watch wire://pending-pair/all for the entry to disappear."
1581 }))
1582 } else {
1583 p.status = "aborted".to_string();
1584 p.last_error = Some(format!(
1585 "SAS digit mismatch (typed {typed}, expected {stored})"
1586 ));
1587 let client = crate::relay_client::RelayClient::new(&p.relay_url);
1588 let _ = client.pair_abandon(&p.code_hash);
1589 let _ = crate::pending_pair::write_pending(&p);
1590 crate::os_notify::toast(
1591 &format!("wire — pair aborted ({code})"),
1592 p.last_error.as_deref().unwrap_or("digits mismatch"),
1593 );
1594 Err(
1595 "digits mismatch — pair aborted. Re-issue with wire_pair_initiate_detached."
1596 .to_string(),
1597 )
1598 }
1599}
1600
1601fn tool_pair_cancel_pending(args: &Value) -> Result<Value, String> {
1602 let code_phrase = args
1603 .get("code_phrase")
1604 .and_then(Value::as_str)
1605 .ok_or("missing 'code_phrase'")?;
1606 let code = crate::sas::parse_code_phrase(code_phrase)
1607 .map_err(|e| e.to_string())?
1608 .to_string();
1609 if let Some(p) = crate::pending_pair::read_pending(&code).map_err(|e| e.to_string())? {
1610 let client = crate::relay_client::RelayClient::new(&p.relay_url);
1611 let _ = client.pair_abandon(&p.code_hash);
1612 }
1613 crate::pending_pair::delete_pending(&code).map_err(|e| e.to_string())?;
1614 Ok(json!({"state": "cancelled", "code_phrase": code}))
1615}
1616
1617fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1620 let relay_url = args.get("relay_url").and_then(Value::as_str);
1621 let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1622 let uses = args
1623 .get("uses")
1624 .and_then(Value::as_u64)
1625 .map(|u| u as u32)
1626 .unwrap_or(1);
1627 let url =
1628 crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1629 let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1630 Ok(json!({
1631 "invite_url": url,
1632 "ttl_secs": ttl_resolved,
1633 "uses": uses,
1634 }))
1635}
1636
1637fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1638 let url = args
1639 .get("url")
1640 .and_then(Value::as_str)
1641 .ok_or("missing 'url'")?;
1642 crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1643}
1644
1645fn tool_dial(args: &Value) -> Result<Value, String> {
1657 let name = args
1658 .get("name")
1659 .and_then(Value::as_str)
1660 .or_else(|| args.get("handle").and_then(Value::as_str))
1661 .ok_or("missing 'name'")?;
1662
1663 if name.contains('@') {
1664 let mut a = args.clone();
1666 if let Some(obj) = a.as_object_mut() {
1667 obj.insert("handle".into(), Value::String(name.to_string()));
1668 }
1669 return tool_add(&a);
1670 }
1671
1672 let relay_state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1673 let pinned = relay_state
1674 .get("peers")
1675 .and_then(Value::as_object)
1676 .map(|m| m.contains_key(name))
1677 .unwrap_or(false);
1678 if pinned {
1679 return Ok(json!({
1680 "name_input": name,
1681 "status": "already_pinned",
1682 "peer_handle": name,
1683 }));
1684 }
1685
1686 Err(format!(
1687 "cannot resolve `{name}` over MCP: bare-nickname / local-sister dialling is not yet \
1688 wired into the MCP surface. Use a federation handle `{name}@<relay>`, or `wire_send` \
1689 (it auto-pairs on miss)."
1690 ))
1691}
1692
1693fn tool_add(args: &Value) -> Result<Value, String> {
1694 let handle = args
1695 .get("handle")
1696 .and_then(Value::as_str)
1697 .ok_or("missing 'handle'")?;
1698 let relay_override = args.get("relay_url").and_then(Value::as_str);
1699
1700 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1701
1702 let (our_did, our_relay, our_slot_id, our_slot_token) =
1704 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1705
1706 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1708 .map_err(|e| format!("{e:#}"))?;
1709 let peer_card = resolved
1710 .get("card")
1711 .cloned()
1712 .ok_or("resolved missing card")?;
1713 let peer_did = resolved
1714 .get("did")
1715 .and_then(Value::as_str)
1716 .ok_or("resolved missing did")?
1717 .to_string();
1718 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1719 let peer_slot_id = resolved
1720 .get("slot_id")
1721 .and_then(Value::as_str)
1722 .ok_or("resolved missing slot_id")?
1723 .to_string();
1724 let peer_relay = resolved
1725 .get("relay_url")
1726 .and_then(Value::as_str)
1727 .map(str::to_string)
1728 .or_else(|| relay_override.map(str::to_string))
1729 .unwrap_or_else(|| format!("https://{}", parsed.domain));
1730
1731 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1733 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1734 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1735 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1736 let existing_token = relay_state
1737 .get("peers")
1738 .and_then(|p| p.get(&peer_handle))
1739 .and_then(|p| p.get("slot_token"))
1740 .and_then(Value::as_str)
1741 .map(str::to_string)
1742 .unwrap_or_default();
1743 relay_state["peers"][&peer_handle] = json!({
1744 "relay_url": peer_relay,
1745 "slot_id": peer_slot_id,
1746 "slot_token": existing_token,
1747 });
1748 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1749
1750 let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1752 let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1753 let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1754 let pk_b64 = our_card
1755 .get("verify_keys")
1756 .and_then(Value::as_object)
1757 .and_then(|m| m.values().next())
1758 .and_then(|v| v.get("key"))
1759 .and_then(Value::as_str)
1760 .ok_or("our card missing verify_keys[*].key")?;
1761 let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1762 let now = time::OffsetDateTime::now_utc()
1763 .format(&time::format_description::well_known::Rfc3339)
1764 .unwrap_or_default();
1765 let event = json!({
1766 "timestamp": now,
1767 "from": our_did,
1768 "to": peer_did,
1769 "type": "pair_drop",
1770 "kind": 1100u32,
1771 "body": {
1772 "card": our_card,
1773 "relay_url": our_relay,
1774 "slot_id": our_slot_id,
1775 "slot_token": our_slot_token,
1776 },
1777 });
1778 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
1779 .map_err(|e| format!("{e:#}"))?;
1780
1781 let client = crate::relay_client::RelayClient::new(&peer_relay);
1782 let resp = client
1783 .handle_intro(&parsed.nick, &signed)
1784 .map_err(|e| format!("{e:#}"))?;
1785 let event_id = signed
1786 .get("event_id")
1787 .and_then(Value::as_str)
1788 .unwrap_or("")
1789 .to_string();
1790 Ok(json!({
1791 "handle": handle,
1792 "paired_with": peer_did,
1793 "peer_handle": peer_handle,
1794 "event_id": event_id,
1795 "drop_response": resp,
1796 "status": "drop_sent",
1797 }))
1798}
1799
1800fn tool_pair_accept(args: &Value) -> Result<Value, String> {
1805 let peer = args
1806 .get("peer")
1807 .and_then(Value::as_str)
1808 .ok_or("missing 'peer'")?;
1809 let nick = crate::agent_card::bare_handle(peer);
1810 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
1811 .map_err(|e| format!("{e:#}"))?
1812 .ok_or_else(|| {
1813 format!(
1814 "no pending pair request from {nick}. Call wire_pair_list_inbound to enumerate, \
1815 or wire_add to send a fresh outbound pair request."
1816 )
1817 })?;
1818
1819 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1822 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
1823 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1824
1825 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1827 relay_state["peers"][&pending.peer_handle] = json!({
1828 "relay_url": pending.peer_relay_url,
1829 "slot_id": pending.peer_slot_id,
1830 "slot_token": pending.peer_slot_token,
1831 });
1832 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1833
1834 crate::pair_invite::send_pair_drop_ack(
1836 &pending.peer_handle,
1837 &pending.peer_relay_url,
1838 &pending.peer_slot_id,
1839 &pending.peer_slot_token,
1840 )
1841 .map_err(|e| {
1842 format!(
1843 "pair_drop_ack send to {} @ {} slot {} failed: {e:#}",
1844 pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
1845 )
1846 })?;
1847
1848 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1849
1850 Ok(json!({
1851 "status": "bilateral_accepted",
1852 "peer_handle": pending.peer_handle,
1853 "peer_did": pending.peer_did,
1854 "peer_relay_url": pending.peer_relay_url,
1855 "via": "pending_inbound",
1856 }))
1857}
1858
1859fn tool_pair_reject(args: &Value) -> Result<Value, String> {
1862 let peer = args
1863 .get("peer")
1864 .and_then(Value::as_str)
1865 .ok_or("missing 'peer'")?;
1866 let nick = crate::agent_card::bare_handle(peer);
1867 let existed =
1868 crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1869 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1870 Ok(json!({
1871 "peer": nick,
1872 "rejected": existed.is_some(),
1873 "had_pending": existed.is_some(),
1874 }))
1875}
1876
1877fn tool_pair_list_inbound() -> Result<Value, String> {
1880 let items =
1881 crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
1882 Ok(json!(items))
1883}
1884
1885fn tool_claim_handle(args: &Value) -> Result<Value, String> {
1886 let nick = args
1887 .get("nick")
1888 .and_then(Value::as_str)
1889 .ok_or("missing 'nick'")?;
1890 let relay_override = args.get("relay_url").and_then(Value::as_str);
1891 let public_url = args.get("public_url").and_then(Value::as_str);
1892
1893 let (_, our_relay, our_slot_id, our_slot_token) =
1895 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1896 let claim_relay = relay_override.unwrap_or(&our_relay);
1897 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1898 let client = crate::relay_client::RelayClient::new(claim_relay);
1899 let resp = client
1900 .handle_claim(nick, &our_slot_id, &our_slot_token, public_url, &card)
1901 .map_err(|e| format!("{e:#}"))?;
1902 Ok(json!({
1903 "nick": nick,
1904 "relay": claim_relay,
1905 "response": resp,
1906 }))
1907}
1908
1909fn tool_whois(args: &Value) -> Result<Value, String> {
1910 if let Some(handle) = args.get("handle").and_then(Value::as_str) {
1911 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1912 let relay_override = args.get("relay_url").and_then(Value::as_str);
1913 crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
1914 } else {
1915 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1917 Ok(json!({
1918 "did": card.get("did").cloned().unwrap_or(Value::Null),
1919 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1920 }))
1921 }
1922}
1923
1924fn tool_profile_set(args: &Value) -> Result<Value, String> {
1925 let field = args
1926 .get("field")
1927 .and_then(Value::as_str)
1928 .ok_or("missing 'field'")?;
1929 let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
1930 let value = if let Some(s) = raw_value.as_str() {
1934 serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
1935 } else {
1936 raw_value
1937 };
1938 let new_profile =
1939 crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
1940 Ok(json!({
1941 "field": field,
1942 "profile": new_profile,
1943 }))
1944}
1945
1946fn tool_profile_get() -> Result<Value, String> {
1947 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1948 Ok(json!({
1949 "did": card.get("did").cloned().unwrap_or(Value::Null),
1950 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1951 }))
1952}
1953
1954fn parse_kind(s: &str) -> u32 {
1957 if let Ok(n) = s.parse::<u32>() {
1958 return n;
1959 }
1960 for (id, name) in crate::signing::kinds() {
1961 if *name == s {
1962 return *id;
1963 }
1964 }
1965 1
1966}
1967
1968fn error_response(id: &Value, code: i32, message: &str) -> Value {
1969 json!({
1970 "jsonrpc": "2.0",
1971 "id": id,
1972 "error": {"code": code, "message": message}
1973 })
1974}
1975
1976#[cfg(test)]
1977mod tests {
1978 use super::*;
1979
1980 #[test]
1981 fn unknown_method_returns_jsonrpc_error() {
1982 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
1983 let resp = handle_request(&req, &McpState::default());
1984 assert_eq!(resp["error"]["code"], -32601);
1985 }
1986
1987 #[test]
1988 fn initialize_advertises_tools_capability() {
1989 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
1990 let resp = handle_request(&req, &McpState::default());
1991 assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
1992 assert!(resp["result"]["capabilities"]["tools"].is_object());
1993 assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
1994 }
1995
1996 #[test]
1997 fn tools_list_includes_pairing_and_messaging() {
1998 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
1999 let resp = handle_request(&req, &McpState::default());
2000 let names: Vec<&str> = resp["result"]["tools"]
2001 .as_array()
2002 .unwrap()
2003 .iter()
2004 .filter_map(|t| t["name"].as_str())
2005 .collect();
2006 for required in [
2007 "wire_whoami",
2008 "wire_peers",
2009 "wire_send",
2010 "wire_tail",
2011 "wire_verify",
2012 "wire_init",
2013 "wire_pair_initiate",
2014 "wire_pair_join",
2015 "wire_pair_check",
2016 "wire_pair_confirm",
2017 ] {
2018 assert!(
2019 names.contains(&required),
2020 "missing required tool {required}"
2021 );
2022 }
2023 assert!(
2027 !names.contains(&"wire_join"),
2028 "wire_join must not be advertised — superseded by wire_pair_join"
2029 );
2030 }
2031
2032 #[test]
2033 fn legacy_wire_join_call_returns_helpful_error() {
2034 let req = json!({
2035 "jsonrpc": "2.0",
2036 "id": 1,
2037 "method": "tools/call",
2038 "params": {"name": "wire_join", "arguments": {}}
2039 });
2040 let resp = handle_request(&req, &McpState::default());
2041 assert_eq!(resp["result"]["isError"], true);
2042 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2043 assert!(
2044 text.contains("wire_pair_join"),
2045 "expected redirect to wire_pair_join, got: {text}"
2046 );
2047 }
2048
2049 #[test]
2050 fn pair_confirm_missing_session_id_errors_cleanly() {
2051 let req = json!({
2052 "jsonrpc": "2.0",
2053 "id": 1,
2054 "method": "tools/call",
2055 "params": {"name": "wire_pair_confirm", "arguments": {"user_typed_digits": "111111"}}
2056 });
2057 let resp = handle_request(&req, &McpState::default());
2058 assert_eq!(resp["result"]["isError"], true);
2059 }
2060
2061 #[test]
2062 fn pair_confirm_unknown_session_errors_cleanly() {
2063 let req = json!({
2064 "jsonrpc": "2.0",
2065 "id": 1,
2066 "method": "tools/call",
2067 "params": {
2068 "name": "wire_pair_confirm",
2069 "arguments": {"session_id": "definitely-not-real", "user_typed_digits": "111111"}
2070 }
2071 });
2072 let resp = handle_request(&req, &McpState::default());
2073 assert_eq!(resp["result"]["isError"], true);
2074 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2075 assert!(text.contains("no such session_id"), "got: {text}");
2076 }
2077
2078 #[test]
2079 fn initialize_advertises_resources_capability() {
2080 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
2081 let resp = handle_request(&req, &McpState::default());
2082 let caps = &resp["result"]["capabilities"];
2083 assert!(
2084 caps["resources"].is_object(),
2085 "resources capability must be present, got {resp}"
2086 );
2087 assert_eq!(
2088 caps["resources"]["subscribe"], true,
2089 "subscribe shipped in v0.2.1"
2090 );
2091 }
2092
2093 #[test]
2094 fn resources_read_with_bad_uri_errors() {
2095 let req = json!({
2096 "jsonrpc": "2.0",
2097 "id": 1,
2098 "method": "resources/read",
2099 "params": {"uri": "http://example.com/not-a-wire-uri"}
2100 });
2101 let resp = handle_request(&req, &McpState::default());
2102 assert!(resp.get("error").is_some(), "expected error, got {resp}");
2103 }
2104
2105 #[test]
2106 fn parse_inbox_uri_handles_variants() {
2107 assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
2108 assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
2109 assert!(
2110 parse_inbox_uri("wire://inbox/")
2111 .unwrap()
2112 .starts_with("__invalid__"),
2113 "empty peer must be invalid"
2114 );
2115 assert!(
2116 parse_inbox_uri("http://other")
2117 .unwrap()
2118 .starts_with("__invalid__"),
2119 "non-wire scheme must be invalid"
2120 );
2121 }
2122
2123 #[test]
2124 fn ping_returns_empty_result() {
2125 let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2126 let resp = handle_request(&req, &McpState::default());
2127 assert_eq!(resp["id"], 7);
2128 assert!(resp["result"].is_object());
2129 }
2130
2131 #[test]
2132 fn notification_returns_null_no_reply() {
2133 let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2134 let resp = handle_request(&req, &McpState::default());
2135 assert_eq!(resp, Value::Null);
2136 }
2137
2138 #[test]
2145 fn detect_session_wire_home_resolves_registered_cwd() {
2146 crate::config::test_support::with_temp_home(|| {
2147 let wire_home = std::env::var("WIRE_HOME").unwrap();
2151 let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2152 let session_home = sessions_root.join("test-alpha");
2153 std::fs::create_dir_all(&session_home).unwrap();
2154 let fake_cwd = "/tmp/fake-project-cwd-abc123";
2155 let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2156 std::fs::write(
2157 sessions_root.join("registry.json"),
2158 serde_json::to_vec_pretty(®istry).unwrap(),
2159 )
2160 .unwrap();
2161
2162 let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2164 assert_eq!(
2165 got.as_deref(),
2166 Some(session_home.as_path()),
2167 "registered cwd must resolve to session_home"
2168 );
2169
2170 let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2172 "/tmp/cwd-not-in-registry-xyz789",
2173 ));
2174 assert!(nope.is_none(), "unregistered cwd must return None");
2175
2176 let stale_cwd = "/tmp/stale-session-cwd";
2179 let stale_registry =
2180 json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2181 std::fs::write(
2182 sessions_root.join("registry.json"),
2183 serde_json::to_vec_pretty(&stale_registry).unwrap(),
2184 )
2185 .unwrap();
2186 let stale_got =
2187 crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2188 assert!(
2189 stale_got.is_none(),
2190 "registered cwd whose session dir is missing must return None"
2191 );
2192 });
2193 }
2194}