1use anyhow::Result;
30use serde_json::{Value, json};
31use std::collections::HashSet;
32use std::io::{BufRead, BufReader, Write};
33use std::sync::{Arc, Mutex};
34
35#[derive(Clone, Default)]
39pub struct McpState {
40 pub subscribed: Arc<Mutex<HashSet<String>>>,
44 pub notif_tx: Arc<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
48}
49
50const PROTOCOL_VERSION: &str = "2025-06-18";
51const SERVER_NAME: &str = "wire";
52const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
53
54pub fn run() -> Result<()> {
75 use std::sync::atomic::{AtomicBool, Ordering};
76 use std::sync::mpsc;
77 use std::time::{Duration, Instant};
78
79 crate::session::maybe_adopt_session_wire_home("mcp");
92
93 crate::cli::maybe_auto_init_cwd_session("mcp");
99
100 ensure_session_bootstrapped();
107
108 crate::session::warn_on_identity_collision(std::process::id(), "mcp");
116
117 let state = McpState::default();
118 let shutdown = Arc::new(AtomicBool::new(false));
119
120 let (tx, rx) = mpsc::channel::<String>();
121
122 if let Ok(mut g) = state.notif_tx.lock() {
125 *g = Some(tx.clone());
126 }
127
128 let writer_handle = std::thread::spawn(move || {
130 let stdout = std::io::stdout();
131 let mut w = stdout.lock();
132 while let Ok(line) = rx.recv() {
133 if writeln!(w, "{line}").is_err() {
134 break;
135 }
136 if w.flush().is_err() {
137 break;
138 }
139 }
140 });
141
142 let subs_w = state.subscribed.clone();
147 let tx_w = tx.clone();
148 let shutdown_w = shutdown.clone();
149 let watcher_handle = std::thread::spawn(move || {
150 let mut watcher = match crate::inbox_watch::InboxWatcher::from_head() {
151 Ok(w) => w,
152 Err(_) => return,
153 };
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 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![json!({
276 "uri": "wire://inbox/all",
277 "name": "wire inbox (all peers)",
278 "description": "Most recent verified events from all pinned peers, JSONL.",
279 "mimeType": "application/x-ndjson"
280 })];
281
282 if let Ok(trust) = crate::config::read_trust() {
283 let agents = trust
284 .get("agents")
285 .and_then(Value::as_object)
286 .cloned()
287 .unwrap_or_default();
288 let self_did = crate::config::read_agent_card()
289 .ok()
290 .and_then(|c| c.get("did").and_then(Value::as_str).map(str::to_string));
291 for (handle, agent) in agents.iter() {
292 let did = agent
293 .get("did")
294 .and_then(Value::as_str)
295 .unwrap_or("")
296 .to_string();
297 if Some(did.as_str()) == self_did.as_deref() {
298 continue;
299 }
300 resources.push(json!({
301 "uri": format!("wire://inbox/{handle}"),
302 "name": format!("inbox from {handle}"),
303 "description": format!("Recent verified events from did:wire:{handle}."),
304 "mimeType": "application/x-ndjson"
305 }));
306 }
307 }
308
309 json!({
310 "jsonrpc": "2.0",
311 "id": id,
312 "result": {
313 "resources": resources
314 }
315 })
316}
317
318fn handle_resources_subscribe(id: &Value, params: &Value, state: &McpState) -> Value {
319 let uri = match params.get("uri").and_then(Value::as_str) {
320 Some(u) => u.to_string(),
321 None => return error_response(id, -32602, "missing 'uri'"),
322 };
323 let inbox_peer = parse_inbox_uri(&uri);
326 if let Some(ref p) = inbox_peer
327 && p.starts_with("__invalid__")
328 {
329 return error_response(
330 id,
331 -32602,
332 "subscribe URI must be wire://inbox/<peer> or wire://inbox/all",
333 );
334 }
335 if let Ok(mut g) = state.subscribed.lock() {
336 g.insert(uri);
337 }
338 json!({"jsonrpc": "2.0", "id": id, "result": {}})
339}
340
341fn handle_resources_unsubscribe(id: &Value, params: &Value, state: &McpState) -> Value {
342 let uri = match params.get("uri").and_then(Value::as_str) {
343 Some(u) => u.to_string(),
344 None => return error_response(id, -32602, "missing 'uri'"),
345 };
346 if let Ok(mut g) = state.subscribed.lock() {
347 g.remove(&uri);
348 }
349 json!({"jsonrpc": "2.0", "id": id, "result": {}})
350}
351
352fn handle_resources_read(id: &Value, params: &Value) -> Value {
353 let uri = match params.get("uri").and_then(Value::as_str) {
354 Some(u) => u,
355 None => return error_response(id, -32602, "missing 'uri'"),
356 };
357 let peer_opt = parse_inbox_uri(uri);
358 match read_inbox_resource(peer_opt) {
359 Ok(payload) => json!({
360 "jsonrpc": "2.0",
361 "id": id,
362 "result": {
363 "contents": [{
364 "uri": uri,
365 "mimeType": "application/x-ndjson",
366 "text": payload,
367 }]
368 }
369 }),
370 Err(e) => error_response(id, -32603, &e.to_string()),
371 }
372}
373
374fn parse_inbox_uri(uri: &str) -> Option<String> {
377 if let Some(rest) = uri.strip_prefix("wire://inbox/") {
378 if rest == "all" {
379 return None;
380 }
381 if !rest.is_empty() {
382 return Some(rest.to_string());
383 }
384 }
385 Some(format!("__invalid__{uri}"))
386}
387
388fn read_inbox_resource(peer_opt: Option<String>) -> Result<String, String> {
389 const LIMIT: usize = 50;
390 if let Some(ref p) = peer_opt
393 && p.starts_with("__invalid__")
394 {
395 return Err(
396 "unknown resource URI (must be wire://inbox/<peer> or wire://inbox/all)".into(),
397 );
398 }
399 let inbox = crate::config::inbox_dir().map_err(|e| e.to_string())?;
400 if !inbox.exists() {
401 return Ok(String::new());
402 }
403 let trust = crate::config::read_trust().map_err(|e| e.to_string())?;
404
405 let paths: Vec<std::path::PathBuf> = match peer_opt {
406 Some(p) => {
407 let path = inbox.join(format!("{p}.jsonl"));
408 if !path.exists() {
409 return Ok(String::new());
410 }
411 vec![path]
412 }
413 None => std::fs::read_dir(&inbox)
414 .map_err(|e| e.to_string())?
415 .flatten()
416 .map(|e| e.path())
417 .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
418 .collect(),
419 };
420
421 let mut events: Vec<(String, bool, Value)> = Vec::new();
422 for path in paths {
423 let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
424 let peer = path
425 .file_stem()
426 .and_then(|s| s.to_str())
427 .unwrap_or("")
428 .to_string();
429 for line in body.lines() {
430 let event: Value = match serde_json::from_str(line) {
431 Ok(v) => v,
432 Err(_) => continue,
433 };
434 let verified = crate::signing::verify_message_v31(&event, &trust).is_ok();
435 events.push((peer.clone(), verified, event));
436 }
437 }
438 let take_from = events.len().saturating_sub(LIMIT);
440 let tail = &events[take_from..];
441
442 let mut out = String::new();
443 for (_peer, verified, mut event) in tail.iter().cloned() {
444 if let Some(obj) = event.as_object_mut() {
445 obj.insert("verified".into(), json!(verified));
446 }
447 out.push_str(&serde_json::to_string(&event).map_err(|e| e.to_string())?);
448 out.push('\n');
449 }
450 Ok(out)
451}
452
453fn handle_initialize(id: &Value) -> Value {
454 json!({
455 "jsonrpc": "2.0",
456 "id": id,
457 "result": {
458 "protocolVersion": PROTOCOL_VERSION,
459 "capabilities": {
460 "tools": {"listChanged": false},
461 "resources": {
462 "listChanged": false,
463 "subscribe": true
468 }
469 },
470 "serverInfo": {
471 "name": SERVER_NAME,
472 "version": SERVER_VERSION,
473 },
474 "instructions": "wire — agent-to-agent comms. Canonical verbs (v0.9+): wire_dial (initiate pair), wire_send (talk; auto-pairs on miss), wire_pending (see inbound requests), wire_accept (consent to a pending pair), wire_reject (refuse), wire_whois (inspect identity), wire_status (daemon + sync health). For cross-machine pairing pass `<handle>@<relay>` to wire_dial. Bilateral: the peer must also accept (wire_accept or wire_dial back) before capability flows in both directions. INBOUND pair requests from strangers land in pending-inbound: call wire_pending to enumerate, surface to operator, then wire_accept or wire_reject. NEVER auto-accept inbound pair requests without operator consent — accepting grants the peer authenticated write access to this agent's inbox. Resources: 'wire://inbox/<peer>' exposes each pinned peer's verified inbox (JSONL). ON SESSION START, DO THIS IMMEDIATELY (every wire session): (1) call wire_status to confirm the `wire daemon` sync loop is running — `daemon_running:true` + `last_sync_age_seconds < 60`; if not, the session won't push outbound or pull inbound and the operator must start a daemon (`wire daemon --interval 5` in a background shell, or systemd/launchd). (2) arm a PERSISTENT stream-watcher on `wire monitor --json` — in Claude Code, the Monitor tool with persistent:true — so peer messages surface live mid-session. The monitor does NOT sync the relay; it only tails the inbox the daemon writes. Both are required. WHEN A PEER MESSAGE ARRIVES, reply to it in your own live context WITHOUT waiting for the operator to prompt you; this is how paired agents collaborate immediately. (Default filter strips pair_drop/pair_drop_ack/heartbeat noise.) v0.14.2: wire_send POSTs synchronously by default — response `status` is the actual relay verdict: `delivered` (event landed on peer's slot), `duplicate` (same event_id already on slot; peer can still pull), `peer_unknown` (peer not pinned — run wire_dial first), `slot_stale` (peer's slot rotated — run wire_dial to re-pair), or `transport_error` (TLS/DNS/relay-5xx; check `reason` field). Pass `queue:true` to opt back into the legacy outbox→daemon-push path for offline-buffer / pre-pair queueing. wire_pull is the symmetric receive primitive — call it to trigger an immediate relay GET instead of waiting for the daemon's 5s pull cycle; returns written[]/rejected[]/total_seen the same way `wire pull --json` does. See docs/AGENT_INTEGRATION.md for the full monitor recipe and THREAT_MODEL.md (T10/T14)."
475 }
476 })
477}
478
479fn handle_tools_list(id: &Value) -> Value {
480 json!({
481 "jsonrpc": "2.0",
482 "id": id,
483 "result": {
484 "tools": tool_defs(),
485 }
486 })
487}
488
489fn tool_defs() -> Vec<Value> {
490 vec![
491 json!({
492 "name": "wire_whoami",
493 "description": "Return this agent's DID, fingerprint, key_id, public key, and capabilities. Read-only.",
494 "inputSchema": {"type": "object", "properties": {}, "required": []}
495 }),
496 json!({
497 "name": "wire_peers",
498 "description": "List pinned peers with their tier (UNTRUSTED/VERIFIED/ATTESTED) and advertised capabilities. Read-only.",
499 "inputSchema": {"type": "object", "properties": {}, "required": []}
500 }),
501 json!({
502 "name": "wire_status",
503 "description": "v0.14.2 — daemon + sync-loop health check. Returns: daemon_running (pidfile pid alive), all_running_pids (pgrep for `wire daemon`), last_sync_age_seconds (age of the most recent successful daemon cycle; null if no cycle ever recorded), outbox_count, inbox_count, peer count. **Call this BEFORE assuming wire_send actually delivered** — `wire_send` returns `status:\"queued\"` even if no daemon is running to push the queued event. A nonzero `outbox_count` with no recent `last_sync` means events are queued into the void. Read-only.",
504 "inputSchema": {"type": "object", "properties": {}, "required": []}
505 }),
506 json!({
507 "name": "wire_send",
508 "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.",
509 "inputSchema": {
510 "type": "object",
511 "properties": {
512 "peer": {"type": "string", "description": "Peer handle (without did:wire: prefix). Must be a pinned peer; check wire_peers first."},
513 "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."},
514 "body": {"type": "string", "description": "Event body. Plain text becomes a JSON string; valid JSON is parsed and embedded structurally."},
515 "time_sensitive_until": {"type": "string", "description": "Optional advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp."}
516 },
517 "required": ["peer", "kind", "body"]
518 }
519 }),
520 json!({
521 "name": "wire_pull",
522 "description": "v0.14.2: trigger an immediate, synchronous pull from this agent's relay slot(s). Returns the same shape as `wire pull --json`: written[] (events landed in inbox), rejected[] (failed signature / cursor verify / dedupe), total_seen, cursor_blocked, endpoints_pulled. **Use this when you want events NOW** instead of waiting for the daemon's 5s pull cycle. Symmetric to wire_send's sync POST. Read-only — only consults the relay's GET, no mutations beyond writing inbox.jsonl + advancing per-slot cursors. Idempotent: re-pulling with the same cursor returns nothing new.",
523 "inputSchema": {"type": "object", "properties": {}, "required": []}
524 }),
525 json!({
526 "name": "wire_tail",
527 "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. **Orientation (wire #79):** defaults to NEWEST-N (last `limit` events across all matched peers, sorted chronologically by timestamp). Pass `oldest: true` for FIFO behaviour (first-N, for inbox replay from the start).",
528 "inputSchema": {
529 "type": "object",
530 "properties": {
531 "peer": {"type": "string", "description": "Optional peer handle to filter inbox by."},
532 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Max events to return."},
533 "oldest": {"type": "boolean", "default": false, "description": "Return the FIRST `limit` events (oldest-N) instead of the default last-N (newest-N)."}
534 },
535 "required": []
536 }
537 }),
538 json!({
539 "name": "wire_verify",
540 "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).",
541 "inputSchema": {
542 "type": "object",
543 "properties": {
544 "event": {"type": "string", "description": "JSON-encoded signed event."}
545 },
546 "required": ["event"]
547 }
548 }),
549 json!({
550 "name": "wire_init",
551 "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.",
552 "inputSchema": {
553 "type": "object",
554 "properties": {
555 "handle": {"type": "string", "description": "Short handle (becomes did:wire:<handle>). ASCII alphanumeric / '-' / '_' only."},
556 "name": {"type": "string", "description": "Optional display name (defaults to capitalized handle)."},
557 "relay_url": {"type": "string", "description": "Optional relay URL — if set, also binds a relay slot."}
558 },
559 "required": ["handle"]
560 }
561 }),
562 json!({
563 "name": "wire_invite_mint",
564 "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}.",
565 "inputSchema": {
566 "type": "object",
567 "properties": {
568 "relay_url": {"type": "string", "description": "Override relay for first-time auto-allocate."},
569 "ttl_secs": {"type": "integer", "description": "Invite lifetime in seconds (default 86400)."},
570 "uses": {"type": "integer", "description": "Number of distinct peers that can accept before consumption (default 1)."}
571 }
572 }
573 }),
574 json!({
575 "name": "wire_invite_accept",
576 "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}.",
577 "inputSchema": {
578 "type": "object",
579 "properties": {
580 "url": {"type": "string", "description": "Full wire://pair?v=1&inv=... URL."}
581 },
582 "required": ["url"]
583 }
584 }),
585 json!({
587 "name": "wire_add",
588 "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 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_accept` or `wire_reject` instead.",
589 "inputSchema": {
590 "type": "object",
591 "properties": {
592 "handle": {"type": "string", "description": "Peer handle like `nick@domain`."},
593 "relay_url": {"type": "string", "description": "Override resolver URL (default: `https://<domain>`)."}
594 },
595 "required": ["handle"]
596 }
597 }),
598 json!({
604 "name": "wire_dial",
605 "description": "v0.8 — go talk to this name. Accepts a character nickname (`noble-slate`), session name, card handle, or DID — or a federation handle (`<handle>@<relay>`). Resolves through the local addressing layer (pinned peers, local sister sessions) or routes federation via `.well-known/wire/agent`. Drives the right pair flow (already-pinned: no-op, local sister: disk-read --local-sister, federation: pair_drop). After this completes the peer is in `wire_peers` and `wire_send` to them works.",
606 "inputSchema": {
607 "type": "object",
608 "properties": {
609 "name": {"type": "string", "description": "Peer name — character nickname / session / handle / DID / `<handle>@<relay>`."}
610 },
611 "required": ["name"]
612 }
613 }),
614 json!({
615 "name": "wire_accept",
616 "description": "v0.9 — accept a pending-inbound pair request by character nickname or handle. Replaces deprecated wire_pair_accept. Pins the peer VERIFIED, ships our slot_token via pair_drop_ack, and deletes the pending record. Requires explicit operator consent — surface the request to the user before calling.",
617 "inputSchema": {
618 "type": "object",
619 "properties": {
620 "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle, from wire_pending)."}
621 },
622 "required": ["peer"]
623 }
624 }),
625 json!({
626 "name": "wire_reject",
627 "description": "v0.9 — refuse a pending-inbound pair request without pairing. Replaces deprecated wire_pair_reject. Idempotent: succeeds with `rejected: false` if no record existed for that peer.",
628 "inputSchema": {
629 "type": "object",
630 "properties": {
631 "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle)."}
632 },
633 "required": ["peer"]
634 }
635 }),
636 json!({
637 "name": "wire_pending",
638 "description": "v0.9 — list pending-inbound pair requests waiting for operator consent. Returns the same flat array as legacy wire_pair_list_inbound. Use on session start (or in response to a `wire — pair request from X` OS toast) to surface inbound requests for accept/reject decisions.",
639 "inputSchema": {"type": "object", "properties": {}}
640 }),
641 json!({
642 "name": "wire_claim",
643 "description": "Publish this agent in a relay's handle directory so others can reach it by `<persona>@<relay-domain>`. ONE-NAME RULE: the claimed handle is ALWAYS your DID-derived persona — you do not choose it. The `nick` arg is optional + advisory; a value that differs from your persona is ignored (response sets typed_nick_ignored=true). Auto-inits + auto-allocates a relay slot if needed. FCFS — same-DID re-claims allowed (used for profile/slot updates).",
644 "inputSchema": {
645 "type": "object",
646 "properties": {
647 "nick": {"type": "string", "description": "Optional + advisory. Ignored if it differs from your DID-derived persona (one-name rule)."},
648 "relay_url": {"type": "string", "description": "Relay to claim on. Default = our relay."},
649 "public_url": {"type": "string", "description": "Public URL the relay should advertise to resolvers."}
650 }
651 }
652 }),
653 json!({
654 "name": "wire_whois",
655 "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.",
656 "inputSchema": {
657 "type": "object",
658 "properties": {
659 "handle": {"type": "string", "description": "Optional `nick@domain`. Omit for self."},
660 "relay_url": {"type": "string", "description": "Override resolver URL."}
661 }
662 }
663 }),
664 json!({
665 "name": "wire_profile_set",
666 "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.",
667 "inputSchema": {
668 "type": "object",
669 "properties": {
670 "field": {"type": "string", "description": "One of: display_name, emoji, motto, vibe, pronouns, avatar_url, handle, now."},
671 "value": {"description": "String for most fields; array for vibe; object for now. Pass JSON null to clear a field."}
672 },
673 "required": ["field", "value"]
674 }
675 }),
676 json!({
677 "name": "wire_profile_get",
678 "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.",
679 "inputSchema": {"type": "object", "properties": {}}
680 }),
681 json!({
686 "name": "wire_group_create",
687 "description": "Create a group chat room (you become the creator). Allocates a shared relay slot whose token is the room key, signs the initial roster, and persists it locally. Returns {id, name, members, relay_url}. Use the returned id with the other wire_group_* tools.",
688 "inputSchema": {
689 "type": "object",
690 "properties": {"name": {"type": "string", "description": "Human label for the group."}},
691 "required": ["name"]
692 }
693 }),
694 json!({
695 "name": "wire_group_add",
696 "description": "Add a bilaterally-VERIFIED pinned peer to a group you created, as a Member. The peer must already be paired + VERIFIED (check wire_peers). Re-signs the roster and queues a signed group_invite to every member (run a normal push/let the daemon deliver). Creator-only.",
697 "inputSchema": {
698 "type": "object",
699 "properties": {
700 "group": {"type": "string", "description": "Group id or name."},
701 "peer": {"type": "string", "description": "Handle of a VERIFIED pinned peer."}
702 },
703 "required": ["group", "peer"]
704 }
705 }),
706 json!({
707 "name": "wire_group_send",
708 "description": "Post a message to a group room (one signed event to the shared slot; every member reads it). You must have the group locally (created it, were added, or joined by code).",
709 "inputSchema": {
710 "type": "object",
711 "properties": {
712 "group": {"type": "string", "description": "Group id or name."},
713 "message": {"type": "string", "description": "Message text."}
714 },
715 "required": ["group", "message"]
716 }
717 }),
718 json!({
719 "name": "wire_group_tail",
720 "description": "Read recent messages from a group room. Each message has a 'verified' bool (signature checked against the roster + room-announced joiner keys). Also surfaces join notices. Pulls the shared room slot.",
721 "inputSchema": {
722 "type": "object",
723 "properties": {
724 "group": {"type": "string", "description": "Group id or name."},
725 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 20, "description": "Max timeline entries to return."}
726 },
727 "required": ["group"]
728 }
729 }),
730 json!({
731 "name": "wire_group_list",
732 "description": "List the groups this agent is in, with each group's members and their GroupTiers (creator/member/introduced). Read-only, local.",
733 "inputSchema": {"type": "object", "properties": {}, "required": []}
734 }),
735 json!({
736 "name": "wire_group_invite",
737 "description": "Mint a shareable join code for a group — a self-contained token (room coords + signed roster). Anyone you give it to can wire_group_join to enter at Introduced tier. The code IS the room key; share only with people you want in the room.",
738 "inputSchema": {
739 "type": "object",
740 "properties": {"group": {"type": "string", "description": "Group id or name."}},
741 "required": ["group"]
742 }
743 }),
744 json!({
745 "name": "wire_group_join",
746 "description": "Join a group from a code minted by wire_group_invite. Materializes the room locally, pins existing members on the creator's vouch, and announces you to the room so members verify your messages. No prior pairing needed.",
747 "inputSchema": {
748 "type": "object",
749 "properties": {"code": {"type": "string", "description": "The `wire-group:` join code."}},
750 "required": ["code"]
751 }
752 }),
753 ]
754}
755
756fn handle_tools_call(id: &Value, params: &Value, _state: &McpState) -> Value {
757 let name = match params.get("name").and_then(Value::as_str) {
758 Some(n) => n,
759 None => return error_response(id, -32602, "missing tool name"),
760 };
761 let args = params
762 .get("arguments")
763 .cloned()
764 .unwrap_or_else(|| json!({}));
765
766 let result = match name {
767 "wire_whoami" => tool_whoami(),
768 "wire_status" => tool_status(),
769 "wire_peers" => tool_peers(),
770 "wire_send" => tool_send(&args),
771 "wire_pull" => tool_pull(),
772 "wire_tail" => tool_tail(&args),
773 "wire_verify" => tool_verify(&args),
774 "wire_init" => tool_init(&args),
775 "wire_invite_mint" => tool_invite_mint(&args),
776 "wire_invite_accept" => tool_invite_accept(&args),
777 "wire_add" => tool_add(&args),
779 "wire_accept" => tool_pair_accept(&args),
784 "wire_reject" => tool_pair_reject(&args),
785 "wire_pending" => tool_pair_list_inbound(),
786 "wire_pair_accept" => Err("wire_pair_accept was renamed to wire_accept (v0.9+). \
787 Use wire_accept instead."
788 .into()),
789 "wire_pair_reject" => Err("wire_pair_reject was renamed to wire_reject (v0.9+). \
790 Use wire_reject instead."
791 .into()),
792 "wire_pair_list_inbound" => Err(
793 "wire_pair_list_inbound was renamed to wire_pending (v0.9+). \
794 Use wire_pending instead."
795 .into(),
796 ),
797 "wire_dial" => tool_dial(&args),
798 "wire_claim" => tool_claim_handle(&args),
799 "wire_whois" => tool_whois(&args),
800 "wire_profile_set" => tool_profile_set(&args),
801 "wire_profile_get" => tool_profile_get(),
802 "wire_group_create" => tool_group_create(&args),
804 "wire_group_add" => tool_group_add(&args),
805 "wire_group_send" => tool_group_send(&args),
806 "wire_group_tail" => tool_group_tail(&args),
807 "wire_group_list" => tool_group_list(),
808 "wire_group_invite" => tool_group_invite(&args),
809 "wire_group_join" => tool_group_join(&args),
810 "wire_join" => Err("wire_join (SAS code-phrase pairing) was removed. \
814 Use wire_dial(\"<handle>@<relay>\") to pair by handle. \
815 See docs/AGENT_INTEGRATION.md."
816 .into()),
817 other => Err(format!("unknown tool: {other}")),
818 };
819
820 match result {
821 Ok(value) => json!({
822 "jsonrpc": "2.0",
823 "id": id,
824 "result": {
825 "content": [{
826 "type": "text",
827 "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
828 }],
829 "isError": false
830 }
831 }),
832 Err(message) => json!({
833 "jsonrpc": "2.0",
834 "id": id,
835 "result": {
836 "content": [{"type": "text", "text": message}],
837 "isError": true
838 }
839 }),
840 }
841}
842
843fn tool_whoami() -> Result<Value, String> {
846 use crate::config;
847 use crate::signing::{b64decode, fingerprint, make_key_id};
848
849 if !config::is_initialized().map_err(|e| e.to_string())? {
850 return Err("not initialized — operator must run `wire init <handle>` first".into());
851 }
852 let card = config::read_agent_card().map_err(|e| e.to_string())?;
853 let did = card
854 .get("did")
855 .and_then(Value::as_str)
856 .unwrap_or("")
857 .to_string();
858 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
859 let pk_b64 = card
860 .get("verify_keys")
861 .and_then(Value::as_object)
862 .and_then(|m| m.values().next())
863 .and_then(|v| v.get("key"))
864 .and_then(Value::as_str)
865 .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
866 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
867 let fp = fingerprint(&pk_bytes);
868 let key_id = make_key_id(&handle, &pk_bytes);
869 let capabilities = card
870 .get("capabilities")
871 .cloned()
872 .unwrap_or_else(|| json!(["wire/v3.2"]));
873 let persona =
877 serde_json::to_value(crate::character::Character::from_card(&card)).unwrap_or(Value::Null);
878 let mut payload = serde_json::Map::new();
884 payload.insert("did".into(), json!(did));
885 payload.insert("handle".into(), json!(handle));
886 payload.insert("persona".into(), persona);
887 payload.insert("fingerprint".into(), json!(fp));
888 payload.insert("key_id".into(), json!(key_id));
889 payload.insert("public_key_b64".into(), json!(pk_b64));
890 payload.insert("capabilities".into(), capabilities);
891 payload.insert(
895 "session_source".into(),
896 json!(crate::session::session_source()),
897 );
898 for (k, v) in crate::cli::op_claims_from_card(&card) {
899 payload.insert(k, v);
900 }
901 Ok(Value::Object(payload))
902}
903
904fn tool_peers() -> Result<Value, String> {
905 use crate::config;
906
907 let trust = config::read_trust().map_err(|e| e.to_string())?;
908 let agents = trust
909 .get("agents")
910 .and_then(Value::as_object)
911 .cloned()
912 .unwrap_or_default();
913 let relay_state =
921 config::read_relay_state().unwrap_or_else(|_| json!({"self": null, "peers": {}}));
922 let mut self_did: Option<String> = None;
923 if let Ok(card) = config::read_agent_card() {
924 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
925 }
926 let mut peers = Vec::new();
927 for (handle, agent) in agents.iter() {
928 let did = agent
929 .get("did")
930 .and_then(Value::as_str)
931 .unwrap_or("")
932 .to_string();
933 if Some(did.as_str()) == self_did.as_deref() {
934 continue;
935 }
936 let persona = match agent.get("card") {
940 Some(c) => crate::character::Character::from_card(c),
941 None => crate::character::Character::from_did(&did),
942 };
943 let peer_op_claims = agent
948 .get("card")
949 .map(crate::cli::op_claims_from_card)
950 .unwrap_or_default();
951 let mut row = serde_json::Map::new();
952 row.insert("handle".into(), json!(handle));
953 row.insert(
954 "persona".into(),
955 serde_json::to_value(&persona).unwrap_or(Value::Null),
956 );
957 row.insert("did".into(), json!(did));
958 row.insert(
959 "tier".into(),
960 json!(crate::trust::effective_tier(&trust, &relay_state, handle)),
961 );
962 row.insert(
963 "capabilities".into(),
964 agent
965 .get("card")
966 .and_then(|c| c.get("capabilities"))
967 .cloned()
968 .unwrap_or_else(|| json!([])),
969 );
970 for (k, v) in peer_op_claims {
971 row.insert(k, v);
972 }
973 peers.push(Value::Object(row));
974 }
975 Ok(json!(peers))
976}
977
978fn group_cli_json(args: &[&str]) -> Result<Value, String> {
983 let exe = std::env::current_exe().map_err(|e| format!("locating wire binary: {e}"))?;
984 let out = std::process::Command::new(exe)
985 .arg("group")
986 .args(args)
987 .arg("--json")
988 .env("WIRE_QUIET_AUTOSESSION", "1") .output()
990 .map_err(|e| format!("spawning `wire group`: {e}"))?;
991 if !out.status.success() {
992 let err = String::from_utf8_lossy(&out.stderr);
993 return Err(err.trim().to_string());
994 }
995 let s = String::from_utf8_lossy(&out.stdout);
996 let line = s
998 .lines()
999 .rev()
1000 .find(|l| l.trim_start().starts_with('{'))
1001 .unwrap_or("{}");
1002 serde_json::from_str(line).map_err(|e| format!("parsing `wire group` output: {e}"))
1003}
1004
1005fn tool_group_create(args: &Value) -> Result<Value, String> {
1006 let name = args
1007 .get("name")
1008 .and_then(Value::as_str)
1009 .ok_or("missing 'name'")?;
1010 group_cli_json(&["create", name])
1011}
1012
1013fn tool_group_add(args: &Value) -> Result<Value, String> {
1014 let group = args
1015 .get("group")
1016 .and_then(Value::as_str)
1017 .ok_or("missing 'group'")?;
1018 let peer = args
1019 .get("peer")
1020 .and_then(Value::as_str)
1021 .ok_or("missing 'peer'")?;
1022 group_cli_json(&["add", group, peer])
1023}
1024
1025fn tool_group_send(args: &Value) -> Result<Value, String> {
1026 let group = args
1027 .get("group")
1028 .and_then(Value::as_str)
1029 .ok_or("missing 'group'")?;
1030 let message = args
1031 .get("message")
1032 .and_then(Value::as_str)
1033 .ok_or("missing 'message'")?;
1034 group_cli_json(&["send", group, message])
1035}
1036
1037fn tool_group_tail(args: &Value) -> Result<Value, String> {
1038 let group = args
1039 .get("group")
1040 .and_then(Value::as_str)
1041 .ok_or("missing 'group'")?;
1042 if let Some(n) = args.get("limit").and_then(Value::as_u64) {
1043 group_cli_json(&["tail", group, "--limit", &n.to_string()])
1044 } else {
1045 group_cli_json(&["tail", group])
1046 }
1047}
1048
1049fn tool_group_list() -> Result<Value, String> {
1050 group_cli_json(&["list"])
1051}
1052
1053fn tool_group_invite(args: &Value) -> Result<Value, String> {
1054 let group = args
1055 .get("group")
1056 .and_then(Value::as_str)
1057 .ok_or("missing 'group'")?;
1058 group_cli_json(&["invite", group])
1059}
1060
1061fn tool_group_join(args: &Value) -> Result<Value, String> {
1062 let code = args
1063 .get("code")
1064 .and_then(Value::as_str)
1065 .ok_or("missing 'code'")?;
1066 group_cli_json(&["join", code])
1067}
1068
1069fn tool_status() -> Result<Value, String> {
1079 use crate::config;
1080
1081 let initialized = config::is_initialized().unwrap_or(false);
1082 if !initialized {
1083 return Ok(json!({
1084 "initialized": false,
1085 "daemon_running": false,
1086 "last_sync_age_seconds": Value::Null,
1087 }));
1088 }
1089
1090 let snap = crate::ensure_up::daemon_liveness();
1091 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1092 let last_sync_record = crate::ensure_up::read_last_sync_record();
1093
1094 let mut daemon = json!({
1095 "running": snap.pidfile_alive,
1096 "pid": snap.pidfile_pid,
1097 "all_running_pids": snap.pgrep_pids,
1098 "orphans": snap.orphan_pids,
1099 });
1100 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
1101 daemon["version"] = json!(d.version);
1102 daemon["bin_path"] = json!(d.bin_path);
1103 daemon["did"] = json!(d.did);
1104 daemon["relay_url"] = json!(d.relay_url);
1105 daemon["started_at"] = json!(d.started_at);
1106 }
1107
1108 let (last_sync_at, last_sync_push_n, last_sync_pull_n, last_sync_rejected_n) =
1109 match last_sync_record {
1110 Some(rec) => (
1111 Some(rec.ts),
1112 Some(rec.push_n),
1113 Some(rec.pull_n),
1114 Some(rec.rejected_n),
1115 ),
1116 None => (None, None, None, None),
1117 };
1118
1119 let outbox_count = config::outbox_dir()
1120 .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1121 .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1122 .unwrap_or(0);
1123 let inbox_count = config::inbox_dir()
1124 .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1125 .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1126 .unwrap_or(0);
1127
1128 let pending_push_breakdown = config::compute_pending_push_breakdown();
1136 let pending_push_count: u64 = pending_push_breakdown.iter().map(|p| p.count).sum();
1137
1138 let stream_state = config::read_stream_state();
1144
1145 Ok(json!({
1146 "initialized": true,
1147 "daemon": daemon,
1148 "daemon_running": snap.pidfile_alive,
1149 "last_sync_at": last_sync_at,
1150 "last_sync_age_seconds": last_sync_age,
1151 "last_sync_push_n": last_sync_push_n,
1152 "last_sync_pull_n": last_sync_pull_n,
1153 "last_sync_rejected_n": last_sync_rejected_n,
1154 "stale_sync": config::stale_sync(last_sync_age),
1155 "outbox_count": outbox_count,
1156 "inbox_count": inbox_count,
1157 "pending_push_count": pending_push_count,
1158 "pending_push_breakdown": pending_push_breakdown,
1159 "stream_state": stream_state,
1160 }))
1161}
1162
1163fn tool_send(args: &Value) -> Result<Value, String> {
1164 use crate::config;
1165 use crate::signing::{b64decode, sign_message_v31};
1166
1167 let peer = args
1168 .get("peer")
1169 .and_then(Value::as_str)
1170 .ok_or("missing 'peer'")?;
1171 let peer = crate::agent_card::bare_handle(peer);
1172 let kind = args
1173 .get("kind")
1174 .and_then(Value::as_str)
1175 .ok_or("missing 'kind'")?;
1176 let body = args
1177 .get("body")
1178 .and_then(Value::as_str)
1179 .ok_or("missing 'body'")?;
1180 let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
1181 let queue = args.get("queue").and_then(Value::as_bool).unwrap_or(false);
1186
1187 if !config::is_initialized().map_err(|e| e.to_string())? {
1188 return Err("not initialized — operator must run `wire init <handle>` first".into());
1189 }
1190 let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
1191 let card = config::read_agent_card().map_err(|e| e.to_string())?;
1192 let did = card
1193 .get("did")
1194 .and_then(Value::as_str)
1195 .unwrap_or("")
1196 .to_string();
1197 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1198 let pk_b64 = card
1199 .get("verify_keys")
1200 .and_then(Value::as_object)
1201 .and_then(|m| m.values().next())
1202 .and_then(|v| v.get("key"))
1203 .and_then(Value::as_str)
1204 .ok_or("agent-card missing verify_keys[*].key")?;
1205 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1206
1207 let body_value: Value =
1209 serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1210 let kind_id = parse_kind(kind);
1211
1212 let now = time::OffsetDateTime::now_utc()
1213 .format(&time::format_description::well_known::Rfc3339)
1214 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1215
1216 let trust_for_did = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
1224 let to_did = crate::trust::resolve_peer_did(&trust_for_did, peer);
1225 let mut event = json!({
1226 "timestamp": now,
1227 "from": did,
1228 "to": to_did,
1229 "type": kind,
1230 "kind": kind_id,
1231 "body": body_value,
1232 });
1233 if let Some(deadline) = deadline {
1234 event["time_sensitive_until"] =
1235 json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1236 }
1237 let signed =
1238 sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1239 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1240
1241 if !queue {
1246 let outcome = crate::send::attempt_deliver(peer, &signed).map_err(|e| e.to_string())?;
1247 let mut v = crate::send::delivery_json(&outcome, peer);
1248 let snap = crate::ensure_up::daemon_liveness();
1254 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1255 if let Some(obj) = v.as_object_mut() {
1256 obj.insert("daemon_seen".into(), json!(snap.pidfile_alive));
1257 obj.insert("last_sync_age_seconds".into(), json!(last_sync_age));
1258 obj.insert(
1259 "stale_sync".into(),
1260 json!(config::stale_sync(last_sync_age)),
1261 );
1262 }
1263 return Ok(v);
1264 }
1265
1266 let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1268 let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1269 let snap = crate::ensure_up::daemon_liveness();
1270 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1271 let peer_pinned_in_trust = trust_for_did
1278 .get("agents")
1279 .and_then(Value::as_object)
1280 .map(|a| a.contains_key(peer))
1281 .unwrap_or(false);
1282 let peer_in_relay_state = config::read_relay_state()
1283 .ok()
1284 .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
1285 .map(|peers| peers.contains_key(peer))
1286 .unwrap_or(false);
1287 let pending_inbound = crate::pending_inbound_pair::list_pending_inbound()
1288 .ok()
1289 .map(|v| v.iter().any(|p| p.peer_handle == peer))
1290 .unwrap_or(false);
1291 let unpushable = !peer_pinned_in_trust && !peer_in_relay_state && !pending_inbound;
1292 let mut out = json!({
1293 "event_id": event_id,
1294 "status": "queued",
1295 "peer": peer,
1296 "outbox": outbox.to_string_lossy(),
1297 "daemon_seen": snap.pidfile_alive,
1298 "last_sync_age_seconds": last_sync_age,
1299 "stale_sync": config::stale_sync(last_sync_age),
1300 });
1301 if unpushable {
1302 out["warning"] = json!(format!(
1303 "`{peer}` is not pinned and has no pending pair — the event will sit in outbox forever unless you pair first (wire_dial)."
1304 ));
1305 }
1306 Ok(out)
1307}
1308
1309fn tool_pull() -> Result<Value, String> {
1316 crate::cli::run_sync_pull().map_err(|e| format!("{e:#}"))
1317}
1318
1319fn tool_tail(args: &Value) -> Result<Value, String> {
1320 use crate::config;
1321 use crate::signing::verify_message_v31;
1322
1323 let peer_filter = args.get("peer").and_then(Value::as_str);
1324 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1325 let oldest = args.get("oldest").and_then(Value::as_bool).unwrap_or(false);
1330 let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1331 if !inbox.exists() {
1332 return Ok(json!([]));
1333 }
1334 let trust = config::read_trust().map_err(|e| e.to_string())?;
1335 let entries: Vec<_> = std::fs::read_dir(&inbox)
1336 .map_err(|e| e.to_string())?
1337 .filter_map(|e| e.ok())
1338 .map(|e| e.path())
1339 .filter(|p| {
1340 p.extension().map(|x| x == "jsonl").unwrap_or(false)
1341 && match peer_filter {
1342 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1343 None => true,
1344 }
1345 })
1346 .collect();
1347
1348 let mut collected: Vec<(String, usize, Value)> = Vec::new();
1351 for path in &entries {
1352 let body = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1353 for (idx, line) in body.lines().enumerate() {
1354 let event: Value = match serde_json::from_str(line) {
1355 Ok(v) => v,
1356 Err(_) => continue,
1357 };
1358 let verified = verify_message_v31(&event, &trust).is_ok();
1359 let mut event_with_meta = event.clone();
1360 if let Some(obj) = event_with_meta.as_object_mut() {
1361 obj.insert("verified".into(), json!(verified));
1362 }
1363 let ts = event
1364 .get("timestamp")
1365 .and_then(Value::as_str)
1366 .unwrap_or("")
1367 .to_string();
1368 collected.push((ts, idx, event_with_meta));
1369 }
1370 }
1371 collected.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
1372
1373 let total = collected.len();
1374 let window: Vec<Value> = if limit == 0 {
1375 collected.into_iter().map(|(_, _, e)| e).collect()
1376 } else if oldest {
1377 collected
1378 .into_iter()
1379 .take(limit)
1380 .map(|(_, _, e)| e)
1381 .collect()
1382 } else {
1383 let start = total.saturating_sub(limit);
1384 collected
1385 .into_iter()
1386 .skip(start)
1387 .map(|(_, _, e)| e)
1388 .collect()
1389 };
1390 Ok(Value::Array(window))
1391}
1392
1393fn tool_verify(args: &Value) -> Result<Value, String> {
1394 use crate::config;
1395 use crate::signing::verify_message_v31;
1396
1397 let event_str = args
1398 .get("event")
1399 .and_then(Value::as_str)
1400 .ok_or("missing 'event'")?;
1401 let event: Value =
1402 serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1403 let trust = config::read_trust().map_err(|e| e.to_string())?;
1404 match verify_message_v31(&event, &trust) {
1405 Ok(()) => Ok(json!({"verified": true})),
1406 Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1407 }
1408}
1409
1410fn ensure_session_bootstrapped() {
1419 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
1420 return;
1421 }
1422 if crate::config::is_initialized().unwrap_or(false) {
1423 return; }
1425 let (did, relay_url, slot_id, slot_token) =
1426 match crate::pair_invite::ensure_self_with_relay(None) {
1427 Ok(t) => t,
1428 Err(_) => return, };
1430 if let Ok(card) = crate::config::read_agent_card() {
1431 let persona = crate::agent_card::display_handle_from_did(&did).to_string();
1432 let client = crate::relay_client::RelayClient::new(&relay_url);
1433 let _ = client.handle_claim_v2(&persona, &slot_id, &slot_token, None, &card, None);
1434 }
1435}
1436
1437fn tool_init(args: &Value) -> Result<Value, String> {
1438 let handle = args
1439 .get("handle")
1440 .and_then(Value::as_str)
1441 .ok_or("missing 'handle'")?;
1442 let name = args.get("name").and_then(Value::as_str);
1443 let relay = args.get("relay_url").and_then(Value::as_str);
1444 crate::init::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1445}
1446
1447fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1450 let relay_url = args.get("relay_url").and_then(Value::as_str);
1451 let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1452 let uses = args
1453 .get("uses")
1454 .and_then(Value::as_u64)
1455 .map(|u| u as u32)
1456 .unwrap_or(1);
1457 let url =
1458 crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1459 let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1460 Ok(json!({
1461 "invite_url": url,
1462 "ttl_secs": ttl_resolved,
1463 "uses": uses,
1464 }))
1465}
1466
1467fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1468 let url = args
1469 .get("url")
1470 .and_then(Value::as_str)
1471 .ok_or("missing 'url'")?;
1472 crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1473}
1474
1475fn tool_dial(args: &Value) -> Result<Value, String> {
1487 let name = args
1488 .get("name")
1489 .and_then(Value::as_str)
1490 .or_else(|| args.get("handle").and_then(Value::as_str))
1491 .ok_or("missing 'name'")?;
1492
1493 if name.contains('@') {
1494 let mut a = args.clone();
1496 if let Some(obj) = a.as_object_mut() {
1497 obj.insert("handle".into(), Value::String(name.to_string()));
1498 }
1499 return tool_add(&a);
1500 }
1501
1502 let relay_state = crate::config::read_relay_state().map_err(|e| e.to_string())?;
1503 let pinned = relay_state
1504 .get("peers")
1505 .and_then(Value::as_object)
1506 .map(|m| m.contains_key(name))
1507 .unwrap_or(false);
1508 if pinned {
1509 return Ok(json!({
1510 "name_input": name,
1511 "status": "already_pinned",
1512 "peer_handle": name,
1513 }));
1514 }
1515
1516 Err(format!(
1517 "cannot resolve `{name}` over MCP: bare-nickname / local-sister dialling is not yet \
1518 wired into the MCP surface. Use a federation handle `{name}@<relay>`, or `wire_send` \
1519 (it auto-pairs on miss)."
1520 ))
1521}
1522
1523fn tool_add(args: &Value) -> Result<Value, String> {
1524 let handle = args
1525 .get("handle")
1526 .and_then(Value::as_str)
1527 .ok_or("missing 'handle'")?;
1528 let relay_override = args.get("relay_url").and_then(Value::as_str);
1529
1530 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1531
1532 let (our_did, our_relay, our_slot_id, our_slot_token) =
1534 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1535
1536 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1538 .map_err(|e| format!("{e:#}"))?;
1539 let peer_card = resolved
1540 .get("card")
1541 .cloned()
1542 .ok_or("resolved missing card")?;
1543 let peer_did = resolved
1544 .get("did")
1545 .and_then(Value::as_str)
1546 .ok_or("resolved missing did")?
1547 .to_string();
1548 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1549 let peer_slot_id = resolved
1550 .get("slot_id")
1551 .and_then(Value::as_str)
1552 .ok_or("resolved missing slot_id")?
1553 .to_string();
1554 let peer_relay = resolved
1555 .get("relay_url")
1556 .and_then(Value::as_str)
1557 .map(str::to_string)
1558 .or_else(|| relay_override.map(str::to_string))
1559 .unwrap_or_else(|| format!("https://{}", parsed.domain));
1560
1561 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1563 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1564 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1565 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1566 let existing_token = relay_state
1567 .get("peers")
1568 .and_then(|p| p.get(&peer_handle))
1569 .and_then(|p| p.get("slot_token"))
1570 .and_then(Value::as_str)
1571 .map(str::to_string)
1572 .unwrap_or_default();
1573 relay_state["peers"][&peer_handle] = json!({
1574 "relay_url": peer_relay,
1575 "slot_id": peer_slot_id,
1576 "slot_token": existing_token,
1577 });
1578 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1579
1580 let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1582 let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1583 let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1584 let pk_b64 = our_card
1585 .get("verify_keys")
1586 .and_then(Value::as_object)
1587 .and_then(|m| m.values().next())
1588 .and_then(|v| v.get("key"))
1589 .and_then(Value::as_str)
1590 .ok_or("our card missing verify_keys[*].key")?;
1591 let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1592 let now = time::OffsetDateTime::now_utc()
1593 .format(&time::format_description::well_known::Rfc3339)
1594 .unwrap_or_default();
1595 let event = json!({
1596 "timestamp": now,
1597 "from": our_did,
1598 "to": peer_did,
1599 "type": "pair_drop",
1600 "kind": 1100u32,
1601 "body": {
1602 "card": our_card,
1603 "relay_url": our_relay,
1604 "slot_id": our_slot_id,
1605 "slot_token": our_slot_token,
1606 },
1607 });
1608 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
1609 .map_err(|e| format!("{e:#}"))?;
1610
1611 let client = crate::relay_client::RelayClient::new(&peer_relay);
1612 let resp = client
1613 .handle_intro(&parsed.nick, &signed)
1614 .map_err(|e| format!("{e:#}"))?;
1615 let event_id = signed
1616 .get("event_id")
1617 .and_then(Value::as_str)
1618 .unwrap_or("")
1619 .to_string();
1620 Ok(json!({
1621 "handle": handle,
1622 "paired_with": peer_did,
1623 "peer_handle": peer_handle,
1624 "event_id": event_id,
1625 "drop_response": resp,
1626 "status": "drop_sent",
1627 }))
1628}
1629
1630fn tool_pair_accept(args: &Value) -> Result<Value, String> {
1635 let peer = args
1636 .get("peer")
1637 .and_then(Value::as_str)
1638 .ok_or("missing 'peer'")?;
1639 let nick = crate::agent_card::bare_handle(peer);
1640 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
1641 .map_err(|e| format!("{e:#}"))?
1642 .ok_or_else(|| {
1643 format!(
1644 "no pending pair request from {nick}. Call wire_pending to enumerate, \
1645 or wire_add to send a fresh outbound pair request."
1646 )
1647 })?;
1648
1649 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1652 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
1653 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1654
1655 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1657 relay_state["peers"][&pending.peer_handle] = json!({
1658 "relay_url": pending.peer_relay_url,
1659 "slot_id": pending.peer_slot_id,
1660 "slot_token": pending.peer_slot_token,
1661 });
1662 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1663
1664 let ack_endpoints: Vec<crate::endpoints::Endpoint> = if pending.peer_endpoints.is_empty() {
1671 vec![crate::endpoints::Endpoint::federation(
1672 pending.peer_relay_url.clone(),
1673 pending.peer_slot_id.clone(),
1674 pending.peer_slot_token.clone(),
1675 )]
1676 } else {
1677 pending.peer_endpoints.clone()
1678 };
1679 crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &ack_endpoints).map_err(|e| {
1680 format!(
1681 "pair_drop_ack send to {} (across {} endpoint(s)) failed: {e:#}",
1682 pending.peer_handle,
1683 ack_endpoints.len()
1684 )
1685 })?;
1686
1687 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1688
1689 Ok(json!({
1690 "status": "bilateral_accepted",
1691 "peer_handle": pending.peer_handle,
1692 "peer_did": pending.peer_did,
1693 "peer_relay_url": pending.peer_relay_url,
1694 "via": "pending_inbound",
1695 }))
1696}
1697
1698fn tool_pair_reject(args: &Value) -> Result<Value, String> {
1702 let peer = args
1703 .get("peer")
1704 .and_then(Value::as_str)
1705 .ok_or("missing 'peer'")?;
1706 let nick = crate::agent_card::bare_handle(peer);
1707 let existed =
1708 crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1709 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1710 Ok(json!({
1711 "peer": nick,
1712 "rejected": existed.is_some(),
1713 "had_pending": existed.is_some(),
1714 }))
1715}
1716
1717fn tool_pair_list_inbound() -> Result<Value, String> {
1721 let items =
1722 crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
1723 Ok(json!(items))
1724}
1725
1726fn tool_claim_handle(args: &Value) -> Result<Value, String> {
1727 let typed = args.get("nick").and_then(Value::as_str);
1728 let relay_override = args.get("relay_url").and_then(Value::as_str);
1729 let public_url = args.get("public_url").and_then(Value::as_str);
1730
1731 let (_, our_relay, our_slot_id, our_slot_token) =
1733 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1734 let claim_relay = relay_override.unwrap_or(&our_relay);
1735 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1736
1737 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
1742 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
1743 let nick = if canonical.is_empty() {
1744 typed.unwrap_or_default().to_string()
1745 } else {
1746 canonical
1747 };
1748 let typed_nick_ignored = typed.map(|t| t != nick).unwrap_or(false);
1749
1750 let client = crate::relay_client::RelayClient::new(claim_relay);
1751 let resp = client
1752 .handle_claim(&nick, &our_slot_id, &our_slot_token, public_url, &card)
1753 .map_err(|e| format!("{e:#}"))?;
1754 Ok(json!({
1755 "nick": nick,
1756 "relay": claim_relay,
1757 "response": resp,
1758 "one_name": true,
1759 "typed_nick_ignored": typed_nick_ignored,
1760 }))
1761}
1762
1763fn tool_whois(args: &Value) -> Result<Value, String> {
1764 if let Some(handle) = args.get("handle").and_then(Value::as_str) {
1765 if !handle.contains('@')
1775 && let Ok(target) = crate::cli::resolve_name_to_target(handle)
1776 {
1777 return Ok(dial_target_to_whois_json(&target));
1778 }
1779 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1780 let relay_override = args.get("relay_url").and_then(Value::as_str);
1781 crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
1782 } else {
1783 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1787 let mut payload = serde_json::Map::new();
1788 payload.insert(
1789 "did".into(),
1790 card.get("did").cloned().unwrap_or(Value::Null),
1791 );
1792 payload.insert(
1793 "profile".into(),
1794 card.get("profile").cloned().unwrap_or(Value::Null),
1795 );
1796 for (k, v) in crate::cli::op_claims_from_card(&card) {
1797 payload.insert(k, v);
1798 }
1799 Ok(Value::Object(payload))
1800 }
1801}
1802
1803fn dial_target_to_whois_json(target: &crate::cli::DialTarget) -> Value {
1809 use crate::cli::DialTarget;
1810 match target {
1811 DialTarget::PinnedPeer {
1812 handle,
1813 did,
1814 nickname,
1815 emoji,
1816 tier,
1817 } => {
1818 let op_claims = crate::config::read_trust()
1819 .ok()
1820 .and_then(|t| {
1821 t.get("agents")
1822 .and_then(Value::as_object)
1823 .and_then(|m| m.get(handle))
1824 .and_then(|a| a.get("card").cloned())
1825 })
1826 .map(|c| crate::cli::op_claims_from_card(&c))
1827 .unwrap_or_default();
1828 let mut payload = serde_json::Map::new();
1829 payload.insert("kind".into(), json!("pinned_peer"));
1830 payload.insert("handle".into(), json!(handle));
1831 payload.insert("did".into(), json!(did));
1832 payload.insert("nickname".into(), json!(nickname));
1833 payload.insert("emoji".into(), json!(emoji));
1834 payload.insert("tier".into(), json!(tier));
1835 for (k, v) in op_claims {
1836 payload.insert(k, v);
1837 }
1838 Value::Object(payload)
1839 }
1840 DialTarget::LocalSister {
1841 session_name,
1842 handle,
1843 did,
1844 nickname,
1845 emoji,
1846 } => json!({
1847 "kind": "local_sister",
1848 "session_name": session_name,
1849 "handle": handle,
1850 "did": did,
1851 "nickname": nickname,
1852 "emoji": emoji,
1853 }),
1854 }
1855}
1856
1857fn tool_profile_set(args: &Value) -> Result<Value, String> {
1858 let field = args
1859 .get("field")
1860 .and_then(Value::as_str)
1861 .ok_or("missing 'field'")?;
1862 let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
1863 let value = if let Some(s) = raw_value.as_str() {
1867 serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
1868 } else {
1869 raw_value
1870 };
1871 let new_profile =
1872 crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
1873 Ok(json!({
1874 "field": field,
1875 "profile": new_profile,
1876 }))
1877}
1878
1879fn tool_profile_get() -> Result<Value, String> {
1880 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1881 Ok(json!({
1882 "did": card.get("did").cloned().unwrap_or(Value::Null),
1883 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1884 }))
1885}
1886
1887fn parse_kind(s: &str) -> u32 {
1890 if let Ok(n) = s.parse::<u32>() {
1891 return n;
1892 }
1893 for (id, name) in crate::signing::kinds() {
1894 if *name == s {
1895 return *id;
1896 }
1897 }
1898 1
1899}
1900
1901fn error_response(id: &Value, code: i32, message: &str) -> Value {
1902 json!({
1903 "jsonrpc": "2.0",
1904 "id": id,
1905 "error": {"code": code, "message": message}
1906 })
1907}
1908
1909#[cfg(test)]
1910mod tests {
1911 use super::*;
1912
1913 #[test]
1914 fn unknown_method_returns_jsonrpc_error() {
1915 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
1916 let resp = handle_request(&req, &McpState::default());
1917 assert_eq!(resp["error"]["code"], -32601);
1918 }
1919
1920 #[test]
1921 fn initialize_advertises_tools_capability() {
1922 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
1923 let resp = handle_request(&req, &McpState::default());
1924 assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
1925 assert!(resp["result"]["capabilities"]["tools"].is_object());
1926 assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
1927 }
1928
1929 #[test]
1930 fn tools_list_includes_pairing_and_messaging() {
1931 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
1932 let resp = handle_request(&req, &McpState::default());
1933 let names: Vec<&str> = resp["result"]["tools"]
1934 .as_array()
1935 .unwrap()
1936 .iter()
1937 .filter_map(|t| t["name"].as_str())
1938 .collect();
1939 for required in [
1940 "wire_whoami",
1941 "wire_peers",
1942 "wire_send",
1943 "wire_tail",
1944 "wire_verify",
1945 "wire_init",
1946 "wire_dial",
1947 ] {
1948 assert!(
1949 names.contains(&required),
1950 "missing required tool {required}"
1951 );
1952 }
1953 for removed in [
1956 "wire_pair_initiate",
1957 "wire_pair_join",
1958 "wire_pair_check",
1959 "wire_pair_confirm",
1960 "wire_pair_initiate_detached",
1961 "wire_pair_join_detached",
1962 "wire_pair_list_pending",
1963 "wire_pair_confirm_detached",
1964 "wire_pair_cancel_pending",
1965 ] {
1966 assert!(
1967 !names.contains(&removed),
1968 "SAS pair tool {removed} must not be advertised after removal"
1969 );
1970 }
1971 assert!(
1975 !names.contains(&"wire_join"),
1976 "wire_join must not be advertised — SAS pairing removed"
1977 );
1978 }
1979
1980 #[test]
1981 fn legacy_wire_join_call_returns_helpful_error() {
1982 let req = json!({
1983 "jsonrpc": "2.0",
1984 "id": 1,
1985 "method": "tools/call",
1986 "params": {"name": "wire_join", "arguments": {}}
1987 });
1988 let resp = handle_request(&req, &McpState::default());
1989 assert_eq!(resp["result"]["isError"], true);
1990 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
1991 assert!(
1992 text.contains("wire_dial"),
1993 "expected redirect to wire_dial, got: {text}"
1994 );
1995 }
1996
1997 #[test]
1998 fn tools_list_canonical_present_deprecated_absent() {
1999 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2000 let resp = handle_request(&req, &McpState::default());
2001 let names: Vec<&str> = resp["result"]["tools"]
2002 .as_array()
2003 .unwrap()
2004 .iter()
2005 .filter_map(|t| t["name"].as_str())
2006 .collect();
2007
2008 for required in ["wire_accept", "wire_reject", "wire_pending"] {
2010 assert!(
2011 names.contains(&required),
2012 "canonical tool {required} missing from tools/list"
2013 );
2014 }
2015
2016 for removed in [
2018 "wire_pair_accept",
2019 "wire_pair_reject",
2020 "wire_pair_list_inbound",
2021 ] {
2022 assert!(
2023 !names.contains(&removed),
2024 "deprecated tool {removed} must not appear in tools/list"
2025 );
2026 }
2027 }
2028
2029 #[test]
2030 fn deprecated_pair_accept_call_returns_helpful_error() {
2031 for (old_name, canonical) in [
2032 ("wire_pair_accept", "wire_accept"),
2033 ("wire_pair_reject", "wire_reject"),
2034 ("wire_pair_list_inbound", "wire_pending"),
2035 ] {
2036 let req = json!({
2037 "jsonrpc": "2.0",
2038 "id": 1,
2039 "method": "tools/call",
2040 "params": {"name": old_name, "arguments": {}}
2041 });
2042 let resp = handle_request(&req, &McpState::default());
2043 assert_eq!(
2044 resp["result"]["isError"], true,
2045 "calling {old_name} should return isError:true"
2046 );
2047 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2048 assert!(
2049 text.contains(canonical),
2050 "error for {old_name} should mention {canonical}, got: {text}"
2051 );
2052 }
2053 }
2054
2055 #[test]
2056 fn initialize_advertises_resources_capability() {
2057 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
2058 let resp = handle_request(&req, &McpState::default());
2059 let caps = &resp["result"]["capabilities"];
2060 assert!(
2061 caps["resources"].is_object(),
2062 "resources capability must be present, got {resp}"
2063 );
2064 assert_eq!(
2065 caps["resources"]["subscribe"], true,
2066 "subscribe shipped in v0.2.1"
2067 );
2068 }
2069
2070 #[test]
2071 fn resources_read_with_bad_uri_errors() {
2072 let req = json!({
2073 "jsonrpc": "2.0",
2074 "id": 1,
2075 "method": "resources/read",
2076 "params": {"uri": "http://example.com/not-a-wire-uri"}
2077 });
2078 let resp = handle_request(&req, &McpState::default());
2079 assert!(resp.get("error").is_some(), "expected error, got {resp}");
2080 }
2081
2082 #[test]
2083 fn parse_inbox_uri_handles_variants() {
2084 assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
2085 assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
2086 assert!(
2087 parse_inbox_uri("wire://inbox/")
2088 .unwrap()
2089 .starts_with("__invalid__"),
2090 "empty peer must be invalid"
2091 );
2092 assert!(
2093 parse_inbox_uri("http://other")
2094 .unwrap()
2095 .starts_with("__invalid__"),
2096 "non-wire scheme must be invalid"
2097 );
2098 }
2099
2100 #[test]
2101 fn ping_returns_empty_result() {
2102 let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2103 let resp = handle_request(&req, &McpState::default());
2104 assert_eq!(resp["id"], 7);
2105 assert!(resp["result"].is_object());
2106 }
2107
2108 #[test]
2109 fn notification_returns_null_no_reply() {
2110 let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2111 let resp = handle_request(&req, &McpState::default());
2112 assert_eq!(resp, Value::Null);
2113 }
2114
2115 #[test]
2122 fn detect_session_wire_home_resolves_registered_cwd() {
2123 crate::config::test_support::with_temp_home(|| {
2124 let wire_home = std::env::var("WIRE_HOME").unwrap();
2128 let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2129 let session_home = sessions_root.join("test-alpha");
2130 std::fs::create_dir_all(&session_home).unwrap();
2131 let fake_cwd = "/tmp/fake-project-cwd-abc123";
2132 let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2133 std::fs::write(
2134 sessions_root.join("registry.json"),
2135 serde_json::to_vec_pretty(®istry).unwrap(),
2136 )
2137 .unwrap();
2138
2139 let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2141 assert_eq!(
2142 got.as_deref(),
2143 Some(session_home.as_path()),
2144 "registered cwd must resolve to session_home"
2145 );
2146
2147 let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2149 "/tmp/cwd-not-in-registry-xyz789",
2150 ));
2151 assert!(nope.is_none(), "unregistered cwd must return None");
2152
2153 let stale_cwd = "/tmp/stale-session-cwd";
2156 let stale_registry =
2157 json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2158 std::fs::write(
2159 sessions_root.join("registry.json"),
2160 serde_json::to_vec_pretty(&stale_registry).unwrap(),
2161 )
2162 .unwrap();
2163 let stale_got =
2164 crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2165 assert!(
2166 stale_got.is_none(),
2167 "registered cwd whose session dir is missing must return None"
2168 );
2169 });
2170 }
2171
2172 #[test]
2179 fn dial_target_to_whois_json_pinned_peer_shape() {
2180 let target = crate::cli::DialTarget::PinnedPeer {
2181 handle: "slate-lotus".into(),
2182 did: "did:wire:slate-lotus-88232017".into(),
2183 nickname: Some("slate-lotus".into()),
2184 emoji: Some("🪴".into()),
2185 tier: "VERIFIED".into(),
2186 };
2187 crate::config::test_support::with_temp_home(|| {
2188 let out = dial_target_to_whois_json(&target);
2189 assert_eq!(out.get("kind").and_then(Value::as_str), Some("pinned_peer"));
2190 assert_eq!(
2191 out.get("handle").and_then(Value::as_str),
2192 Some("slate-lotus")
2193 );
2194 assert_eq!(out.get("tier").and_then(Value::as_str), Some("VERIFIED"));
2195 assert!(out.get("op_did").is_none());
2199 });
2200 }
2201
2202 #[test]
2203 fn dial_target_to_whois_json_local_sister_shape() {
2204 let target = crate::cli::DialTarget::LocalSister {
2205 session_name: "vesper-valley".into(),
2206 handle: "vesper-valley".into(),
2207 did: Some("did:wire:vesper-valley-deadbeef".into()),
2208 nickname: Some("vesper-valley".into()),
2209 emoji: Some("🦌".into()),
2210 };
2211 let out = dial_target_to_whois_json(&target);
2212 assert_eq!(
2213 out.get("kind").and_then(Value::as_str),
2214 Some("local_sister")
2215 );
2216 assert_eq!(
2217 out.get("session_name").and_then(Value::as_str),
2218 Some("vesper-valley")
2219 );
2220 assert_eq!(
2221 out.get("did").and_then(Value::as_str),
2222 Some("did:wire:vesper-valley-deadbeef")
2223 );
2224 assert!(out.get("tier").is_none());
2227 assert!(out.get("op_did").is_none());
2228 }
2229}