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