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<()> {
89 use std::sync::atomic::{AtomicBool, Ordering};
90 use std::sync::mpsc;
91 use std::time::{Duration, Instant};
92
93 let state = McpState::default();
94 let shutdown = Arc::new(AtomicBool::new(false));
95
96 let (tx, rx) = mpsc::channel::<String>();
97
98 if let Ok(mut g) = state.notif_tx.lock() {
101 *g = Some(tx.clone());
102 }
103
104 let writer_handle = std::thread::spawn(move || {
106 let stdout = std::io::stdout();
107 let mut w = stdout.lock();
108 while let Ok(line) = rx.recv() {
109 if writeln!(w, "{line}").is_err() {
110 break;
111 }
112 if w.flush().is_err() {
113 break;
114 }
115 }
116 });
117
118 let subs_w = state.subscribed.clone();
123 let tx_w = tx.clone();
124 let shutdown_w = shutdown.clone();
125 let watcher_handle = std::thread::spawn(move || {
126 let mut watcher = match crate::inbox_watch::InboxWatcher::from_head() {
127 Ok(w) => w,
128 Err(_) => return,
129 };
130 let mut prev_pending: std::collections::HashMap<String, String> =
134 std::collections::HashMap::new();
135 let poll_interval = Duration::from_secs(2);
136 let mut next_poll = Instant::now() + poll_interval;
137 loop {
138 if shutdown_w.load(Ordering::SeqCst) {
139 return;
140 }
141 std::thread::sleep(Duration::from_millis(100));
142 if Instant::now() < next_poll {
143 continue;
144 }
145 next_poll = Instant::now() + poll_interval;
146 let subs_snapshot = match subs_w.lock() {
147 Ok(g) => g.clone(),
148 Err(_) => return,
149 };
150
151 let mut affected: HashSet<String> = HashSet::new();
152
153 if !subs_snapshot.is_empty()
155 && let Ok(events) = watcher.poll()
156 {
157 for ev in &events {
158 if subs_snapshot.contains("wire://inbox/all") {
159 affected.insert("wire://inbox/all".to_string());
160 }
161 let peer_uri = format!("wire://inbox/{}", ev.peer);
162 if subs_snapshot.contains(&peer_uri) {
163 affected.insert(peer_uri);
164 }
165 }
166 }
167
168 if let Ok(items) = crate::pending_pair::list_pending() {
171 let mut cur: std::collections::HashMap<String, String> =
172 std::collections::HashMap::new();
173 for p in &items {
174 cur.insert(p.code.clone(), p.status.clone());
175 }
176 let changed = cur.len() != prev_pending.len()
179 || cur.iter().any(|(k, v)| prev_pending.get(k) != Some(v))
180 || prev_pending.keys().any(|k| !cur.contains_key(k));
181 if changed && subs_snapshot.contains("wire://pending-pair/all") {
182 affected.insert("wire://pending-pair/all".to_string());
183 }
184 prev_pending = cur;
185 }
186
187 for uri in affected {
188 let notif = json!({
189 "jsonrpc": "2.0",
190 "method": "notifications/resources/updated",
191 "params": {"uri": uri}
192 });
193 if tx_w.send(notif.to_string()).is_err() {
194 return;
195 }
196 }
197 }
198 });
199
200 let stdin = std::io::stdin();
201 let mut reader = BufReader::new(stdin.lock());
202 let mut line = String::new();
203 loop {
204 line.clear();
205 let n = reader.read_line(&mut line)?;
206 if n == 0 {
207 shutdown.store(true, Ordering::SeqCst);
211 if let Ok(mut g) = state.notif_tx.lock() {
212 *g = None;
213 }
214 drop(tx);
215 let _ = watcher_handle.join();
216 let _ = writer_handle.join();
217 return Ok(());
218 }
219 let trimmed = line.trim();
220 if trimmed.is_empty() {
221 continue;
222 }
223 let request: Value = match serde_json::from_str(trimmed) {
224 Ok(v) => v,
225 Err(e) => {
226 let err = error_response(&Value::Null, -32700, &format!("parse error: {e}"));
227 let _ = tx.send(err.to_string());
228 continue;
229 }
230 };
231 let response = handle_request(&request, &state);
232 if response.get("id").is_some() || response.get("error").is_some() {
234 let _ = tx.send(response.to_string());
235 }
236 }
237}
238
239fn handle_request(req: &Value, state: &McpState) -> Value {
240 let id = req.get("id").cloned().unwrap_or(Value::Null);
241 let method = match req.get("method").and_then(Value::as_str) {
242 Some(m) => m,
243 None => return error_response(&id, -32600, "missing method"),
244 };
245 match method {
246 "initialize" => handle_initialize(&id),
247 "notifications/initialized" => Value::Null, "tools/list" => handle_tools_list(&id),
249 "tools/call" => handle_tools_call(&id, req.get("params").unwrap_or(&Value::Null), state),
250 "resources/list" => handle_resources_list(&id),
251 "resources/read" => handle_resources_read(&id, req.get("params").unwrap_or(&Value::Null)),
252 "resources/subscribe" => {
253 handle_resources_subscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
254 }
255 "resources/unsubscribe" => {
256 handle_resources_unsubscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
257 }
258 "ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}),
259 other => error_response(&id, -32601, &format!("method not found: {other}")),
260 }
261}
262
263fn handle_resources_list(id: &Value) -> Value {
275 let mut resources = vec![
276 json!({
277 "uri": "wire://inbox/all",
278 "name": "wire inbox (all peers)",
279 "description": "Most recent verified events from all pinned peers, JSONL.",
280 "mimeType": "application/x-ndjson"
281 }),
282 json!({
283 "uri": "wire://pending-pair/all",
284 "name": "wire pending pair sessions",
285 "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).",
286 "mimeType": "application/json"
287 }),
288 ];
289
290 if let Ok(trust) = crate::config::read_trust() {
291 let agents = trust
292 .get("agents")
293 .and_then(Value::as_object)
294 .cloned()
295 .unwrap_or_default();
296 let self_did = crate::config::read_agent_card()
297 .ok()
298 .and_then(|c| c.get("did").and_then(Value::as_str).map(str::to_string));
299 for (handle, agent) in agents.iter() {
300 let did = agent
301 .get("did")
302 .and_then(Value::as_str)
303 .unwrap_or("")
304 .to_string();
305 if Some(did.as_str()) == self_did.as_deref() {
306 continue;
307 }
308 resources.push(json!({
309 "uri": format!("wire://inbox/{handle}"),
310 "name": format!("inbox from {handle}"),
311 "description": format!("Recent verified events from did:wire:{handle}."),
312 "mimeType": "application/x-ndjson"
313 }));
314 }
315 }
316
317 json!({
318 "jsonrpc": "2.0",
319 "id": id,
320 "result": {
321 "resources": resources
322 }
323 })
324}
325
326fn handle_resources_subscribe(id: &Value, params: &Value, state: &McpState) -> Value {
327 let uri = match params.get("uri").and_then(Value::as_str) {
328 Some(u) => u.to_string(),
329 None => return error_response(id, -32602, "missing 'uri'"),
330 };
331 let inbox_peer = parse_inbox_uri(&uri);
335 let is_pending = uri == "wire://pending-pair/all";
336 if let Some(ref p) = inbox_peer
337 && p.starts_with("__invalid__")
338 && !is_pending
339 {
340 return error_response(
341 id,
342 -32602,
343 "subscribe URI must be wire://inbox/<peer>, wire://inbox/all, or wire://pending-pair/all",
344 );
345 }
346 if let Ok(mut g) = state.subscribed.lock() {
347 g.insert(uri);
348 }
349 json!({"jsonrpc": "2.0", "id": id, "result": {}})
350}
351
352fn handle_resources_unsubscribe(id: &Value, params: &Value, state: &McpState) -> Value {
353 let uri = match params.get("uri").and_then(Value::as_str) {
354 Some(u) => u.to_string(),
355 None => return error_response(id, -32602, "missing 'uri'"),
356 };
357 if let Ok(mut g) = state.subscribed.lock() {
358 g.remove(&uri);
359 }
360 json!({"jsonrpc": "2.0", "id": id, "result": {}})
361}
362
363fn handle_resources_read(id: &Value, params: &Value) -> Value {
364 let uri = match params.get("uri").and_then(Value::as_str) {
365 Some(u) => u,
366 None => return error_response(id, -32602, "missing 'uri'"),
367 };
368 if uri == "wire://pending-pair/all" {
370 return match crate::pending_pair::list_pending() {
371 Ok(items) => {
372 let body = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string());
373 json!({
374 "jsonrpc": "2.0",
375 "id": id,
376 "result": {
377 "contents": [{
378 "uri": uri,
379 "mimeType": "application/json",
380 "text": body,
381 }]
382 }
383 })
384 }
385 Err(e) => error_response(id, -32603, &e.to_string()),
386 };
387 }
388 let peer_opt = parse_inbox_uri(uri);
389 match read_inbox_resource(peer_opt) {
390 Ok(payload) => json!({
391 "jsonrpc": "2.0",
392 "id": id,
393 "result": {
394 "contents": [{
395 "uri": uri,
396 "mimeType": "application/x-ndjson",
397 "text": payload,
398 }]
399 }
400 }),
401 Err(e) => error_response(id, -32603, &e.to_string()),
402 }
403}
404
405fn parse_inbox_uri(uri: &str) -> Option<String> {
408 if let Some(rest) = uri.strip_prefix("wire://inbox/") {
409 if rest == "all" {
410 return None;
411 }
412 if !rest.is_empty() {
413 return Some(rest.to_string());
414 }
415 }
416 Some(format!("__invalid__{uri}"))
417}
418
419fn read_inbox_resource(peer_opt: Option<String>) -> Result<String, String> {
420 const LIMIT: usize = 50;
421 if let Some(ref p) = peer_opt
424 && p.starts_with("__invalid__")
425 {
426 return Err(
427 "unknown resource URI (must be wire://inbox/<peer> or wire://inbox/all)".into(),
428 );
429 }
430 let inbox = crate::config::inbox_dir().map_err(|e| e.to_string())?;
431 if !inbox.exists() {
432 return Ok(String::new());
433 }
434 let trust = crate::config::read_trust().map_err(|e| e.to_string())?;
435
436 let paths: Vec<std::path::PathBuf> = match peer_opt {
437 Some(p) => {
438 let path = inbox.join(format!("{p}.jsonl"));
439 if !path.exists() {
440 return Ok(String::new());
441 }
442 vec![path]
443 }
444 None => std::fs::read_dir(&inbox)
445 .map_err(|e| e.to_string())?
446 .flatten()
447 .map(|e| e.path())
448 .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
449 .collect(),
450 };
451
452 let mut events: Vec<(String, bool, Value)> = Vec::new();
453 for path in paths {
454 let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
455 let peer = path
456 .file_stem()
457 .and_then(|s| s.to_str())
458 .unwrap_or("")
459 .to_string();
460 for line in body.lines() {
461 let event: Value = match serde_json::from_str(line) {
462 Ok(v) => v,
463 Err(_) => continue,
464 };
465 let verified = crate::signing::verify_message_v31(&event, &trust).is_ok();
466 events.push((peer.clone(), verified, event));
467 }
468 }
469 let take_from = events.len().saturating_sub(LIMIT);
471 let tail = &events[take_from..];
472
473 let mut out = String::new();
474 for (_peer, verified, mut event) in tail.iter().cloned() {
475 if let Some(obj) = event.as_object_mut() {
476 obj.insert("verified".into(), json!(verified));
477 }
478 out.push_str(&serde_json::to_string(&event).map_err(|e| e.to_string())?);
479 out.push('\n');
480 }
481 Ok(out)
482}
483
484fn handle_initialize(id: &Value) -> Value {
485 json!({
486 "jsonrpc": "2.0",
487 "id": id,
488 "result": {
489 "protocolVersion": PROTOCOL_VERSION,
490 "capabilities": {
491 "tools": {"listChanged": false},
492 "resources": {
493 "listChanged": false,
494 "subscribe": true
499 }
500 },
501 "serverInfo": {
502 "name": SERVER_NAME,
503 "version": SERVER_VERSION,
504 },
505 "instructions": "wire — magic-wormhole for AI agents. Agents drive pairing via wire_pair_initiate/join/check; the user types the 6-digit SAS back into chat for wire_pair_confirm — this is the only human-in-loop step. Resources: 'wire://inbox/<peer>' exposes each pinned peer's verified inbox (JSONL). RECOMMENDED ON SESSION START: arm a persistent stream-watcher on `wire monitor` (or `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; in other harnesses background the process. Default filter strips pair_drop/pair_drop_ack/heartbeat noise — one stdout line per real event. See docs/AGENT_INTEGRATION.md for the full monitor recipe and THREAT_MODEL.md (T10/T14)."
506 }
507 })
508}
509
510fn handle_tools_list(id: &Value) -> Value {
511 json!({
512 "jsonrpc": "2.0",
513 "id": id,
514 "result": {
515 "tools": tool_defs(),
516 }
517 })
518}
519
520fn tool_defs() -> Vec<Value> {
521 vec![
522 json!({
523 "name": "wire_whoami",
524 "description": "Return this agent's DID, fingerprint, key_id, public key, and capabilities. Read-only.",
525 "inputSchema": {"type": "object", "properties": {}, "required": []}
526 }),
527 json!({
528 "name": "wire_peers",
529 "description": "List pinned peers with their tier (UNTRUSTED/VERIFIED/ATTESTED) and advertised capabilities. Read-only.",
530 "inputSchema": {"type": "object", "properties": {}, "required": []}
531 }),
532 json!({
533 "name": "wire_send",
534 "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.",
535 "inputSchema": {
536 "type": "object",
537 "properties": {
538 "peer": {"type": "string", "description": "Peer handle (without did:wire: prefix). Must be a pinned peer; check wire_peers first."},
539 "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."},
540 "body": {"type": "string", "description": "Event body. Plain text becomes a JSON string; valid JSON is parsed and embedded structurally."},
541 "time_sensitive_until": {"type": "string", "description": "Optional advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp."}
542 },
543 "required": ["peer", "kind", "body"]
544 }
545 }),
546 json!({
547 "name": "wire_tail",
548 "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.",
549 "inputSchema": {
550 "type": "object",
551 "properties": {
552 "peer": {"type": "string", "description": "Optional peer handle to filter inbox by."},
553 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Max events to return."}
554 },
555 "required": []
556 }
557 }),
558 json!({
559 "name": "wire_verify",
560 "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).",
561 "inputSchema": {
562 "type": "object",
563 "properties": {
564 "event": {"type": "string", "description": "JSON-encoded signed event."}
565 },
566 "required": ["event"]
567 }
568 }),
569 json!({
570 "name": "wire_init",
571 "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.",
572 "inputSchema": {
573 "type": "object",
574 "properties": {
575 "handle": {"type": "string", "description": "Short handle (becomes did:wire:<handle>). ASCII alphanumeric / '-' / '_' only."},
576 "name": {"type": "string", "description": "Optional display name (defaults to capitalized handle)."},
577 "relay_url": {"type": "string", "description": "Optional relay URL — if set, also binds a relay slot."}
578 },
579 "required": ["handle"]
580 }
581 }),
582 json!({
583 "name": "wire_pair_initiate",
584 "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).",
585 "inputSchema": {
586 "type": "object",
587 "properties": {
588 "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
589 "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
590 "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."}
591 },
592 "required": []
593 }
594 }),
595 json!({
596 "name": "wire_pair_join",
597 "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.",
598 "inputSchema": {
599 "type": "object",
600 "properties": {
601 "code_phrase": {"type": "string", "description": "Code phrase from the host (e.g. '73-2QXC4P')."},
602 "handle": {"type": "string", "description": "Auto-init this handle if local identity not yet created. Skipped if already inited."},
603 "relay_url": {"type": "string", "description": "Relay base URL. Defaults to the relay this agent's identity is already bound to."},
604 "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 30, "description": "How long to block waiting for SPAKE2 exchange to complete."}
605 },
606 "required": ["code_phrase"]
607 }
608 }),
609 json!({
610 "name": "wire_pair_check",
611 "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.",
612 "inputSchema": {
613 "type": "object",
614 "properties": {
615 "session_id": {"type": "string"},
616 "max_wait_secs": {"type": "integer", "minimum": 0, "maximum": 60, "default": 8}
617 },
618 "required": ["session_id"]
619 }
620 }),
621 json!({
622 "name": "wire_pair_confirm",
623 "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').",
624 "inputSchema": {
625 "type": "object",
626 "properties": {
627 "session_id": {"type": "string"},
628 "user_typed_digits": {"type": "string", "description": "The 6 SAS digits the user typed back, e.g. '384217' or '384-217'."}
629 },
630 "required": ["session_id", "user_typed_digits"]
631 }
632 }),
633 json!({
634 "name": "wire_pair_initiate_detached",
635 "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.",
636 "inputSchema": {
637 "type": "object",
638 "properties": {
639 "handle": {"type": "string", "description": "Optional handle for auto-init (idempotent)."},
640 "relay_url": {"type": "string"}
641 }
642 }
643 }),
644 json!({
645 "name": "wire_pair_join_detached",
646 "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.",
647 "inputSchema": {
648 "type": "object",
649 "properties": {
650 "handle": {"type": "string"},
651 "code_phrase": {"type": "string"},
652 "relay_url": {"type": "string"}
653 },
654 "required": ["code_phrase"]
655 }
656 }),
657 json!({
658 "name": "wire_pair_list_pending",
659 "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.",
660 "inputSchema": {"type": "object", "properties": {}}
661 }),
662 json!({
663 "name": "wire_pair_confirm_detached",
664 "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.",
665 "inputSchema": {
666 "type": "object",
667 "properties": {
668 "code_phrase": {"type": "string"},
669 "user_typed_digits": {"type": "string"}
670 },
671 "required": ["code_phrase", "user_typed_digits"]
672 }
673 }),
674 json!({
675 "name": "wire_pair_cancel_pending",
676 "description": "Cancel a pending detached pair. Releases the relay slot and removes the local pending file. Safe to call regardless of current status (idempotent).",
677 "inputSchema": {
678 "type": "object",
679 "properties": {"code_phrase": {"type": "string"}},
680 "required": ["code_phrase"]
681 }
682 }),
683 json!({
684 "name": "wire_invite_mint",
685 "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}.",
686 "inputSchema": {
687 "type": "object",
688 "properties": {
689 "relay_url": {"type": "string", "description": "Override relay for first-time auto-allocate."},
690 "ttl_secs": {"type": "integer", "description": "Invite lifetime in seconds (default 86400)."},
691 "uses": {"type": "integer", "description": "Number of distinct peers that can accept before consumption (default 1)."}
692 }
693 }
694 }),
695 json!({
696 "name": "wire_invite_accept",
697 "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}.",
698 "inputSchema": {
699 "type": "object",
700 "properties": {
701 "url": {"type": "string", "description": "Full wire://pair?v=1&inv=... URL."}
702 },
703 "required": ["url"]
704 }
705 }),
706 json!({
708 "name": "wire_add",
709 "description": "Zero-paste pair (v0.5). 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. Peer's daemon completes the bilateral pin on next pull. After ~1-2 sec both sides can `wire_send` to each other. Use this when the operator gives you a handle like `coffee-ghost@wireup.net`.",
710 "inputSchema": {
711 "type": "object",
712 "properties": {
713 "handle": {"type": "string", "description": "Peer handle like `nick@domain`."},
714 "relay_url": {"type": "string", "description": "Override resolver URL (default: `https://<domain>`)."}
715 },
716 "required": ["handle"]
717 }
718 }),
719 json!({
720 "name": "wire_claim",
721 "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).",
722 "inputSchema": {
723 "type": "object",
724 "properties": {
725 "nick": {"type": "string", "description": "2-32 chars, [a-z0-9_-], not in the reserved set."},
726 "relay_url": {"type": "string", "description": "Relay to claim on. Default = our relay."},
727 "public_url": {"type": "string", "description": "Public URL the relay should advertise to resolvers."}
728 },
729 "required": ["nick"]
730 }
731 }),
732 json!({
733 "name": "wire_whois",
734 "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.",
735 "inputSchema": {
736 "type": "object",
737 "properties": {
738 "handle": {"type": "string", "description": "Optional `nick@domain`. Omit for self."},
739 "relay_url": {"type": "string", "description": "Override resolver URL."}
740 }
741 }
742 }),
743 json!({
744 "name": "wire_profile_set",
745 "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.",
746 "inputSchema": {
747 "type": "object",
748 "properties": {
749 "field": {"type": "string", "description": "One of: display_name, emoji, motto, vibe, pronouns, avatar_url, handle, now."},
750 "value": {"description": "String for most fields; array for vibe; object for now. Pass JSON null to clear a field."}
751 },
752 "required": ["field", "value"]
753 }
754 }),
755 json!({
756 "name": "wire_profile_get",
757 "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.",
758 "inputSchema": {"type": "object", "properties": {}}
759 }),
760 ]
761}
762
763fn handle_tools_call(id: &Value, params: &Value, state: &McpState) -> Value {
764 let name = match params.get("name").and_then(Value::as_str) {
765 Some(n) => n,
766 None => return error_response(id, -32602, "missing tool name"),
767 };
768 let args = params
769 .get("arguments")
770 .cloned()
771 .unwrap_or_else(|| json!({}));
772
773 let result = match name {
774 "wire_whoami" => tool_whoami(),
775 "wire_peers" => tool_peers(),
776 "wire_send" => tool_send(&args),
777 "wire_tail" => tool_tail(&args),
778 "wire_verify" => tool_verify(&args),
779 "wire_init" => tool_init(&args),
780 "wire_pair_initiate" => tool_pair_initiate(&args),
781 "wire_pair_join" => tool_pair_join(&args),
782 "wire_pair_check" => tool_pair_check(&args),
783 "wire_pair_confirm" => tool_pair_confirm(&args, state),
784 "wire_pair_initiate_detached" => tool_pair_initiate_detached(&args),
785 "wire_pair_join_detached" => tool_pair_join_detached(&args),
786 "wire_pair_list_pending" => tool_pair_list_pending(),
787 "wire_pair_confirm_detached" => tool_pair_confirm_detached(&args),
788 "wire_pair_cancel_pending" => tool_pair_cancel_pending(&args),
789 "wire_invite_mint" => tool_invite_mint(&args),
790 "wire_invite_accept" => tool_invite_accept(&args),
791 "wire_add" => tool_add(&args),
793 "wire_claim" => tool_claim_handle(&args),
794 "wire_whois" => tool_whois(&args),
795 "wire_profile_set" => tool_profile_set(&args),
796 "wire_profile_get" => tool_profile_get(),
797 "wire_join" => Err(
800 "wire_join was renamed to wire_pair_join (use code_phrase argument). \
801 See docs/AGENT_INTEGRATION.md."
802 .into(),
803 ),
804 other => Err(format!("unknown tool: {other}")),
805 };
806
807 match result {
808 Ok(value) => json!({
809 "jsonrpc": "2.0",
810 "id": id,
811 "result": {
812 "content": [{
813 "type": "text",
814 "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
815 }],
816 "isError": false
817 }
818 }),
819 Err(message) => json!({
820 "jsonrpc": "2.0",
821 "id": id,
822 "result": {
823 "content": [{"type": "text", "text": message}],
824 "isError": true
825 }
826 }),
827 }
828}
829
830fn tool_whoami() -> Result<Value, String> {
833 use crate::config;
834 use crate::signing::{b64decode, fingerprint, make_key_id};
835
836 if !config::is_initialized().map_err(|e| e.to_string())? {
837 return Err("not initialized — operator must run `wire init <handle>` first".into());
838 }
839 let card = config::read_agent_card().map_err(|e| e.to_string())?;
840 let did = card
841 .get("did")
842 .and_then(Value::as_str)
843 .unwrap_or("")
844 .to_string();
845 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
846 let pk_b64 = card
847 .get("verify_keys")
848 .and_then(Value::as_object)
849 .and_then(|m| m.values().next())
850 .and_then(|v| v.get("key"))
851 .and_then(Value::as_str)
852 .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
853 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
854 let fp = fingerprint(&pk_bytes);
855 let key_id = make_key_id(&handle, &pk_bytes);
856 let capabilities = card
857 .get("capabilities")
858 .cloned()
859 .unwrap_or_else(|| json!(["wire/v3.1"]));
860 Ok(json!({
861 "did": did,
862 "handle": handle,
863 "fingerprint": fp,
864 "key_id": key_id,
865 "public_key_b64": pk_b64,
866 "capabilities": capabilities,
867 }))
868}
869
870fn tool_peers() -> Result<Value, String> {
871 use crate::config;
872 use crate::trust::get_tier;
873
874 let trust = config::read_trust().map_err(|e| e.to_string())?;
875 let agents = trust
876 .get("agents")
877 .and_then(Value::as_object)
878 .cloned()
879 .unwrap_or_default();
880 let mut self_did: Option<String> = None;
881 if let Ok(card) = config::read_agent_card() {
882 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
883 }
884 let mut peers = Vec::new();
885 for (handle, agent) in agents.iter() {
886 let did = agent
887 .get("did")
888 .and_then(Value::as_str)
889 .unwrap_or("")
890 .to_string();
891 if Some(did.as_str()) == self_did.as_deref() {
892 continue;
893 }
894 peers.push(json!({
895 "handle": handle,
896 "did": did,
897 "tier": get_tier(&trust, handle),
898 "capabilities": agent.get("card").and_then(|c| c.get("capabilities")).cloned().unwrap_or_else(|| json!([])),
899 }));
900 }
901 Ok(json!(peers))
902}
903
904fn tool_send(args: &Value) -> Result<Value, String> {
905 use crate::config;
906 use crate::signing::{b64decode, sign_message_v31};
907
908 let peer = args
909 .get("peer")
910 .and_then(Value::as_str)
911 .ok_or("missing 'peer'")?;
912 let kind = args
913 .get("kind")
914 .and_then(Value::as_str)
915 .ok_or("missing 'kind'")?;
916 let body = args
917 .get("body")
918 .and_then(Value::as_str)
919 .ok_or("missing 'body'")?;
920 let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
921
922 if !config::is_initialized().map_err(|e| e.to_string())? {
923 return Err("not initialized — operator must run `wire init <handle>` first".into());
924 }
925 let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
926 let card = config::read_agent_card().map_err(|e| e.to_string())?;
927 let did = card
928 .get("did")
929 .and_then(Value::as_str)
930 .unwrap_or("")
931 .to_string();
932 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
933 let pk_b64 = card
934 .get("verify_keys")
935 .and_then(Value::as_object)
936 .and_then(|m| m.values().next())
937 .and_then(|v| v.get("key"))
938 .and_then(Value::as_str)
939 .ok_or("agent-card missing verify_keys[*].key")?;
940 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
941
942 let body_value: Value =
944 serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
945 let kind_id = parse_kind(kind);
946
947 let now = time::OffsetDateTime::now_utc()
948 .format(&time::format_description::well_known::Rfc3339)
949 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
950
951 let mut event = json!({
952 "timestamp": now,
953 "from": did,
954 "to": format!("did:wire:{peer}"),
955 "type": kind,
956 "kind": kind_id,
957 "body": body_value,
958 });
959 if let Some(deadline) = deadline {
960 event["time_sensitive_until"] =
961 json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
962 }
963 let signed =
964 sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
965 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
966
967 let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
968 let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
969
970 Ok(json!({
971 "event_id": event_id,
972 "status": "queued",
973 "peer": peer,
974 "outbox": outbox.to_string_lossy(),
975 }))
976}
977
978fn tool_tail(args: &Value) -> Result<Value, String> {
979 use crate::config;
980 use crate::signing::verify_message_v31;
981
982 let peer_filter = args.get("peer").and_then(Value::as_str);
983 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
984 let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
985 if !inbox.exists() {
986 return Ok(json!([]));
987 }
988 let trust = config::read_trust().map_err(|e| e.to_string())?;
989 let mut events = Vec::new();
990 let entries: Vec<_> = std::fs::read_dir(&inbox)
991 .map_err(|e| e.to_string())?
992 .filter_map(|e| e.ok())
993 .map(|e| e.path())
994 .filter(|p| {
995 p.extension().map(|x| x == "jsonl").unwrap_or(false)
996 && match peer_filter {
997 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
998 None => true,
999 }
1000 })
1001 .collect();
1002 for path in entries {
1003 let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
1004 for line in body.lines() {
1005 let event: Value = match serde_json::from_str(line) {
1006 Ok(v) => v,
1007 Err(_) => continue,
1008 };
1009 let verified = verify_message_v31(&event, &trust).is_ok();
1010 let mut event_with_meta = event.clone();
1011 if let Some(obj) = event_with_meta.as_object_mut() {
1012 obj.insert("verified".into(), json!(verified));
1013 }
1014 events.push(event_with_meta);
1015 if events.len() >= limit {
1016 return Ok(Value::Array(events));
1017 }
1018 }
1019 }
1020 Ok(Value::Array(events))
1021}
1022
1023fn tool_verify(args: &Value) -> Result<Value, String> {
1024 use crate::config;
1025 use crate::signing::verify_message_v31;
1026
1027 let event_str = args
1028 .get("event")
1029 .and_then(Value::as_str)
1030 .ok_or("missing 'event'")?;
1031 let event: Value =
1032 serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1033 let trust = config::read_trust().map_err(|e| e.to_string())?;
1034 match verify_message_v31(&event, &trust) {
1035 Ok(()) => Ok(json!({"verified": true})),
1036 Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1037 }
1038}
1039
1040fn tool_init(args: &Value) -> Result<Value, String> {
1043 let handle = args
1044 .get("handle")
1045 .and_then(Value::as_str)
1046 .ok_or("missing 'handle'")?;
1047 let name = args.get("name").and_then(Value::as_str);
1048 let relay = args.get("relay_url").and_then(Value::as_str);
1049 crate::pair_session::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1050}
1051
1052fn resolve_relay_url(args: &Value) -> Result<String, String> {
1056 if let Some(url) = args.get("relay_url").and_then(Value::as_str) {
1057 return Ok(url.to_string());
1058 }
1059 let state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1060 state["self"]["relay_url"]
1061 .as_str()
1062 .map(str::to_string)
1063 .ok_or_else(|| "no relay_url provided and no relay bound (call wire_init with relay_url, or pass relay_url here)".into())
1064}
1065
1066fn auto_init_if_needed(args: &Value) -> Result<(), String> {
1072 let initialized = crate::config::is_initialized().map_err(|e| e.to_string())?;
1073 if initialized {
1074 return Ok(());
1075 }
1076 let handle = args.get("handle").and_then(Value::as_str).ok_or(
1077 "not initialized — pass `handle` to auto-init, or call wire_init explicitly first",
1078 )?;
1079 let relay = args.get("relay_url").and_then(Value::as_str);
1080 crate::pair_session::init_self_idempotent(handle, None, relay)
1081 .map(|_| ())
1082 .map_err(|e| e.to_string())
1083}
1084
1085fn tool_pair_initiate(args: &Value) -> Result<Value, String> {
1086 use crate::pair_session::{
1087 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1088 };
1089
1090 store_sweep_expired();
1091 auto_init_if_needed(args)?;
1093
1094 let relay_url = resolve_relay_url(args)?;
1095 let max_wait = args
1096 .get("max_wait_secs")
1097 .and_then(Value::as_u64)
1098 .unwrap_or(30)
1099 .min(60);
1100
1101 let mut s = pair_session_open("host", &relay_url, None).map_err(|e| e.to_string())?;
1102 let code = s.code.clone();
1103
1104 let sas_opt = if max_wait > 0 {
1105 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1106 .map_err(|e| e.to_string())?
1107 } else {
1108 None
1109 };
1110
1111 let session_id = store_insert(s);
1112
1113 let mut out = json!({
1114 "session_id": session_id,
1115 "code_phrase": code,
1116 "relay_url": relay_url,
1117 });
1118 match sas_opt {
1119 Some(sas) => {
1120 out["state"] = json!("sas_ready");
1121 out["sas"] = json!(sas);
1122 out["next"] = json!(
1123 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel (voice/text). \
1124 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1125 );
1126 }
1127 None => {
1128 out["state"] = json!("waiting");
1129 out["next"] = json!(
1130 "Share the code_phrase with the user; ask them to read it to their peer (the peer pastes into wire_pair_join). \
1131 Poll wire_pair_check(session_id) until state='sas_ready'."
1132 );
1133 }
1134 }
1135 Ok(out)
1136}
1137
1138fn tool_pair_join(args: &Value) -> Result<Value, String> {
1139 use crate::pair_session::{
1140 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1141 };
1142
1143 store_sweep_expired();
1144 auto_init_if_needed(args)?;
1145
1146 let code = args
1147 .get("code_phrase")
1148 .and_then(Value::as_str)
1149 .ok_or("missing 'code_phrase'")?;
1150 let relay_url = resolve_relay_url(args)?;
1151 let max_wait = args
1152 .get("max_wait_secs")
1153 .and_then(Value::as_u64)
1154 .unwrap_or(30)
1155 .min(60);
1156
1157 let mut s = pair_session_open("guest", &relay_url, Some(code)).map_err(|e| e.to_string())?;
1158
1159 let sas_opt =
1160 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1161 .map_err(|e| e.to_string())?;
1162
1163 let session_id = store_insert(s);
1164
1165 let mut out = json!({
1166 "session_id": session_id,
1167 "relay_url": relay_url,
1168 });
1169 match sas_opt {
1170 Some(sas) => {
1171 out["state"] = json!("sas_ready");
1172 out["sas"] = json!(sas);
1173 out["next"] = json!(
1174 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel. \
1175 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1176 );
1177 }
1178 None => {
1179 out["state"] = json!("waiting");
1180 out["next"] = json!("Poll wire_pair_check(session_id).");
1181 }
1182 }
1183 Ok(out)
1184}
1185
1186fn tool_pair_check(args: &Value) -> Result<Value, String> {
1187 use crate::pair_session::{pair_session_wait_for_sas, store_get, store_sweep_expired};
1188
1189 store_sweep_expired();
1190 let session_id = args
1191 .get("session_id")
1192 .and_then(Value::as_str)
1193 .ok_or("missing 'session_id'")?;
1194 let max_wait = args
1195 .get("max_wait_secs")
1196 .and_then(Value::as_u64)
1197 .unwrap_or(8)
1198 .min(60);
1199
1200 let arc = store_get(session_id)
1201 .ok_or_else(|| format!("no such session_id (expired or never opened): {session_id}"))?;
1202 let mut s = arc.lock().map_err(|e| e.to_string())?;
1203
1204 if s.finalized {
1205 return Ok(json!({
1206 "state": "finalized",
1207 "session_id": session_id,
1208 "sas": s.formatted_sas(),
1209 }));
1210 }
1211 if let Some(reason) = s.aborted.clone() {
1212 return Ok(json!({
1213 "state": "aborted",
1214 "session_id": session_id,
1215 "reason": reason,
1216 }));
1217 }
1218
1219 let sas_opt =
1220 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1221 .map_err(|e| e.to_string())?;
1222
1223 Ok(match sas_opt {
1224 Some(sas) => json!({
1225 "state": "sas_ready",
1226 "session_id": session_id,
1227 "sas": sas,
1228 "next": "Have the user TYPE the 6 SAS digits BACK INTO CHAT, then pass to wire_pair_confirm."
1229 }),
1230 None => json!({
1231 "state": "waiting",
1232 "session_id": session_id,
1233 }),
1234 })
1235}
1236
1237fn tool_pair_confirm(args: &Value, state: &McpState) -> Result<Value, String> {
1238 use crate::pair_session::{
1239 pair_session_confirm_sas, pair_session_finalize, store_get, store_remove,
1240 };
1241
1242 let session_id = args
1243 .get("session_id")
1244 .and_then(Value::as_str)
1245 .ok_or("missing 'session_id'")?;
1246 let typed = args
1247 .get("user_typed_digits")
1248 .and_then(Value::as_str)
1249 .ok_or(
1250 "missing 'user_typed_digits' — the user must type the 6 SAS digits back into chat",
1251 )?;
1252
1253 let arc = store_get(session_id).ok_or_else(|| format!("no such session_id: {session_id}"))?;
1254
1255 let confirm_err = {
1256 let mut s = arc.lock().map_err(|e| e.to_string())?;
1257 match pair_session_confirm_sas(&mut s, typed) {
1258 Ok(()) => None,
1259 Err(e) => Some((s.aborted.is_some(), e.to_string())),
1260 }
1261 };
1262 if let Some((aborted, msg)) = confirm_err {
1263 if aborted {
1264 store_remove(session_id);
1265 }
1266 return Err(msg);
1267 }
1268
1269 let mut result = {
1270 let mut s = arc.lock().map_err(|e| e.to_string())?;
1271 pair_session_finalize(&mut s, 30).map_err(|e| e.to_string())?
1272 };
1273 store_remove(session_id);
1274
1275 let peer_handle = result["peer_handle"].as_str().unwrap_or("").to_string();
1284 let peer_uri = format!("wire://inbox/{peer_handle}");
1285
1286 let mut auto = json!({
1287 "subscribed": false,
1288 "daemon": "unknown",
1289 "notify": "unknown",
1290 "resources_list_changed_emitted": false,
1291 });
1292
1293 if !peer_handle.is_empty()
1294 && let Ok(mut g) = state.subscribed.lock()
1295 {
1296 g.insert(peer_uri.clone());
1297 auto["subscribed"] = json!(true);
1298 }
1299
1300 auto["daemon"] = match crate::ensure_up::ensure_daemon_running() {
1301 Ok(true) => json!("spawned"),
1302 Ok(false) => json!("already_running"),
1303 Err(e) => json!(format!("spawn_error: {e}")),
1304 };
1305 auto["notify"] = match crate::ensure_up::ensure_notify_running() {
1306 Ok(true) => json!("spawned"),
1307 Ok(false) => json!("already_running"),
1308 Err(e) => json!(format!("spawn_error: {e}")),
1309 };
1310
1311 if let Some(tx) = state.notif_tx.lock().ok().and_then(|g| g.clone()) {
1312 let notif = json!({
1313 "jsonrpc": "2.0",
1314 "method": "notifications/resources/list_changed",
1315 });
1316 if tx.send(notif.to_string()).is_ok() {
1317 auto["resources_list_changed_emitted"] = json!(true);
1318 }
1319 }
1320
1321 result["auto"] = auto;
1322 result["next"] = json!(
1323 "Done. Daemon + notify running, subscribed to peer inbox. Use wire_send/wire_tail \
1324 freely; new events arrive via notifications/resources/updated (where supported) and \
1325 OS toasts (always)."
1326 );
1327 Ok(result)
1328}
1329
1330fn tool_pair_initiate_detached(args: &Value) -> Result<Value, String> {
1333 auto_init_if_needed(args)?;
1334 let relay_url = resolve_relay_url(args)?;
1335 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1336 let _ = crate::ensure_up::ensure_daemon_running();
1337 }
1338 let code = crate::sas::generate_code_phrase();
1339 let code_hash = crate::pair_session::derive_code_hash(&code);
1340 let now = time::OffsetDateTime::now_utc()
1341 .format(&time::format_description::well_known::Rfc3339)
1342 .unwrap_or_default();
1343 let p = crate::pending_pair::PendingPair {
1344 code: code.clone(),
1345 code_hash,
1346 role: "host".to_string(),
1347 relay_url: relay_url.clone(),
1348 status: "request_host".to_string(),
1349 sas: None,
1350 peer_did: None,
1351 created_at: now,
1352 last_error: None,
1353 pair_id: None,
1354 our_slot_id: None,
1355 our_slot_token: None,
1356 spake2_seed_b64: None,
1357 };
1358 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1359 Ok(json!({
1360 "code_phrase": code,
1361 "relay_url": relay_url,
1362 "state": "queued",
1363 "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."
1364 }))
1365}
1366
1367fn tool_pair_join_detached(args: &Value) -> Result<Value, String> {
1368 auto_init_if_needed(args)?;
1369 let relay_url = resolve_relay_url(args)?;
1370 let code_phrase = args
1371 .get("code_phrase")
1372 .and_then(Value::as_str)
1373 .ok_or("missing 'code_phrase'")?;
1374 let code = crate::sas::parse_code_phrase(code_phrase)
1375 .map_err(|e| e.to_string())?
1376 .to_string();
1377 let code_hash = crate::pair_session::derive_code_hash(&code);
1378 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1379 let _ = crate::ensure_up::ensure_daemon_running();
1380 }
1381 let now = time::OffsetDateTime::now_utc()
1382 .format(&time::format_description::well_known::Rfc3339)
1383 .unwrap_or_default();
1384 let p = crate::pending_pair::PendingPair {
1385 code: code.clone(),
1386 code_hash,
1387 role: "guest".to_string(),
1388 relay_url: relay_url.clone(),
1389 status: "request_guest".to_string(),
1390 sas: None,
1391 peer_did: None,
1392 created_at: now,
1393 last_error: None,
1394 pair_id: None,
1395 our_slot_id: None,
1396 our_slot_token: None,
1397 spake2_seed_b64: None,
1398 };
1399 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1400 Ok(json!({
1401 "code_phrase": code,
1402 "relay_url": relay_url,
1403 "state": "queued",
1404 "next": "Subscribe to wire://pending-pair/all; on sas_ready notification, surface digits to user and call wire_pair_confirm_detached."
1405 }))
1406}
1407
1408fn tool_pair_list_pending() -> Result<Value, String> {
1409 let items = crate::pending_pair::list_pending().map_err(|e| e.to_string())?;
1410 Ok(json!({"pending": items}))
1411}
1412
1413fn tool_pair_confirm_detached(args: &Value) -> Result<Value, String> {
1414 let code_phrase = args
1415 .get("code_phrase")
1416 .and_then(Value::as_str)
1417 .ok_or("missing 'code_phrase'")?;
1418 let typed = args
1419 .get("user_typed_digits")
1420 .and_then(Value::as_str)
1421 .ok_or("missing 'user_typed_digits'")?;
1422 let code = crate::sas::parse_code_phrase(code_phrase)
1423 .map_err(|e| e.to_string())?
1424 .to_string();
1425 let typed: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
1426 if typed.len() != 6 {
1427 return Err(format!(
1428 "expected 6 digits (got {} after stripping non-digits)",
1429 typed.len()
1430 ));
1431 }
1432 let mut p = crate::pending_pair::read_pending(&code)
1433 .map_err(|e| e.to_string())?
1434 .ok_or_else(|| format!("no pending pair for code {code}"))?;
1435 if p.status != "sas_ready" {
1436 return Err(format!(
1437 "pair {code} not in sas_ready state (current: {})",
1438 p.status
1439 ));
1440 }
1441 let stored = p
1442 .sas
1443 .as_ref()
1444 .ok_or("pending file has status=sas_ready but no sas field")?
1445 .clone();
1446 if stored == typed {
1447 p.status = "confirmed".to_string();
1448 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1449 Ok(json!({
1450 "state": "confirmed",
1451 "code_phrase": code,
1452 "next": "Daemon will finalize on its next tick (~1s). Poll wire_peers or watch wire://pending-pair/all for the entry to disappear."
1453 }))
1454 } else {
1455 p.status = "aborted".to_string();
1456 p.last_error = Some(format!(
1457 "SAS digit mismatch (typed {typed}, expected {stored})"
1458 ));
1459 let client = crate::relay_client::RelayClient::new(&p.relay_url);
1460 let _ = client.pair_abandon(&p.code_hash);
1461 let _ = crate::pending_pair::write_pending(&p);
1462 crate::os_notify::toast(
1463 &format!("wire — pair aborted ({code})"),
1464 p.last_error.as_deref().unwrap_or("digits mismatch"),
1465 );
1466 Err(
1467 "digits mismatch — pair aborted. Re-issue with wire_pair_initiate_detached."
1468 .to_string(),
1469 )
1470 }
1471}
1472
1473fn tool_pair_cancel_pending(args: &Value) -> Result<Value, String> {
1474 let code_phrase = args
1475 .get("code_phrase")
1476 .and_then(Value::as_str)
1477 .ok_or("missing 'code_phrase'")?;
1478 let code = crate::sas::parse_code_phrase(code_phrase)
1479 .map_err(|e| e.to_string())?
1480 .to_string();
1481 if let Some(p) = crate::pending_pair::read_pending(&code).map_err(|e| e.to_string())? {
1482 let client = crate::relay_client::RelayClient::new(&p.relay_url);
1483 let _ = client.pair_abandon(&p.code_hash);
1484 }
1485 crate::pending_pair::delete_pending(&code).map_err(|e| e.to_string())?;
1486 Ok(json!({"state": "cancelled", "code_phrase": code}))
1487}
1488
1489fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1492 let relay_url = args.get("relay_url").and_then(Value::as_str);
1493 let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1494 let uses = args
1495 .get("uses")
1496 .and_then(Value::as_u64)
1497 .map(|u| u as u32)
1498 .unwrap_or(1);
1499 let url =
1500 crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1501 let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1502 Ok(json!({
1503 "invite_url": url,
1504 "ttl_secs": ttl_resolved,
1505 "uses": uses,
1506 }))
1507}
1508
1509fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1510 let url = args
1511 .get("url")
1512 .and_then(Value::as_str)
1513 .ok_or("missing 'url'")?;
1514 crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1515}
1516
1517fn tool_add(args: &Value) -> Result<Value, String> {
1520 let handle = args
1521 .get("handle")
1522 .and_then(Value::as_str)
1523 .ok_or("missing 'handle'")?;
1524 let relay_override = args.get("relay_url").and_then(Value::as_str);
1525
1526 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1527
1528 let (our_did, our_relay, our_slot_id, our_slot_token) =
1530 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1531
1532 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1534 .map_err(|e| format!("{e:#}"))?;
1535 let peer_card = resolved
1536 .get("card")
1537 .cloned()
1538 .ok_or("resolved missing card")?;
1539 let peer_did = resolved
1540 .get("did")
1541 .and_then(Value::as_str)
1542 .ok_or("resolved missing did")?
1543 .to_string();
1544 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1545 let peer_slot_id = resolved
1546 .get("slot_id")
1547 .and_then(Value::as_str)
1548 .ok_or("resolved missing slot_id")?
1549 .to_string();
1550 let peer_relay = resolved
1551 .get("relay_url")
1552 .and_then(Value::as_str)
1553 .map(str::to_string)
1554 .or_else(|| relay_override.map(str::to_string))
1555 .unwrap_or_else(|| format!("https://{}", parsed.domain));
1556
1557 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1559 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1560 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1561 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1562 let existing_token = relay_state
1563 .get("peers")
1564 .and_then(|p| p.get(&peer_handle))
1565 .and_then(|p| p.get("slot_token"))
1566 .and_then(Value::as_str)
1567 .map(str::to_string)
1568 .unwrap_or_default();
1569 relay_state["peers"][&peer_handle] = json!({
1570 "relay_url": peer_relay,
1571 "slot_id": peer_slot_id,
1572 "slot_token": existing_token,
1573 });
1574 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1575
1576 let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1578 let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1579 let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1580 let pk_b64 = our_card
1581 .get("verify_keys")
1582 .and_then(Value::as_object)
1583 .and_then(|m| m.values().next())
1584 .and_then(|v| v.get("key"))
1585 .and_then(Value::as_str)
1586 .ok_or("our card missing verify_keys[*].key")?;
1587 let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1588 let now = time::OffsetDateTime::now_utc()
1589 .format(&time::format_description::well_known::Rfc3339)
1590 .unwrap_or_default();
1591 let event = json!({
1592 "timestamp": now,
1593 "from": our_did,
1594 "to": peer_did,
1595 "type": "pair_drop",
1596 "kind": 1100u32,
1597 "body": {
1598 "card": our_card,
1599 "relay_url": our_relay,
1600 "slot_id": our_slot_id,
1601 "slot_token": our_slot_token,
1602 },
1603 });
1604 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
1605 .map_err(|e| format!("{e:#}"))?;
1606
1607 let client = crate::relay_client::RelayClient::new(&peer_relay);
1608 let resp = client
1609 .handle_intro(&parsed.nick, &signed)
1610 .map_err(|e| format!("{e:#}"))?;
1611 let event_id = signed
1612 .get("event_id")
1613 .and_then(Value::as_str)
1614 .unwrap_or("")
1615 .to_string();
1616 Ok(json!({
1617 "handle": handle,
1618 "paired_with": peer_did,
1619 "peer_handle": peer_handle,
1620 "event_id": event_id,
1621 "drop_response": resp,
1622 "status": "drop_sent",
1623 }))
1624}
1625
1626fn tool_claim_handle(args: &Value) -> Result<Value, String> {
1627 let nick = args
1628 .get("nick")
1629 .and_then(Value::as_str)
1630 .ok_or("missing 'nick'")?;
1631 let relay_override = args.get("relay_url").and_then(Value::as_str);
1632 let public_url = args.get("public_url").and_then(Value::as_str);
1633
1634 let (_, our_relay, our_slot_id, our_slot_token) =
1636 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1637 let claim_relay = relay_override.unwrap_or(&our_relay);
1638 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1639 let client = crate::relay_client::RelayClient::new(claim_relay);
1640 let resp = client
1641 .handle_claim(nick, &our_slot_id, &our_slot_token, public_url, &card)
1642 .map_err(|e| format!("{e:#}"))?;
1643 Ok(json!({
1644 "nick": nick,
1645 "relay": claim_relay,
1646 "response": resp,
1647 }))
1648}
1649
1650fn tool_whois(args: &Value) -> Result<Value, String> {
1651 if let Some(handle) = args.get("handle").and_then(Value::as_str) {
1652 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1653 let relay_override = args.get("relay_url").and_then(Value::as_str);
1654 crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
1655 } else {
1656 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1658 Ok(json!({
1659 "did": card.get("did").cloned().unwrap_or(Value::Null),
1660 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1661 }))
1662 }
1663}
1664
1665fn tool_profile_set(args: &Value) -> Result<Value, String> {
1666 let field = args
1667 .get("field")
1668 .and_then(Value::as_str)
1669 .ok_or("missing 'field'")?;
1670 let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
1671 let value = if let Some(s) = raw_value.as_str() {
1675 serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
1676 } else {
1677 raw_value
1678 };
1679 let new_profile =
1680 crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
1681 Ok(json!({
1682 "field": field,
1683 "profile": new_profile,
1684 }))
1685}
1686
1687fn tool_profile_get() -> Result<Value, String> {
1688 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1689 Ok(json!({
1690 "did": card.get("did").cloned().unwrap_or(Value::Null),
1691 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1692 }))
1693}
1694
1695fn parse_kind(s: &str) -> u32 {
1698 if let Ok(n) = s.parse::<u32>() {
1699 return n;
1700 }
1701 for (id, name) in crate::signing::kinds() {
1702 if *name == s {
1703 return *id;
1704 }
1705 }
1706 1
1707}
1708
1709fn error_response(id: &Value, code: i32, message: &str) -> Value {
1710 json!({
1711 "jsonrpc": "2.0",
1712 "id": id,
1713 "error": {"code": code, "message": message}
1714 })
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719 use super::*;
1720
1721 #[test]
1722 fn unknown_method_returns_jsonrpc_error() {
1723 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
1724 let resp = handle_request(&req, &McpState::default());
1725 assert_eq!(resp["error"]["code"], -32601);
1726 }
1727
1728 #[test]
1729 fn initialize_advertises_tools_capability() {
1730 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
1731 let resp = handle_request(&req, &McpState::default());
1732 assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
1733 assert!(resp["result"]["capabilities"]["tools"].is_object());
1734 assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
1735 }
1736
1737 #[test]
1738 fn tools_list_includes_pairing_and_messaging() {
1739 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
1740 let resp = handle_request(&req, &McpState::default());
1741 let names: Vec<&str> = resp["result"]["tools"]
1742 .as_array()
1743 .unwrap()
1744 .iter()
1745 .filter_map(|t| t["name"].as_str())
1746 .collect();
1747 for required in [
1748 "wire_whoami",
1749 "wire_peers",
1750 "wire_send",
1751 "wire_tail",
1752 "wire_verify",
1753 "wire_init",
1754 "wire_pair_initiate",
1755 "wire_pair_join",
1756 "wire_pair_check",
1757 "wire_pair_confirm",
1758 ] {
1759 assert!(
1760 names.contains(&required),
1761 "missing required tool {required}"
1762 );
1763 }
1764 assert!(
1768 !names.contains(&"wire_join"),
1769 "wire_join must not be advertised — superseded by wire_pair_join"
1770 );
1771 }
1772
1773 #[test]
1774 fn legacy_wire_join_call_returns_helpful_error() {
1775 let req = json!({
1776 "jsonrpc": "2.0",
1777 "id": 1,
1778 "method": "tools/call",
1779 "params": {"name": "wire_join", "arguments": {}}
1780 });
1781 let resp = handle_request(&req, &McpState::default());
1782 assert_eq!(resp["result"]["isError"], true);
1783 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
1784 assert!(
1785 text.contains("wire_pair_join"),
1786 "expected redirect to wire_pair_join, got: {text}"
1787 );
1788 }
1789
1790 #[test]
1791 fn pair_confirm_missing_session_id_errors_cleanly() {
1792 let req = json!({
1793 "jsonrpc": "2.0",
1794 "id": 1,
1795 "method": "tools/call",
1796 "params": {"name": "wire_pair_confirm", "arguments": {"user_typed_digits": "111111"}}
1797 });
1798 let resp = handle_request(&req, &McpState::default());
1799 assert_eq!(resp["result"]["isError"], true);
1800 }
1801
1802 #[test]
1803 fn pair_confirm_unknown_session_errors_cleanly() {
1804 let req = json!({
1805 "jsonrpc": "2.0",
1806 "id": 1,
1807 "method": "tools/call",
1808 "params": {
1809 "name": "wire_pair_confirm",
1810 "arguments": {"session_id": "definitely-not-real", "user_typed_digits": "111111"}
1811 }
1812 });
1813 let resp = handle_request(&req, &McpState::default());
1814 assert_eq!(resp["result"]["isError"], true);
1815 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
1816 assert!(text.contains("no such session_id"), "got: {text}");
1817 }
1818
1819 #[test]
1820 fn initialize_advertises_resources_capability() {
1821 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
1822 let resp = handle_request(&req, &McpState::default());
1823 let caps = &resp["result"]["capabilities"];
1824 assert!(
1825 caps["resources"].is_object(),
1826 "resources capability must be present, got {resp}"
1827 );
1828 assert_eq!(
1829 caps["resources"]["subscribe"], true,
1830 "subscribe shipped in v0.2.1"
1831 );
1832 }
1833
1834 #[test]
1835 fn resources_read_with_bad_uri_errors() {
1836 let req = json!({
1837 "jsonrpc": "2.0",
1838 "id": 1,
1839 "method": "resources/read",
1840 "params": {"uri": "http://example.com/not-a-wire-uri"}
1841 });
1842 let resp = handle_request(&req, &McpState::default());
1843 assert!(resp.get("error").is_some(), "expected error, got {resp}");
1844 }
1845
1846 #[test]
1847 fn parse_inbox_uri_handles_variants() {
1848 assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
1849 assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
1850 assert!(
1851 parse_inbox_uri("wire://inbox/")
1852 .unwrap()
1853 .starts_with("__invalid__"),
1854 "empty peer must be invalid"
1855 );
1856 assert!(
1857 parse_inbox_uri("http://other")
1858 .unwrap()
1859 .starts_with("__invalid__"),
1860 "non-wire scheme must be invalid"
1861 );
1862 }
1863
1864 #[test]
1865 fn ping_returns_empty_result() {
1866 let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
1867 let resp = handle_request(&req, &McpState::default());
1868 assert_eq!(resp["id"], 7);
1869 assert!(resp["result"].is_object());
1870 }
1871
1872 #[test]
1873 fn notification_returns_null_no_reply() {
1874 let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
1875 let resp = handle_request(&req, &McpState::default());
1876 assert_eq!(resp, Value::Null);
1877 }
1878}