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