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