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