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 peer = crate::agent_card::bare_handle(peer);
913 let kind = args
914 .get("kind")
915 .and_then(Value::as_str)
916 .ok_or("missing 'kind'")?;
917 let body = args
918 .get("body")
919 .and_then(Value::as_str)
920 .ok_or("missing 'body'")?;
921 let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
922
923 if !config::is_initialized().map_err(|e| e.to_string())? {
924 return Err("not initialized — operator must run `wire init <handle>` first".into());
925 }
926 let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
927 let card = config::read_agent_card().map_err(|e| e.to_string())?;
928 let did = card
929 .get("did")
930 .and_then(Value::as_str)
931 .unwrap_or("")
932 .to_string();
933 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
934 let pk_b64 = card
935 .get("verify_keys")
936 .and_then(Value::as_object)
937 .and_then(|m| m.values().next())
938 .and_then(|v| v.get("key"))
939 .and_then(Value::as_str)
940 .ok_or("agent-card missing verify_keys[*].key")?;
941 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
942
943 let body_value: Value =
945 serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
946 let kind_id = parse_kind(kind);
947
948 let now = time::OffsetDateTime::now_utc()
949 .format(&time::format_description::well_known::Rfc3339)
950 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
951
952 let mut event = json!({
953 "timestamp": now,
954 "from": did,
955 "to": format!("did:wire:{peer}"),
956 "type": kind,
957 "kind": kind_id,
958 "body": body_value,
959 });
960 if let Some(deadline) = deadline {
961 event["time_sensitive_until"] =
962 json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
963 }
964 let signed =
965 sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
966 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
967
968 let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
969 let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
970
971 Ok(json!({
972 "event_id": event_id,
973 "status": "queued",
974 "peer": peer,
975 "outbox": outbox.to_string_lossy(),
976 }))
977}
978
979fn tool_tail(args: &Value) -> Result<Value, String> {
980 use crate::config;
981 use crate::signing::verify_message_v31;
982
983 let peer_filter = args.get("peer").and_then(Value::as_str);
984 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
985 let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
986 if !inbox.exists() {
987 return Ok(json!([]));
988 }
989 let trust = config::read_trust().map_err(|e| e.to_string())?;
990 let mut events = Vec::new();
991 let entries: Vec<_> = std::fs::read_dir(&inbox)
992 .map_err(|e| e.to_string())?
993 .filter_map(|e| e.ok())
994 .map(|e| e.path())
995 .filter(|p| {
996 p.extension().map(|x| x == "jsonl").unwrap_or(false)
997 && match peer_filter {
998 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
999 None => true,
1000 }
1001 })
1002 .collect();
1003 for path in entries {
1004 let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
1005 for line in body.lines() {
1006 let event: Value = match serde_json::from_str(line) {
1007 Ok(v) => v,
1008 Err(_) => continue,
1009 };
1010 let verified = verify_message_v31(&event, &trust).is_ok();
1011 let mut event_with_meta = event.clone();
1012 if let Some(obj) = event_with_meta.as_object_mut() {
1013 obj.insert("verified".into(), json!(verified));
1014 }
1015 events.push(event_with_meta);
1016 if events.len() >= limit {
1017 return Ok(Value::Array(events));
1018 }
1019 }
1020 }
1021 Ok(Value::Array(events))
1022}
1023
1024fn tool_verify(args: &Value) -> Result<Value, String> {
1025 use crate::config;
1026 use crate::signing::verify_message_v31;
1027
1028 let event_str = args
1029 .get("event")
1030 .and_then(Value::as_str)
1031 .ok_or("missing 'event'")?;
1032 let event: Value =
1033 serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1034 let trust = config::read_trust().map_err(|e| e.to_string())?;
1035 match verify_message_v31(&event, &trust) {
1036 Ok(()) => Ok(json!({"verified": true})),
1037 Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1038 }
1039}
1040
1041fn tool_init(args: &Value) -> Result<Value, String> {
1044 let handle = args
1045 .get("handle")
1046 .and_then(Value::as_str)
1047 .ok_or("missing 'handle'")?;
1048 let name = args.get("name").and_then(Value::as_str);
1049 let relay = args.get("relay_url").and_then(Value::as_str);
1050 crate::pair_session::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1051}
1052
1053fn resolve_relay_url(args: &Value) -> Result<String, String> {
1057 if let Some(url) = args.get("relay_url").and_then(Value::as_str) {
1058 return Ok(url.to_string());
1059 }
1060 let state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1061 state["self"]["relay_url"]
1062 .as_str()
1063 .map(str::to_string)
1064 .ok_or_else(|| "no relay_url provided and no relay bound (call wire_init with relay_url, or pass relay_url here)".into())
1065}
1066
1067fn auto_init_if_needed(args: &Value) -> Result<(), String> {
1073 let initialized = crate::config::is_initialized().map_err(|e| e.to_string())?;
1074 if initialized {
1075 return Ok(());
1076 }
1077 let handle = args.get("handle").and_then(Value::as_str).ok_or(
1078 "not initialized — pass `handle` to auto-init, or call wire_init explicitly first",
1079 )?;
1080 let relay = args.get("relay_url").and_then(Value::as_str);
1081 crate::pair_session::init_self_idempotent(handle, None, relay)
1082 .map(|_| ())
1083 .map_err(|e| e.to_string())
1084}
1085
1086fn tool_pair_initiate(args: &Value) -> Result<Value, String> {
1087 use crate::pair_session::{
1088 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1089 };
1090
1091 store_sweep_expired();
1092 auto_init_if_needed(args)?;
1094
1095 let relay_url = resolve_relay_url(args)?;
1096 let max_wait = args
1097 .get("max_wait_secs")
1098 .and_then(Value::as_u64)
1099 .unwrap_or(30)
1100 .min(60);
1101
1102 let mut s = pair_session_open("host", &relay_url, None).map_err(|e| e.to_string())?;
1103 let code = s.code.clone();
1104
1105 let sas_opt = if max_wait > 0 {
1106 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1107 .map_err(|e| e.to_string())?
1108 } else {
1109 None
1110 };
1111
1112 let session_id = store_insert(s);
1113
1114 let mut out = json!({
1115 "session_id": session_id,
1116 "code_phrase": code,
1117 "relay_url": relay_url,
1118 });
1119 match sas_opt {
1120 Some(sas) => {
1121 out["state"] = json!("sas_ready");
1122 out["sas"] = json!(sas);
1123 out["next"] = json!(
1124 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel (voice/text). \
1125 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1126 );
1127 }
1128 None => {
1129 out["state"] = json!("waiting");
1130 out["next"] = json!(
1131 "Share the code_phrase with the user; ask them to read it to their peer (the peer pastes into wire_pair_join). \
1132 Poll wire_pair_check(session_id) until state='sas_ready'."
1133 );
1134 }
1135 }
1136 Ok(out)
1137}
1138
1139fn tool_pair_join(args: &Value) -> Result<Value, String> {
1140 use crate::pair_session::{
1141 pair_session_open, pair_session_wait_for_sas, store_insert, store_sweep_expired,
1142 };
1143
1144 store_sweep_expired();
1145 auto_init_if_needed(args)?;
1146
1147 let code = args
1148 .get("code_phrase")
1149 .and_then(Value::as_str)
1150 .ok_or("missing 'code_phrase'")?;
1151 let relay_url = resolve_relay_url(args)?;
1152 let max_wait = args
1153 .get("max_wait_secs")
1154 .and_then(Value::as_u64)
1155 .unwrap_or(30)
1156 .min(60);
1157
1158 let mut s = pair_session_open("guest", &relay_url, Some(code)).map_err(|e| e.to_string())?;
1159
1160 let sas_opt =
1161 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1162 .map_err(|e| e.to_string())?;
1163
1164 let session_id = store_insert(s);
1165
1166 let mut out = json!({
1167 "session_id": session_id,
1168 "relay_url": relay_url,
1169 });
1170 match sas_opt {
1171 Some(sas) => {
1172 out["state"] = json!("sas_ready");
1173 out["sas"] = json!(sas);
1174 out["next"] = json!(
1175 "Show this SAS to the user and ask them to compare with their peer's SAS over a side channel. \
1176 Then ask the user to TYPE the 6 digits BACK INTO CHAT — pass that to wire_pair_confirm."
1177 );
1178 }
1179 None => {
1180 out["state"] = json!("waiting");
1181 out["next"] = json!("Poll wire_pair_check(session_id).");
1182 }
1183 }
1184 Ok(out)
1185}
1186
1187fn tool_pair_check(args: &Value) -> Result<Value, String> {
1188 use crate::pair_session::{pair_session_wait_for_sas, store_get, store_sweep_expired};
1189
1190 store_sweep_expired();
1191 let session_id = args
1192 .get("session_id")
1193 .and_then(Value::as_str)
1194 .ok_or("missing 'session_id'")?;
1195 let max_wait = args
1196 .get("max_wait_secs")
1197 .and_then(Value::as_u64)
1198 .unwrap_or(8)
1199 .min(60);
1200
1201 let arc = store_get(session_id)
1202 .ok_or_else(|| format!("no such session_id (expired or never opened): {session_id}"))?;
1203 let mut s = arc.lock().map_err(|e| e.to_string())?;
1204
1205 if s.finalized {
1206 return Ok(json!({
1207 "state": "finalized",
1208 "session_id": session_id,
1209 "sas": s.formatted_sas(),
1210 }));
1211 }
1212 if let Some(reason) = s.aborted.clone() {
1213 return Ok(json!({
1214 "state": "aborted",
1215 "session_id": session_id,
1216 "reason": reason,
1217 }));
1218 }
1219
1220 let sas_opt =
1221 pair_session_wait_for_sas(&mut s, max_wait, std::time::Duration::from_millis(250))
1222 .map_err(|e| e.to_string())?;
1223
1224 Ok(match sas_opt {
1225 Some(sas) => json!({
1226 "state": "sas_ready",
1227 "session_id": session_id,
1228 "sas": sas,
1229 "next": "Have the user TYPE the 6 SAS digits BACK INTO CHAT, then pass to wire_pair_confirm."
1230 }),
1231 None => json!({
1232 "state": "waiting",
1233 "session_id": session_id,
1234 }),
1235 })
1236}
1237
1238fn tool_pair_confirm(args: &Value, state: &McpState) -> Result<Value, String> {
1239 use crate::pair_session::{
1240 pair_session_confirm_sas, pair_session_finalize, store_get, store_remove,
1241 };
1242
1243 let session_id = args
1244 .get("session_id")
1245 .and_then(Value::as_str)
1246 .ok_or("missing 'session_id'")?;
1247 let typed = args
1248 .get("user_typed_digits")
1249 .and_then(Value::as_str)
1250 .ok_or(
1251 "missing 'user_typed_digits' — the user must type the 6 SAS digits back into chat",
1252 )?;
1253
1254 let arc = store_get(session_id).ok_or_else(|| format!("no such session_id: {session_id}"))?;
1255
1256 let confirm_err = {
1257 let mut s = arc.lock().map_err(|e| e.to_string())?;
1258 match pair_session_confirm_sas(&mut s, typed) {
1259 Ok(()) => None,
1260 Err(e) => Some((s.aborted.is_some(), e.to_string())),
1261 }
1262 };
1263 if let Some((aborted, msg)) = confirm_err {
1264 if aborted {
1265 store_remove(session_id);
1266 }
1267 return Err(msg);
1268 }
1269
1270 let mut result = {
1271 let mut s = arc.lock().map_err(|e| e.to_string())?;
1272 pair_session_finalize(&mut s, 30).map_err(|e| e.to_string())?
1273 };
1274 store_remove(session_id);
1275
1276 let peer_handle = result["peer_handle"].as_str().unwrap_or("").to_string();
1285 let peer_uri = format!("wire://inbox/{peer_handle}");
1286
1287 let mut auto = json!({
1288 "subscribed": false,
1289 "daemon": "unknown",
1290 "notify": "unknown",
1291 "resources_list_changed_emitted": false,
1292 });
1293
1294 if !peer_handle.is_empty()
1295 && let Ok(mut g) = state.subscribed.lock()
1296 {
1297 g.insert(peer_uri.clone());
1298 auto["subscribed"] = json!(true);
1299 }
1300
1301 auto["daemon"] = match crate::ensure_up::ensure_daemon_running() {
1302 Ok(true) => json!("spawned"),
1303 Ok(false) => json!("already_running"),
1304 Err(e) => json!(format!("spawn_error: {e}")),
1305 };
1306 auto["notify"] = match crate::ensure_up::ensure_notify_running() {
1307 Ok(true) => json!("spawned"),
1308 Ok(false) => json!("already_running"),
1309 Err(e) => json!(format!("spawn_error: {e}")),
1310 };
1311
1312 if let Some(tx) = state.notif_tx.lock().ok().and_then(|g| g.clone()) {
1313 let notif = json!({
1314 "jsonrpc": "2.0",
1315 "method": "notifications/resources/list_changed",
1316 });
1317 if tx.send(notif.to_string()).is_ok() {
1318 auto["resources_list_changed_emitted"] = json!(true);
1319 }
1320 }
1321
1322 result["auto"] = auto;
1323 result["next"] = json!(
1324 "Done. Daemon + notify running, subscribed to peer inbox. Use wire_send/wire_tail \
1325 freely; new events arrive via notifications/resources/updated (where supported) and \
1326 OS toasts (always)."
1327 );
1328 Ok(result)
1329}
1330
1331fn tool_pair_initiate_detached(args: &Value) -> Result<Value, String> {
1334 auto_init_if_needed(args)?;
1335 let relay_url = resolve_relay_url(args)?;
1336 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1337 let _ = crate::ensure_up::ensure_daemon_running();
1338 }
1339 let code = crate::sas::generate_code_phrase();
1340 let code_hash = crate::pair_session::derive_code_hash(&code);
1341 let now = time::OffsetDateTime::now_utc()
1342 .format(&time::format_description::well_known::Rfc3339)
1343 .unwrap_or_default();
1344 let p = crate::pending_pair::PendingPair {
1345 code: code.clone(),
1346 code_hash,
1347 role: "host".to_string(),
1348 relay_url: relay_url.clone(),
1349 status: "request_host".to_string(),
1350 sas: None,
1351 peer_did: None,
1352 created_at: now,
1353 last_error: None,
1354 pair_id: None,
1355 our_slot_id: None,
1356 our_slot_token: None,
1357 spake2_seed_b64: None,
1358 };
1359 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1360 Ok(json!({
1361 "code_phrase": code,
1362 "relay_url": relay_url,
1363 "state": "queued",
1364 "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."
1365 }))
1366}
1367
1368fn tool_pair_join_detached(args: &Value) -> Result<Value, String> {
1369 auto_init_if_needed(args)?;
1370 let relay_url = resolve_relay_url(args)?;
1371 let code_phrase = args
1372 .get("code_phrase")
1373 .and_then(Value::as_str)
1374 .ok_or("missing 'code_phrase'")?;
1375 let code = crate::sas::parse_code_phrase(code_phrase)
1376 .map_err(|e| e.to_string())?
1377 .to_string();
1378 let code_hash = crate::pair_session::derive_code_hash(&code);
1379 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err() {
1380 let _ = crate::ensure_up::ensure_daemon_running();
1381 }
1382 let now = time::OffsetDateTime::now_utc()
1383 .format(&time::format_description::well_known::Rfc3339)
1384 .unwrap_or_default();
1385 let p = crate::pending_pair::PendingPair {
1386 code: code.clone(),
1387 code_hash,
1388 role: "guest".to_string(),
1389 relay_url: relay_url.clone(),
1390 status: "request_guest".to_string(),
1391 sas: None,
1392 peer_did: None,
1393 created_at: now,
1394 last_error: None,
1395 pair_id: None,
1396 our_slot_id: None,
1397 our_slot_token: None,
1398 spake2_seed_b64: None,
1399 };
1400 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1401 Ok(json!({
1402 "code_phrase": code,
1403 "relay_url": relay_url,
1404 "state": "queued",
1405 "next": "Subscribe to wire://pending-pair/all; on sas_ready notification, surface digits to user and call wire_pair_confirm_detached."
1406 }))
1407}
1408
1409fn tool_pair_list_pending() -> Result<Value, String> {
1410 let items = crate::pending_pair::list_pending().map_err(|e| e.to_string())?;
1411 Ok(json!({"pending": items}))
1412}
1413
1414fn tool_pair_confirm_detached(args: &Value) -> Result<Value, String> {
1415 let code_phrase = args
1416 .get("code_phrase")
1417 .and_then(Value::as_str)
1418 .ok_or("missing 'code_phrase'")?;
1419 let typed = args
1420 .get("user_typed_digits")
1421 .and_then(Value::as_str)
1422 .ok_or("missing 'user_typed_digits'")?;
1423 let code = crate::sas::parse_code_phrase(code_phrase)
1424 .map_err(|e| e.to_string())?
1425 .to_string();
1426 let typed: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
1427 if typed.len() != 6 {
1428 return Err(format!(
1429 "expected 6 digits (got {} after stripping non-digits)",
1430 typed.len()
1431 ));
1432 }
1433 let mut p = crate::pending_pair::read_pending(&code)
1434 .map_err(|e| e.to_string())?
1435 .ok_or_else(|| format!("no pending pair for code {code}"))?;
1436 if p.status != "sas_ready" {
1437 return Err(format!(
1438 "pair {code} not in sas_ready state (current: {})",
1439 p.status
1440 ));
1441 }
1442 let stored = p
1443 .sas
1444 .as_ref()
1445 .ok_or("pending file has status=sas_ready but no sas field")?
1446 .clone();
1447 if stored == typed {
1448 p.status = "confirmed".to_string();
1449 crate::pending_pair::write_pending(&p).map_err(|e| e.to_string())?;
1450 Ok(json!({
1451 "state": "confirmed",
1452 "code_phrase": code,
1453 "next": "Daemon will finalize on its next tick (~1s). Poll wire_peers or watch wire://pending-pair/all for the entry to disappear."
1454 }))
1455 } else {
1456 p.status = "aborted".to_string();
1457 p.last_error = Some(format!(
1458 "SAS digit mismatch (typed {typed}, expected {stored})"
1459 ));
1460 let client = crate::relay_client::RelayClient::new(&p.relay_url);
1461 let _ = client.pair_abandon(&p.code_hash);
1462 let _ = crate::pending_pair::write_pending(&p);
1463 crate::os_notify::toast(
1464 &format!("wire — pair aborted ({code})"),
1465 p.last_error.as_deref().unwrap_or("digits mismatch"),
1466 );
1467 Err(
1468 "digits mismatch — pair aborted. Re-issue with wire_pair_initiate_detached."
1469 .to_string(),
1470 )
1471 }
1472}
1473
1474fn tool_pair_cancel_pending(args: &Value) -> Result<Value, String> {
1475 let code_phrase = args
1476 .get("code_phrase")
1477 .and_then(Value::as_str)
1478 .ok_or("missing 'code_phrase'")?;
1479 let code = crate::sas::parse_code_phrase(code_phrase)
1480 .map_err(|e| e.to_string())?
1481 .to_string();
1482 if let Some(p) = crate::pending_pair::read_pending(&code).map_err(|e| e.to_string())? {
1483 let client = crate::relay_client::RelayClient::new(&p.relay_url);
1484 let _ = client.pair_abandon(&p.code_hash);
1485 }
1486 crate::pending_pair::delete_pending(&code).map_err(|e| e.to_string())?;
1487 Ok(json!({"state": "cancelled", "code_phrase": code}))
1488}
1489
1490fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1493 let relay_url = args.get("relay_url").and_then(Value::as_str);
1494 let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1495 let uses = args
1496 .get("uses")
1497 .and_then(Value::as_u64)
1498 .map(|u| u as u32)
1499 .unwrap_or(1);
1500 let url =
1501 crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1502 let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1503 Ok(json!({
1504 "invite_url": url,
1505 "ttl_secs": ttl_resolved,
1506 "uses": uses,
1507 }))
1508}
1509
1510fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1511 let url = args
1512 .get("url")
1513 .and_then(Value::as_str)
1514 .ok_or("missing 'url'")?;
1515 crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1516}
1517
1518fn tool_add(args: &Value) -> Result<Value, String> {
1521 let handle = args
1522 .get("handle")
1523 .and_then(Value::as_str)
1524 .ok_or("missing 'handle'")?;
1525 let relay_override = args.get("relay_url").and_then(Value::as_str);
1526
1527 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1528
1529 let (our_did, our_relay, our_slot_id, our_slot_token) =
1531 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1532
1533 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1535 .map_err(|e| format!("{e:#}"))?;
1536 let peer_card = resolved
1537 .get("card")
1538 .cloned()
1539 .ok_or("resolved missing card")?;
1540 let peer_did = resolved
1541 .get("did")
1542 .and_then(Value::as_str)
1543 .ok_or("resolved missing did")?
1544 .to_string();
1545 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1546 let peer_slot_id = resolved
1547 .get("slot_id")
1548 .and_then(Value::as_str)
1549 .ok_or("resolved missing slot_id")?
1550 .to_string();
1551 let peer_relay = resolved
1552 .get("relay_url")
1553 .and_then(Value::as_str)
1554 .map(str::to_string)
1555 .or_else(|| relay_override.map(str::to_string))
1556 .unwrap_or_else(|| format!("https://{}", parsed.domain));
1557
1558 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1560 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1561 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1562 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1563 let existing_token = relay_state
1564 .get("peers")
1565 .and_then(|p| p.get(&peer_handle))
1566 .and_then(|p| p.get("slot_token"))
1567 .and_then(Value::as_str)
1568 .map(str::to_string)
1569 .unwrap_or_default();
1570 relay_state["peers"][&peer_handle] = json!({
1571 "relay_url": peer_relay,
1572 "slot_id": peer_slot_id,
1573 "slot_token": existing_token,
1574 });
1575 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1576
1577 let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1579 let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1580 let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1581 let pk_b64 = our_card
1582 .get("verify_keys")
1583 .and_then(Value::as_object)
1584 .and_then(|m| m.values().next())
1585 .and_then(|v| v.get("key"))
1586 .and_then(Value::as_str)
1587 .ok_or("our card missing verify_keys[*].key")?;
1588 let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1589 let now = time::OffsetDateTime::now_utc()
1590 .format(&time::format_description::well_known::Rfc3339)
1591 .unwrap_or_default();
1592 let event = json!({
1593 "timestamp": now,
1594 "from": our_did,
1595 "to": peer_did,
1596 "type": "pair_drop",
1597 "kind": 1100u32,
1598 "body": {
1599 "card": our_card,
1600 "relay_url": our_relay,
1601 "slot_id": our_slot_id,
1602 "slot_token": our_slot_token,
1603 },
1604 });
1605 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
1606 .map_err(|e| format!("{e:#}"))?;
1607
1608 let client = crate::relay_client::RelayClient::new(&peer_relay);
1609 let resp = client
1610 .handle_intro(&parsed.nick, &signed)
1611 .map_err(|e| format!("{e:#}"))?;
1612 let event_id = signed
1613 .get("event_id")
1614 .and_then(Value::as_str)
1615 .unwrap_or("")
1616 .to_string();
1617 Ok(json!({
1618 "handle": handle,
1619 "paired_with": peer_did,
1620 "peer_handle": peer_handle,
1621 "event_id": event_id,
1622 "drop_response": resp,
1623 "status": "drop_sent",
1624 }))
1625}
1626
1627fn tool_claim_handle(args: &Value) -> Result<Value, String> {
1628 let nick = args
1629 .get("nick")
1630 .and_then(Value::as_str)
1631 .ok_or("missing 'nick'")?;
1632 let relay_override = args.get("relay_url").and_then(Value::as_str);
1633 let public_url = args.get("public_url").and_then(Value::as_str);
1634
1635 let (_, our_relay, our_slot_id, our_slot_token) =
1637 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1638 let claim_relay = relay_override.unwrap_or(&our_relay);
1639 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1640 let client = crate::relay_client::RelayClient::new(claim_relay);
1641 let resp = client
1642 .handle_claim(nick, &our_slot_id, &our_slot_token, public_url, &card)
1643 .map_err(|e| format!("{e:#}"))?;
1644 Ok(json!({
1645 "nick": nick,
1646 "relay": claim_relay,
1647 "response": resp,
1648 }))
1649}
1650
1651fn tool_whois(args: &Value) -> Result<Value, String> {
1652 if let Some(handle) = args.get("handle").and_then(Value::as_str) {
1653 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1654 let relay_override = args.get("relay_url").and_then(Value::as_str);
1655 crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
1656 } else {
1657 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1659 Ok(json!({
1660 "did": card.get("did").cloned().unwrap_or(Value::Null),
1661 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1662 }))
1663 }
1664}
1665
1666fn tool_profile_set(args: &Value) -> Result<Value, String> {
1667 let field = args
1668 .get("field")
1669 .and_then(Value::as_str)
1670 .ok_or("missing 'field'")?;
1671 let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
1672 let value = if let Some(s) = raw_value.as_str() {
1676 serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
1677 } else {
1678 raw_value
1679 };
1680 let new_profile =
1681 crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
1682 Ok(json!({
1683 "field": field,
1684 "profile": new_profile,
1685 }))
1686}
1687
1688fn tool_profile_get() -> Result<Value, String> {
1689 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1690 Ok(json!({
1691 "did": card.get("did").cloned().unwrap_or(Value::Null),
1692 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1693 }))
1694}
1695
1696fn parse_kind(s: &str) -> u32 {
1699 if let Ok(n) = s.parse::<u32>() {
1700 return n;
1701 }
1702 for (id, name) in crate::signing::kinds() {
1703 if *name == s {
1704 return *id;
1705 }
1706 }
1707 1
1708}
1709
1710fn error_response(id: &Value, code: i32, message: &str) -> Value {
1711 json!({
1712 "jsonrpc": "2.0",
1713 "id": id,
1714 "error": {"code": code, "message": message}
1715 })
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720 use super::*;
1721
1722 #[test]
1723 fn unknown_method_returns_jsonrpc_error() {
1724 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
1725 let resp = handle_request(&req, &McpState::default());
1726 assert_eq!(resp["error"]["code"], -32601);
1727 }
1728
1729 #[test]
1730 fn initialize_advertises_tools_capability() {
1731 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
1732 let resp = handle_request(&req, &McpState::default());
1733 assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
1734 assert!(resp["result"]["capabilities"]["tools"].is_object());
1735 assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
1736 }
1737
1738 #[test]
1739 fn tools_list_includes_pairing_and_messaging() {
1740 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
1741 let resp = handle_request(&req, &McpState::default());
1742 let names: Vec<&str> = resp["result"]["tools"]
1743 .as_array()
1744 .unwrap()
1745 .iter()
1746 .filter_map(|t| t["name"].as_str())
1747 .collect();
1748 for required in [
1749 "wire_whoami",
1750 "wire_peers",
1751 "wire_send",
1752 "wire_tail",
1753 "wire_verify",
1754 "wire_init",
1755 "wire_pair_initiate",
1756 "wire_pair_join",
1757 "wire_pair_check",
1758 "wire_pair_confirm",
1759 ] {
1760 assert!(
1761 names.contains(&required),
1762 "missing required tool {required}"
1763 );
1764 }
1765 assert!(
1769 !names.contains(&"wire_join"),
1770 "wire_join must not be advertised — superseded by wire_pair_join"
1771 );
1772 }
1773
1774 #[test]
1775 fn legacy_wire_join_call_returns_helpful_error() {
1776 let req = json!({
1777 "jsonrpc": "2.0",
1778 "id": 1,
1779 "method": "tools/call",
1780 "params": {"name": "wire_join", "arguments": {}}
1781 });
1782 let resp = handle_request(&req, &McpState::default());
1783 assert_eq!(resp["result"]["isError"], true);
1784 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
1785 assert!(
1786 text.contains("wire_pair_join"),
1787 "expected redirect to wire_pair_join, got: {text}"
1788 );
1789 }
1790
1791 #[test]
1792 fn pair_confirm_missing_session_id_errors_cleanly() {
1793 let req = json!({
1794 "jsonrpc": "2.0",
1795 "id": 1,
1796 "method": "tools/call",
1797 "params": {"name": "wire_pair_confirm", "arguments": {"user_typed_digits": "111111"}}
1798 });
1799 let resp = handle_request(&req, &McpState::default());
1800 assert_eq!(resp["result"]["isError"], true);
1801 }
1802
1803 #[test]
1804 fn pair_confirm_unknown_session_errors_cleanly() {
1805 let req = json!({
1806 "jsonrpc": "2.0",
1807 "id": 1,
1808 "method": "tools/call",
1809 "params": {
1810 "name": "wire_pair_confirm",
1811 "arguments": {"session_id": "definitely-not-real", "user_typed_digits": "111111"}
1812 }
1813 });
1814 let resp = handle_request(&req, &McpState::default());
1815 assert_eq!(resp["result"]["isError"], true);
1816 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
1817 assert!(text.contains("no such session_id"), "got: {text}");
1818 }
1819
1820 #[test]
1821 fn initialize_advertises_resources_capability() {
1822 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
1823 let resp = handle_request(&req, &McpState::default());
1824 let caps = &resp["result"]["capabilities"];
1825 assert!(
1826 caps["resources"].is_object(),
1827 "resources capability must be present, got {resp}"
1828 );
1829 assert_eq!(
1830 caps["resources"]["subscribe"], true,
1831 "subscribe shipped in v0.2.1"
1832 );
1833 }
1834
1835 #[test]
1836 fn resources_read_with_bad_uri_errors() {
1837 let req = json!({
1838 "jsonrpc": "2.0",
1839 "id": 1,
1840 "method": "resources/read",
1841 "params": {"uri": "http://example.com/not-a-wire-uri"}
1842 });
1843 let resp = handle_request(&req, &McpState::default());
1844 assert!(resp.get("error").is_some(), "expected error, got {resp}");
1845 }
1846
1847 #[test]
1848 fn parse_inbox_uri_handles_variants() {
1849 assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
1850 assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
1851 assert!(
1852 parse_inbox_uri("wire://inbox/")
1853 .unwrap()
1854 .starts_with("__invalid__"),
1855 "empty peer must be invalid"
1856 );
1857 assert!(
1858 parse_inbox_uri("http://other")
1859 .unwrap()
1860 .starts_with("__invalid__"),
1861 "non-wire scheme must be invalid"
1862 );
1863 }
1864
1865 #[test]
1866 fn ping_returns_empty_result() {
1867 let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
1868 let resp = handle_request(&req, &McpState::default());
1869 assert_eq!(resp["id"], 7);
1870 assert!(resp["result"].is_object());
1871 }
1872
1873 #[test]
1874 fn notification_returns_null_no_reply() {
1875 let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
1876 let resp = handle_request(&req, &McpState::default());
1877 assert_eq!(resp, Value::Null);
1878 }
1879}