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 ]
872}
873
874fn handle_tools_call(id: &Value, params: &Value, state: &McpState) -> Value {
875 let name = match params.get("name").and_then(Value::as_str) {
876 Some(n) => n,
877 None => return error_response(id, -32602, "missing tool name"),
878 };
879 let args = params
880 .get("arguments")
881 .cloned()
882 .unwrap_or_else(|| json!({}));
883
884 let result = match name {
885 "wire_whoami" => tool_whoami(),
886 "wire_peers" => tool_peers(),
887 "wire_send" => tool_send(&args),
888 "wire_tail" => tool_tail(&args),
889 "wire_verify" => tool_verify(&args),
890 "wire_init" => tool_init(&args),
891 "wire_pair_initiate" => tool_pair_initiate(&args),
892 "wire_pair_join" => tool_pair_join(&args),
893 "wire_pair_check" => tool_pair_check(&args),
894 "wire_pair_confirm" => tool_pair_confirm(&args, state),
895 "wire_pair_initiate_detached" => tool_pair_initiate_detached(&args),
896 "wire_pair_join_detached" => tool_pair_join_detached(&args),
897 "wire_pair_list_pending" => tool_pair_list_pending(),
898 "wire_pair_confirm_detached" => tool_pair_confirm_detached(&args),
899 "wire_pair_cancel_pending" => tool_pair_cancel_pending(&args),
900 "wire_invite_mint" => tool_invite_mint(&args),
901 "wire_invite_accept" => tool_invite_accept(&args),
902 "wire_add" => tool_add(&args),
904 "wire_pair_accept" | "wire_accept" => tool_pair_accept(&args),
910 "wire_pair_reject" | "wire_reject" => tool_pair_reject(&args),
911 "wire_pair_list_inbound" | "wire_pending" => tool_pair_list_inbound(),
912 "wire_dial" => tool_dial(&args),
913 "wire_claim" => tool_claim_handle(&args),
914 "wire_whois" => tool_whois(&args),
915 "wire_profile_set" => tool_profile_set(&args),
916 "wire_profile_get" => tool_profile_get(),
917 "wire_join" => Err(
920 "wire_join was renamed to wire_pair_join (use code_phrase argument). \
921 See docs/AGENT_INTEGRATION.md."
922 .into(),
923 ),
924 other => Err(format!("unknown tool: {other}")),
925 };
926
927 match result {
928 Ok(value) => json!({
929 "jsonrpc": "2.0",
930 "id": id,
931 "result": {
932 "content": [{
933 "type": "text",
934 "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
935 }],
936 "isError": false
937 }
938 }),
939 Err(message) => json!({
940 "jsonrpc": "2.0",
941 "id": id,
942 "result": {
943 "content": [{"type": "text", "text": message}],
944 "isError": true
945 }
946 }),
947 }
948}
949
950fn tool_whoami() -> Result<Value, String> {
953 use crate::config;
954 use crate::signing::{b64decode, fingerprint, make_key_id};
955
956 if !config::is_initialized().map_err(|e| e.to_string())? {
957 return Err("not initialized — operator must run `wire init <handle>` first".into());
958 }
959 let card = config::read_agent_card().map_err(|e| e.to_string())?;
960 let did = card
961 .get("did")
962 .and_then(Value::as_str)
963 .unwrap_or("")
964 .to_string();
965 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
966 let pk_b64 = card
967 .get("verify_keys")
968 .and_then(Value::as_object)
969 .and_then(|m| m.values().next())
970 .and_then(|v| v.get("key"))
971 .and_then(Value::as_str)
972 .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
973 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
974 let fp = fingerprint(&pk_bytes);
975 let key_id = make_key_id(&handle, &pk_bytes);
976 let capabilities = card
977 .get("capabilities")
978 .cloned()
979 .unwrap_or_else(|| json!(["wire/v3.1"]));
980 let persona =
984 serde_json::to_value(crate::character::Character::from_card(&card)).unwrap_or(Value::Null);
985 Ok(json!({
986 "did": did,
987 "handle": handle,
988 "persona": persona,
989 "fingerprint": fp,
990 "key_id": key_id,
991 "public_key_b64": pk_b64,
992 "capabilities": capabilities,
993 }))
994}
995
996fn tool_peers() -> Result<Value, String> {
997 use crate::config;
998 use crate::trust::get_tier;
999
1000 let trust = config::read_trust().map_err(|e| e.to_string())?;
1001 let agents = trust
1002 .get("agents")
1003 .and_then(Value::as_object)
1004 .cloned()
1005 .unwrap_or_default();
1006 let mut self_did: Option<String> = None;
1007 if let Ok(card) = config::read_agent_card() {
1008 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
1009 }
1010 let mut peers = Vec::new();
1011 for (handle, agent) in agents.iter() {
1012 let did = agent
1013 .get("did")
1014 .and_then(Value::as_str)
1015 .unwrap_or("")
1016 .to_string();
1017 if Some(did.as_str()) == self_did.as_deref() {
1018 continue;
1019 }
1020 let persona = match agent.get("card") {
1024 Some(c) => crate::character::Character::from_card(c),
1025 None => crate::character::Character::from_did(&did),
1026 };
1027 peers.push(json!({
1028 "handle": handle,
1029 "persona": serde_json::to_value(&persona).unwrap_or(Value::Null),
1030 "did": did,
1031 "tier": get_tier(&trust, handle),
1032 "capabilities": agent.get("card").and_then(|c| c.get("capabilities")).cloned().unwrap_or_else(|| json!([])),
1033 }));
1034 }
1035 Ok(json!(peers))
1036}
1037
1038fn tool_send(args: &Value) -> Result<Value, String> {
1039 use crate::config;
1040 use crate::signing::{b64decode, sign_message_v31};
1041
1042 let peer = args
1043 .get("peer")
1044 .and_then(Value::as_str)
1045 .ok_or("missing 'peer'")?;
1046 let peer = crate::agent_card::bare_handle(peer);
1047 let kind = args
1048 .get("kind")
1049 .and_then(Value::as_str)
1050 .ok_or("missing 'kind'")?;
1051 let body = args
1052 .get("body")
1053 .and_then(Value::as_str)
1054 .ok_or("missing 'body'")?;
1055 let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
1056
1057 if !config::is_initialized().map_err(|e| e.to_string())? {
1058 return Err("not initialized — operator must run `wire init <handle>` first".into());
1059 }
1060 let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
1061 let card = config::read_agent_card().map_err(|e| e.to_string())?;
1062 let did = card
1063 .get("did")
1064 .and_then(Value::as_str)
1065 .unwrap_or("")
1066 .to_string();
1067 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1068 let pk_b64 = card
1069 .get("verify_keys")
1070 .and_then(Value::as_object)
1071 .and_then(|m| m.values().next())
1072 .and_then(|v| v.get("key"))
1073 .and_then(Value::as_str)
1074 .ok_or("agent-card missing verify_keys[*].key")?;
1075 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1076
1077 let body_value: Value =
1079 serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1080 let kind_id = parse_kind(kind);
1081
1082 let now = time::OffsetDateTime::now_utc()
1083 .format(&time::format_description::well_known::Rfc3339)
1084 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1085
1086 let mut event = json!({
1087 "timestamp": now,
1088 "from": did,
1089 "to": format!("did:wire:{peer}"),
1090 "type": kind,
1091 "kind": kind_id,
1092 "body": body_value,
1093 });
1094 if let Some(deadline) = deadline {
1095 event["time_sensitive_until"] =
1096 json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1097 }
1098 let signed =
1099 sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1100 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1101
1102 let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1103 let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1104
1105 Ok(json!({
1106 "event_id": event_id,
1107 "status": "queued",
1108 "peer": peer,
1109 "outbox": outbox.to_string_lossy(),
1110 }))
1111}
1112
1113fn tool_tail(args: &Value) -> Result<Value, String> {
1114 use crate::config;
1115 use crate::signing::verify_message_v31;
1116
1117 let peer_filter = args.get("peer").and_then(Value::as_str);
1118 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1119 let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1120 if !inbox.exists() {
1121 return Ok(json!([]));
1122 }
1123 let trust = config::read_trust().map_err(|e| e.to_string())?;
1124 let mut events = Vec::new();
1125 let entries: Vec<_> = std::fs::read_dir(&inbox)
1126 .map_err(|e| e.to_string())?
1127 .filter_map(|e| e.ok())
1128 .map(|e| e.path())
1129 .filter(|p| {
1130 p.extension().map(|x| x == "jsonl").unwrap_or(false)
1131 && match peer_filter {
1132 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1133 None => true,
1134 }
1135 })
1136 .collect();
1137 for path in entries {
1138 let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
1139 for line in body.lines() {
1140 let event: Value = match serde_json::from_str(line) {
1141 Ok(v) => v,
1142 Err(_) => continue,
1143 };
1144 let verified = verify_message_v31(&event, &trust).is_ok();
1145 let mut event_with_meta = event.clone();
1146 if let Some(obj) = event_with_meta.as_object_mut() {
1147 obj.insert("verified".into(), json!(verified));
1148 }
1149 events.push(event_with_meta);
1150 if events.len() >= limit {
1151 return Ok(Value::Array(events));
1152 }
1153 }
1154 }
1155 Ok(Value::Array(events))
1156}
1157
1158fn tool_verify(args: &Value) -> Result<Value, String> {
1159 use crate::config;
1160 use crate::signing::verify_message_v31;
1161
1162 let event_str = args
1163 .get("event")
1164 .and_then(Value::as_str)
1165 .ok_or("missing 'event'")?;
1166 let event: Value =
1167 serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1168 let trust = config::read_trust().map_err(|e| e.to_string())?;
1169 match verify_message_v31(&event, &trust) {
1170 Ok(()) => Ok(json!({"verified": true})),
1171 Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1172 }
1173}
1174
1175fn ensure_session_bootstrapped() {
1184 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
1185 return;
1186 }
1187 if crate::config::is_initialized().unwrap_or(false) {
1188 return; }
1190 let (did, relay_url, slot_id, slot_token) =
1191 match crate::pair_invite::ensure_self_with_relay(None) {
1192 Ok(t) => t,
1193 Err(_) => return, };
1195 if let Ok(card) = crate::config::read_agent_card() {
1196 let persona = crate::agent_card::display_handle_from_did(&did).to_string();
1197 let client = crate::relay_client::RelayClient::new(&relay_url);
1198 let _ = client.handle_claim_v2(&persona, &slot_id, &slot_token, None, &card, None);
1199 }
1200}
1201
1202fn tool_init(args: &Value) -> Result<Value, String> {
1203 let handle = args
1204 .get("handle")
1205 .and_then(Value::as_str)
1206 .ok_or("missing 'handle'")?;
1207 let name = args.get("name").and_then(Value::as_str);
1208 let relay = args.get("relay_url").and_then(Value::as_str);
1209 crate::pair_session::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1210}
1211
1212fn resolve_relay_url(args: &Value) -> Result<String, String> {
1216 if let Some(url) = args.get("relay_url").and_then(Value::as_str) {
1217 return Ok(url.to_string());
1218 }
1219 let state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1220 state["self"]["relay_url"]
1221 .as_str()
1222 .map(str::to_string)
1223 .ok_or_else(|| "no relay_url provided and no relay bound (call wire_init with relay_url, or pass relay_url here)".into())
1224}
1225
1226fn auto_init_if_needed(args: &Value) -> Result<(), String> {
1232 let initialized = crate::config::is_initialized().map_err(|e| e.to_string())?;
1233 if initialized {
1234 return Ok(());
1235 }
1236 let handle = args.get("handle").and_then(Value::as_str).ok_or(
1237 "not initialized — pass `handle` to auto-init, or call wire_init explicitly first",
1238 )?;
1239 let relay = args.get("relay_url").and_then(Value::as_str);
1240 crate::pair_session::init_self_idempotent(handle, None, relay)
1241 .map(|_| ())
1242 .map_err(|e| e.to_string())
1243}
1244
1245fn tool_pair_initiate(args: &Value) -> Result<Value, String> {
1246 use crate::pair_session::{
1247 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1248 };
1249
1250 store_sweep_expired();
1251 auto_init_if_needed(args)?;
1253
1254 let relay_url = resolve_relay_url(args)?;
1255 let max_wait = args
1256 .get("max_wait_secs")
1257 .and_then(Value::as_u64)
1258 .unwrap_or(30)
1259 .min(60);
1260
1261 let mut s = pair_session_open("host", &relay_url, None).map_err(|e| e.to_string())?;
1262 let code = s.code.clone();
1263
1264 let sas_opt = if max_wait > 0 {
1265 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1266 .map_err(|e| e.to_string())?
1267 } else {
1268 None
1269 };
1270
1271 let session_id = store_insert(s);
1272
1273 let mut out = json!({
1274 "session_id": session_id,
1275 "code_phrase": code,
1276 "relay_url": relay_url,
1277 });
1278 match sas_opt {
1279 Some(sas) => {
1280 out["state"] = json!("sas_ready");
1281 out["sas"] = json!(sas);
1282 out["next"] = json!(
1283 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel (voice/text). \
1284 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1285 );
1286 }
1287 None => {
1288 out["state"] = json!("waiting");
1289 out["next"] = json!(
1290 "Share the code_phrase with the user; ask them to read it to their peer (the peer pastes into wire_pair_join). \
1291 Poll wire_pair_check(session_id) until state='sas_ready'."
1292 );
1293 }
1294 }
1295 Ok(out)
1296}
1297
1298fn tool_pair_join(args: &Value) -> Result<Value, String> {
1299 use crate::pair_session::{
1300 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1301 };
1302
1303 store_sweep_expired();
1304 auto_init_if_needed(args)?;
1305
1306 let code = args
1307 .get("code_phrase")
1308 .and_then(Value::as_str)
1309 .ok_or("missing 'code_phrase'")?;
1310 let relay_url = resolve_relay_url(args)?;
1311 let max_wait = args
1312 .get("max_wait_secs")
1313 .and_then(Value::as_u64)
1314 .unwrap_or(30)
1315 .min(60);
1316
1317 let mut s = pair_session_open("guest", &relay_url, Some(code)).map_err(|e| e.to_string())?;
1318
1319 let sas_opt =
1320 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1321 .map_err(|e| e.to_string())?;
1322
1323 let session_id = store_insert(s);
1324
1325 let mut out = json!({
1326 "session_id": session_id,
1327 "relay_url": relay_url,
1328 });
1329 match sas_opt {
1330 Some(sas) => {
1331 out["state"] = json!("sas_ready");
1332 out["sas"] = json!(sas);
1333 out["next"] = json!(
1334 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel. \
1335 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1336 );
1337 }
1338 None => {
1339 out["state"] = json!("waiting");
1340 out["next"] = json!("Poll wire_pair_check(session_id).");
1341 }
1342 }
1343 Ok(out)
1344}
1345
1346fn tool_pair_check(args: &Value) -> Result<Value, String> {
1347 use crate::pair_session::{pair_session_wait_for_sas, store_get, store_sweep_expired};
1348
1349 store_sweep_expired();
1350 let session_id = args
1351 .get("session_id")
1352 .and_then(Value::as_str)
1353 .ok_or("missing 'session_id'")?;
1354 let max_wait = args
1355 .get("max_wait_secs")
1356 .and_then(Value::as_u64)
1357 .unwrap_or(8)
1358 .min(60);
1359
1360 let arc = store_get(session_id)
1361 .ok_or_else(|| format!("no such session_id (expired or never opened): {session_id}"))?;
1362 let mut s = arc.lock().map_err(|e| e.to_string())?;
1363
1364 if s.finalized {
1365 return Ok(json!({
1366 "state": "finalized",
1367 "session_id": session_id,
1368 "sas": s.formatted_sas(),
1369 }));
1370 }
1371 if let Some(reason) = s.aborted.clone() {
1372 return Ok(json!({
1373 "state": "aborted",
1374 "session_id": session_id,
1375 "reason": reason,
1376 }));
1377 }
1378
1379 let sas_opt =
1380 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1381 .map_err(|e| e.to_string())?;
1382
1383 Ok(match sas_opt {
1384 Some(sas) => json!({
1385 "state": "sas_ready",
1386 "session_id": session_id,
1387 "sas": sas,
1388 "next": "Have the user TYPE the 6 SAS digits BACK INTO CHAT, then pass to wire_pair_confirm."
1389 }),
1390 None => json!({
1391 "state": "waiting",
1392 "session_id": session_id,
1393 }),
1394 })
1395}
1396
1397fn tool_pair_confirm(args: &Value, state: &McpState) -> Result<Value, String> {
1398 use crate::pair_session::{
1399 pair_session_confirm_sas, pair_session_finalize, store_get, store_remove,
1400 };
1401
1402 let session_id = args
1403 .get("session_id")
1404 .and_then(Value::as_str)
1405 .ok_or("missing 'session_id'")?;
1406 let typed = args
1407 .get("user_typed_digits")
1408 .and_then(Value::as_str)
1409 .ok_or(
1410 "missing 'user_typed_digits' — the user must type the 6 SAS digits back into chat",
1411 )?;
1412
1413 let arc = store_get(session_id).ok_or_else(|| format!("no such session_id: {session_id}"))?;
1414
1415 let confirm_err = {
1416 let mut s = arc.lock().map_err(|e| e.to_string())?;
1417 match pair_session_confirm_sas(&mut s, typed) {
1418 Ok(()) => None,
1419 Err(e) => Some((s.aborted.is_some(), e.to_string())),
1420 }
1421 };
1422 if let Some((aborted, msg)) = confirm_err {
1423 if aborted {
1424 store_remove(session_id);
1425 }
1426 return Err(msg);
1427 }
1428
1429 let mut result = {
1430 let mut s = arc.lock().map_err(|e| e.to_string())?;
1431 pair_session_finalize(&mut s, 30).map_err(|e| e.to_string())?
1432 };
1433 store_remove(session_id);
1434
1435 let peer_handle = result["peer_handle"].as_str().unwrap_or("").to_string();
1444 let peer_uri = format!("wire://inbox/{peer_handle}");
1445
1446 let mut auto = json!({
1447 "subscribed": false,
1448 "daemon": "unknown",
1449 "notify": "unknown",
1450 "resources_list_changed_emitted": false,
1451 });
1452
1453 if !peer_handle.is_empty()
1454 && let Ok(mut g) = state.subscribed.lock()
1455 {
1456 g.insert(peer_uri.clone());
1457 auto["subscribed"] = json!(true);
1458 }
1459
1460 auto["daemon"] = match crate::ensure_up::ensure_daemon_running() {
1461 Ok(true) => json!("spawned"),
1462 Ok(false) => json!("already_running"),
1463 Err(e) => json!(format!("spawn_error: {e}")),
1464 };
1465 auto["notify"] = match crate::ensure_up::ensure_notify_running() {
1466 Ok(true) => json!("spawned"),
1467 Ok(false) => json!("already_running"),
1468 Err(e) => json!(format!("spawn_error: {e}")),
1469 };
1470
1471 if let Some(tx) = state.notif_tx.lock().ok().and_then(|g| g.clone()) {
1472 let notif = json!({
1473 "jsonrpc": "2.0",
1474 "method": "notifications/resources/list_changed",
1475 });
1476 if tx.send(notif.to_string()).is_ok() {
1477 auto["resources_list_changed_emitted"] = json!(true);
1478 }
1479 }
1480
1481 result["auto"] = auto;
1482 result["next"] = json!(
1483 "Done. Daemon + notify running, subscribed to peer inbox. Use wire_send/wire_tail \
1484 freely; new events arrive via notifications/resources/updated (where supported) and \
1485 OS toasts (always)."
1486 );
1487 Ok(result)
1488}
1489
1490fn tool_pair_initiate_detached(args: &Value) -> Result<Value, String> {
1493 auto_init_if_needed(args)?;
1494 let relay_url = resolve_relay_url(args)?;
1495 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1496 let _ = crate::ensure_up::ensure_daemon_running();
1497 }
1498 let code = crate::sas::generate_code_phrase();
1499 let code_hash = crate::pair_session::derive_code_hash(&code);
1500 let now = time::OffsetDateTime::now_utc()
1501 .format(&time::format_description::well_known::Rfc3339)
1502 .unwrap_or_default();
1503 let p = crate::pending_pair::PendingPair {
1504 code: code.clone(),
1505 code_hash,
1506 role: "host".to_string(),
1507 relay_url: relay_url.clone(),
1508 status: "request_host".to_string(),
1509 sas: None,
1510 peer_did: None,
1511 created_at: now,
1512 last_error: None,
1513 pair_id: None,
1514 our_slot_id: None,
1515 our_slot_token: None,
1516 spake2_seed_b64: None,
1517 };
1518 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1519 Ok(json!({
1520 "code_phrase": code,
1521 "relay_url": relay_url,
1522 "state": "queued",
1523 "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."
1524 }))
1525}
1526
1527fn tool_pair_join_detached(args: &Value) -> Result<Value, String> {
1528 auto_init_if_needed(args)?;
1529 let relay_url = resolve_relay_url(args)?;
1530 let code_phrase = args
1531 .get("code_phrase")
1532 .and_then(Value::as_str)
1533 .ok_or("missing 'code_phrase'")?;
1534 let code = crate::sas::parse_code_phrase(code_phrase)
1535 .map_err(|e| e.to_string())?
1536 .to_string();
1537 let code_hash = crate::pair_session::derive_code_hash(&code);
1538 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1539 let _ = crate::ensure_up::ensure_daemon_running();
1540 }
1541 let now = time::OffsetDateTime::now_utc()
1542 .format(&time::format_description::well_known::Rfc3339)
1543 .unwrap_or_default();
1544 let p = crate::pending_pair::PendingPair {
1545 code: code.clone(),
1546 code_hash,
1547 role: "guest".to_string(),
1548 relay_url: relay_url.clone(),
1549 status: "request_guest".to_string(),
1550 sas: None,
1551 peer_did: None,
1552 created_at: now,
1553 last_error: None,
1554 pair_id: None,
1555 our_slot_id: None,
1556 our_slot_token: None,
1557 spake2_seed_b64: None,
1558 };
1559 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1560 Ok(json!({
1561 "code_phrase": code,
1562 "relay_url": relay_url,
1563 "state": "queued",
1564 "next": "Subscribe to wire://pending-pair/all; on sas_ready notification, surface digits to user and call wire_pair_confirm_detached."
1565 }))
1566}
1567
1568fn tool_pair_list_pending() -> Result<Value, String> {
1569 let items = crate::pending_pair::list_pending().map_err(|e| e.to_string())?;
1570 Ok(json!({"pending": items}))
1571}
1572
1573fn tool_pair_confirm_detached(args: &Value) -> Result<Value, String> {
1574 let code_phrase = args
1575 .get("code_phrase")
1576 .and_then(Value::as_str)
1577 .ok_or("missing 'code_phrase'")?;
1578 let typed = args
1579 .get("user_typed_digits")
1580 .and_then(Value::as_str)
1581 .ok_or("missing 'user_typed_digits'")?;
1582 let code = crate::sas::parse_code_phrase(code_phrase)
1583 .map_err(|e| e.to_string())?
1584 .to_string();
1585 let typed: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
1586 if typed.len() != 6 {
1587 return Err(format!(
1588 "expected 6 digits (got {} after stripping non-digits)",
1589 typed.len()
1590 ));
1591 }
1592 let mut p = crate::pending_pair::read_pending(&code)
1593 .map_err(|e| e.to_string())?
1594 .ok_or_else(|| format!("no pending pair for code {code}"))?;
1595 if p.status != "sas_ready" {
1596 return Err(format!(
1597 "pair {code} not in sas_ready state (current: {})",
1598 p.status
1599 ));
1600 }
1601 let stored = p
1602 .sas
1603 .as_ref()
1604 .ok_or("pending file has status=sas_ready but no sas field")?
1605 .clone();
1606 if stored == typed {
1607 p.status = "confirmed".to_string();
1608 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1609 Ok(json!({
1610 "state": "confirmed",
1611 "code_phrase": code,
1612 "next": "Daemon will finalize on its next tick (~1s). Poll wire_peers or watch wire://pending-pair/all for the entry to disappear."
1613 }))
1614 } else {
1615 p.status = "aborted".to_string();
1616 p.last_error = Some(format!(
1617 "SAS digit mismatch (typed {typed}, expected {stored})"
1618 ));
1619 let client = crate::relay_client::RelayClient::new(&p.relay_url);
1620 let _ = client.pair_abandon(&p.code_hash);
1621 let _ = crate::pending_pair::write_pending(&p);
1622 crate::os_notify::toast(
1623 &format!("wire — pair aborted ({code})"),
1624 p.last_error.as_deref().unwrap_or("digits mismatch"),
1625 );
1626 Err(
1627 "digits mismatch — pair aborted. Re-issue with wire_pair_initiate_detached."
1628 .to_string(),
1629 )
1630 }
1631}
1632
1633fn tool_pair_cancel_pending(args: &Value) -> Result<Value, String> {
1634 let code_phrase = args
1635 .get("code_phrase")
1636 .and_then(Value::as_str)
1637 .ok_or("missing 'code_phrase'")?;
1638 let code = crate::sas::parse_code_phrase(code_phrase)
1639 .map_err(|e| e.to_string())?
1640 .to_string();
1641 if let Some(p) = crate::pending_pair::read_pending(&code).map_err(|e| e.to_string())? {
1642 let client = crate::relay_client::RelayClient::new(&p.relay_url);
1643 let _ = client.pair_abandon(&p.code_hash);
1644 }
1645 crate::pending_pair::delete_pending(&code).map_err(|e| e.to_string())?;
1646 Ok(json!({"state": "cancelled", "code_phrase": code}))
1647}
1648
1649fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1652 let relay_url = args.get("relay_url").and_then(Value::as_str);
1653 let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1654 let uses = args
1655 .get("uses")
1656 .and_then(Value::as_u64)
1657 .map(|u| u as u32)
1658 .unwrap_or(1);
1659 let url =
1660 crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1661 let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1662 Ok(json!({
1663 "invite_url": url,
1664 "ttl_secs": ttl_resolved,
1665 "uses": uses,
1666 }))
1667}
1668
1669fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1670 let url = args
1671 .get("url")
1672 .and_then(Value::as_str)
1673 .ok_or("missing 'url'")?;
1674 crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1675}
1676
1677fn tool_dial(args: &Value) -> Result<Value, String> {
1689 let name = args
1690 .get("name")
1691 .and_then(Value::as_str)
1692 .or_else(|| args.get("handle").and_then(Value::as_str))
1693 .ok_or("missing 'name'")?;
1694
1695 if name.contains('@') {
1696 let mut a = args.clone();
1698 if let Some(obj) = a.as_object_mut() {
1699 obj.insert("handle".into(), Value::String(name.to_string()));
1700 }
1701 return tool_add(&a);
1702 }
1703
1704 let relay_state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1705 let pinned = relay_state
1706 .get("peers")
1707 .and_then(Value::as_object)
1708 .map(|m| m.contains_key(name))
1709 .unwrap_or(false);
1710 if pinned {
1711 return Ok(json!({
1712 "name_input": name,
1713 "status": "already_pinned",
1714 "peer_handle": name,
1715 }));
1716 }
1717
1718 Err(format!(
1719 "cannot resolve `{name}` over MCP: bare-nickname / local-sister dialling is not yet \
1720 wired into the MCP surface. Use a federation handle `{name}@<relay>`, or `wire_send` \
1721 (it auto-pairs on miss)."
1722 ))
1723}
1724
1725fn tool_add(args: &Value) -> Result<Value, String> {
1726 let handle = args
1727 .get("handle")
1728 .and_then(Value::as_str)
1729 .ok_or("missing 'handle'")?;
1730 let relay_override = args.get("relay_url").and_then(Value::as_str);
1731
1732 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1733
1734 let (our_did, our_relay, our_slot_id, our_slot_token) =
1736 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1737
1738 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1740 .map_err(|e| format!("{e:#}"))?;
1741 let peer_card = resolved
1742 .get("card")
1743 .cloned()
1744 .ok_or("resolved missing card")?;
1745 let peer_did = resolved
1746 .get("did")
1747 .and_then(Value::as_str)
1748 .ok_or("resolved missing did")?
1749 .to_string();
1750 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1751 let peer_slot_id = resolved
1752 .get("slot_id")
1753 .and_then(Value::as_str)
1754 .ok_or("resolved missing slot_id")?
1755 .to_string();
1756 let peer_relay = resolved
1757 .get("relay_url")
1758 .and_then(Value::as_str)
1759 .map(str::to_string)
1760 .or_else(|| relay_override.map(str::to_string))
1761 .unwrap_or_else(|| format!("https://{}", parsed.domain));
1762
1763 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1765 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1766 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1767 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1768 let existing_token = relay_state
1769 .get("peers")
1770 .and_then(|p| p.get(&peer_handle))
1771 .and_then(|p| p.get("slot_token"))
1772 .and_then(Value::as_str)
1773 .map(str::to_string)
1774 .unwrap_or_default();
1775 relay_state["peers"][&peer_handle] = json!({
1776 "relay_url": peer_relay,
1777 "slot_id": peer_slot_id,
1778 "slot_token": existing_token,
1779 });
1780 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1781
1782 let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1784 let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1785 let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1786 let pk_b64 = our_card
1787 .get("verify_keys")
1788 .and_then(Value::as_object)
1789 .and_then(|m| m.values().next())
1790 .and_then(|v| v.get("key"))
1791 .and_then(Value::as_str)
1792 .ok_or("our card missing verify_keys[*].key")?;
1793 let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1794 let now = time::OffsetDateTime::now_utc()
1795 .format(&time::format_description::well_known::Rfc3339)
1796 .unwrap_or_default();
1797 let event = json!({
1798 "timestamp": now,
1799 "from": our_did,
1800 "to": peer_did,
1801 "type": "pair_drop",
1802 "kind": 1100u32,
1803 "body": {
1804 "card": our_card,
1805 "relay_url": our_relay,
1806 "slot_id": our_slot_id,
1807 "slot_token": our_slot_token,
1808 },
1809 });
1810 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
1811 .map_err(|e| format!("{e:#}"))?;
1812
1813 let client = crate::relay_client::RelayClient::new(&peer_relay);
1814 let resp = client
1815 .handle_intro(&parsed.nick, &signed)
1816 .map_err(|e| format!("{e:#}"))?;
1817 let event_id = signed
1818 .get("event_id")
1819 .and_then(Value::as_str)
1820 .unwrap_or("")
1821 .to_string();
1822 Ok(json!({
1823 "handle": handle,
1824 "paired_with": peer_did,
1825 "peer_handle": peer_handle,
1826 "event_id": event_id,
1827 "drop_response": resp,
1828 "status": "drop_sent",
1829 }))
1830}
1831
1832fn tool_pair_accept(args: &Value) -> Result<Value, String> {
1837 let peer = args
1838 .get("peer")
1839 .and_then(Value::as_str)
1840 .ok_or("missing 'peer'")?;
1841 let nick = crate::agent_card::bare_handle(peer);
1842 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
1843 .map_err(|e| format!("{e:#}"))?
1844 .ok_or_else(|| {
1845 format!(
1846 "no pending pair request from {nick}. Call wire_pair_list_inbound to enumerate, \
1847 or wire_add to send a fresh outbound pair request."
1848 )
1849 })?;
1850
1851 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1854 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
1855 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1856
1857 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1859 relay_state["peers"][&pending.peer_handle] = json!({
1860 "relay_url": pending.peer_relay_url,
1861 "slot_id": pending.peer_slot_id,
1862 "slot_token": pending.peer_slot_token,
1863 });
1864 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1865
1866 crate::pair_invite::send_pair_drop_ack(
1868 &pending.peer_handle,
1869 &pending.peer_relay_url,
1870 &pending.peer_slot_id,
1871 &pending.peer_slot_token,
1872 )
1873 .map_err(|e| {
1874 format!(
1875 "pair_drop_ack send to {} @ {} slot {} failed: {e:#}",
1876 pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
1877 )
1878 })?;
1879
1880 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1881
1882 Ok(json!({
1883 "status": "bilateral_accepted",
1884 "peer_handle": pending.peer_handle,
1885 "peer_did": pending.peer_did,
1886 "peer_relay_url": pending.peer_relay_url,
1887 "via": "pending_inbound",
1888 }))
1889}
1890
1891fn tool_pair_reject(args: &Value) -> Result<Value, String> {
1894 let peer = args
1895 .get("peer")
1896 .and_then(Value::as_str)
1897 .ok_or("missing 'peer'")?;
1898 let nick = crate::agent_card::bare_handle(peer);
1899 let existed =
1900 crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1901 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1902 Ok(json!({
1903 "peer": nick,
1904 "rejected": existed.is_some(),
1905 "had_pending": existed.is_some(),
1906 }))
1907}
1908
1909fn tool_pair_list_inbound() -> Result<Value, String> {
1912 let items =
1913 crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
1914 Ok(json!(items))
1915}
1916
1917fn tool_claim_handle(args: &Value) -> Result<Value, String> {
1918 let typed = args.get("nick").and_then(Value::as_str);
1919 let relay_override = args.get("relay_url").and_then(Value::as_str);
1920 let public_url = args.get("public_url").and_then(Value::as_str);
1921
1922 let (_, our_relay, our_slot_id, our_slot_token) =
1924 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1925 let claim_relay = relay_override.unwrap_or(&our_relay);
1926 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1927
1928 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
1933 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
1934 let nick = if canonical.is_empty() {
1935 typed.unwrap_or_default().to_string()
1936 } else {
1937 canonical
1938 };
1939 let typed_nick_ignored = typed.map(|t| t != nick).unwrap_or(false);
1940
1941 let client = crate::relay_client::RelayClient::new(claim_relay);
1942 let resp = client
1943 .handle_claim(&nick, &our_slot_id, &our_slot_token, public_url, &card)
1944 .map_err(|e| format!("{e:#}"))?;
1945 Ok(json!({
1946 "nick": nick,
1947 "relay": claim_relay,
1948 "response": resp,
1949 "one_name": true,
1950 "typed_nick_ignored": typed_nick_ignored,
1951 }))
1952}
1953
1954fn tool_whois(args: &Value) -> Result<Value, String> {
1955 if let Some(handle) = args.get("handle").and_then(Value::as_str) {
1956 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1957 let relay_override = args.get("relay_url").and_then(Value::as_str);
1958 crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
1959 } else {
1960 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1962 Ok(json!({
1963 "did": card.get("did").cloned().unwrap_or(Value::Null),
1964 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1965 }))
1966 }
1967}
1968
1969fn tool_profile_set(args: &Value) -> Result<Value, String> {
1970 let field = args
1971 .get("field")
1972 .and_then(Value::as_str)
1973 .ok_or("missing 'field'")?;
1974 let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
1975 let value = if let Some(s) = raw_value.as_str() {
1979 serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
1980 } else {
1981 raw_value
1982 };
1983 let new_profile =
1984 crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
1985 Ok(json!({
1986 "field": field,
1987 "profile": new_profile,
1988 }))
1989}
1990
1991fn tool_profile_get() -> Result<Value, String> {
1992 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1993 Ok(json!({
1994 "did": card.get("did").cloned().unwrap_or(Value::Null),
1995 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1996 }))
1997}
1998
1999fn parse_kind(s: &str) -> u32 {
2002 if let Ok(n) = s.parse::<u32>() {
2003 return n;
2004 }
2005 for (id, name) in crate::signing::kinds() {
2006 if *name == s {
2007 return *id;
2008 }
2009 }
2010 1
2011}
2012
2013fn error_response(id: &Value, code: i32, message: &str) -> Value {
2014 json!({
2015 "jsonrpc": "2.0",
2016 "id": id,
2017 "error": {"code": code, "message": message}
2018 })
2019}
2020
2021#[cfg(test)]
2022mod tests {
2023 use super::*;
2024
2025 #[test]
2026 fn unknown_method_returns_jsonrpc_error() {
2027 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
2028 let resp = handle_request(&req, &McpState::default());
2029 assert_eq!(resp["error"]["code"], -32601);
2030 }
2031
2032 #[test]
2033 fn initialize_advertises_tools_capability() {
2034 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
2035 let resp = handle_request(&req, &McpState::default());
2036 assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
2037 assert!(resp["result"]["capabilities"]["tools"].is_object());
2038 assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
2039 }
2040
2041 #[test]
2042 fn tools_list_includes_pairing_and_messaging() {
2043 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2044 let resp = handle_request(&req, &McpState::default());
2045 let names: Vec<&str> = resp["result"]["tools"]
2046 .as_array()
2047 .unwrap()
2048 .iter()
2049 .filter_map(|t| t["name"].as_str())
2050 .collect();
2051 for required in [
2052 "wire_whoami",
2053 "wire_peers",
2054 "wire_send",
2055 "wire_tail",
2056 "wire_verify",
2057 "wire_init",
2058 "wire_pair_initiate",
2059 "wire_pair_join",
2060 "wire_pair_check",
2061 "wire_pair_confirm",
2062 ] {
2063 assert!(
2064 names.contains(&required),
2065 "missing required tool {required}"
2066 );
2067 }
2068 assert!(
2072 !names.contains(&"wire_join"),
2073 "wire_join must not be advertised — superseded by wire_pair_join"
2074 );
2075 }
2076
2077 #[test]
2078 fn legacy_wire_join_call_returns_helpful_error() {
2079 let req = json!({
2080 "jsonrpc": "2.0",
2081 "id": 1,
2082 "method": "tools/call",
2083 "params": {"name": "wire_join", "arguments": {}}
2084 });
2085 let resp = handle_request(&req, &McpState::default());
2086 assert_eq!(resp["result"]["isError"], true);
2087 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2088 assert!(
2089 text.contains("wire_pair_join"),
2090 "expected redirect to wire_pair_join, got: {text}"
2091 );
2092 }
2093
2094 #[test]
2095 fn pair_confirm_missing_session_id_errors_cleanly() {
2096 let req = json!({
2097 "jsonrpc": "2.0",
2098 "id": 1,
2099 "method": "tools/call",
2100 "params": {"name": "wire_pair_confirm", "arguments": {"user_typed_digits": "111111"}}
2101 });
2102 let resp = handle_request(&req, &McpState::default());
2103 assert_eq!(resp["result"]["isError"], true);
2104 }
2105
2106 #[test]
2107 fn pair_confirm_unknown_session_errors_cleanly() {
2108 let req = json!({
2109 "jsonrpc": "2.0",
2110 "id": 1,
2111 "method": "tools/call",
2112 "params": {
2113 "name": "wire_pair_confirm",
2114 "arguments": {"session_id": "definitely-not-real", "user_typed_digits": "111111"}
2115 }
2116 });
2117 let resp = handle_request(&req, &McpState::default());
2118 assert_eq!(resp["result"]["isError"], true);
2119 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2120 assert!(text.contains("no such session_id"), "got: {text}");
2121 }
2122
2123 #[test]
2124 fn initialize_advertises_resources_capability() {
2125 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
2126 let resp = handle_request(&req, &McpState::default());
2127 let caps = &resp["result"]["capabilities"];
2128 assert!(
2129 caps["resources"].is_object(),
2130 "resources capability must be present, got {resp}"
2131 );
2132 assert_eq!(
2133 caps["resources"]["subscribe"], true,
2134 "subscribe shipped in v0.2.1"
2135 );
2136 }
2137
2138 #[test]
2139 fn resources_read_with_bad_uri_errors() {
2140 let req = json!({
2141 "jsonrpc": "2.0",
2142 "id": 1,
2143 "method": "resources/read",
2144 "params": {"uri": "http://example.com/not-a-wire-uri"}
2145 });
2146 let resp = handle_request(&req, &McpState::default());
2147 assert!(resp.get("error").is_some(), "expected error, got {resp}");
2148 }
2149
2150 #[test]
2151 fn parse_inbox_uri_handles_variants() {
2152 assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
2153 assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
2154 assert!(
2155 parse_inbox_uri("wire://inbox/")
2156 .unwrap()
2157 .starts_with("__invalid__"),
2158 "empty peer must be invalid"
2159 );
2160 assert!(
2161 parse_inbox_uri("http://other")
2162 .unwrap()
2163 .starts_with("__invalid__"),
2164 "non-wire scheme must be invalid"
2165 );
2166 }
2167
2168 #[test]
2169 fn ping_returns_empty_result() {
2170 let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2171 let resp = handle_request(&req, &McpState::default());
2172 assert_eq!(resp["id"], 7);
2173 assert!(resp["result"].is_object());
2174 }
2175
2176 #[test]
2177 fn notification_returns_null_no_reply() {
2178 let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2179 let resp = handle_request(&req, &McpState::default());
2180 assert_eq!(resp, Value::Null);
2181 }
2182
2183 #[test]
2190 fn detect_session_wire_home_resolves_registered_cwd() {
2191 crate::config::test_support::with_temp_home(|| {
2192 let wire_home = std::env::var("WIRE_HOME").unwrap();
2196 let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2197 let session_home = sessions_root.join("test-alpha");
2198 std::fs::create_dir_all(&session_home).unwrap();
2199 let fake_cwd = "/tmp/fake-project-cwd-abc123";
2200 let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2201 std::fs::write(
2202 sessions_root.join("registry.json"),
2203 serde_json::to_vec_pretty(®istry).unwrap(),
2204 )
2205 .unwrap();
2206
2207 let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2209 assert_eq!(
2210 got.as_deref(),
2211 Some(session_home.as_path()),
2212 "registered cwd must resolve to session_home"
2213 );
2214
2215 let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2217 "/tmp/cwd-not-in-registry-xyz789",
2218 ));
2219 assert!(nope.is_none(), "unregistered cwd must return None");
2220
2221 let stale_cwd = "/tmp/stale-session-cwd";
2224 let stale_registry =
2225 json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2226 std::fs::write(
2227 sessions_root.join("registry.json"),
2228 serde_json::to_vec_pretty(&stale_registry).unwrap(),
2229 )
2230 .unwrap();
2231 let stale_got =
2232 crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2233 assert!(
2234 stale_got.is_none(),
2235 "registered cwd whose session dir is missing must return None"
2236 );
2237 });
2238 }
2239}