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