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 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_err()
115 && crate::config::is_initialized().unwrap_or(false)
116 {
117 let _ = crate::ensure_up::ensure_daemon_running();
118 }
119
120 crate::session::warn_on_identity_collision(std::process::id(), "mcp");
128
129 let state = McpState::default();
130 let shutdown = Arc::new(AtomicBool::new(false));
131
132 let (tx, rx) = mpsc::channel::<String>();
133
134 if let Ok(mut g) = state.notif_tx.lock() {
137 *g = Some(tx.clone());
138 }
139
140 let writer_handle = std::thread::spawn(move || {
142 let stdout = std::io::stdout();
143 let mut w = stdout.lock();
144 while let Ok(line) = rx.recv() {
145 if writeln!(w, "{line}").is_err() {
146 break;
147 }
148 if w.flush().is_err() {
149 break;
150 }
151 }
152 });
153
154 let subs_w = state.subscribed.clone();
159 let tx_w = tx.clone();
160 let shutdown_w = shutdown.clone();
161 let watcher_handle = std::thread::spawn(move || {
162 let mut watcher = match crate::inbox_watch::InboxWatcher::from_head() {
163 Ok(w) => w,
164 Err(_) => return,
165 };
166 let poll_interval = Duration::from_secs(2);
167 let mut next_poll = Instant::now() + poll_interval;
168 loop {
169 if shutdown_w.load(Ordering::SeqCst) {
170 return;
171 }
172 std::thread::sleep(Duration::from_millis(100));
173 if Instant::now() < next_poll {
174 continue;
175 }
176 next_poll = Instant::now() + poll_interval;
177 let subs_snapshot = match subs_w.lock() {
178 Ok(g) => g.clone(),
179 Err(_) => return,
180 };
181
182 let mut affected: HashSet<String> = HashSet::new();
183
184 if !subs_snapshot.is_empty()
186 && let Ok(events) = watcher.poll()
187 {
188 for ev in &events {
189 if subs_snapshot.contains("wire://inbox/all") {
190 affected.insert("wire://inbox/all".to_string());
191 }
192 let peer_uri = format!("wire://inbox/{}", ev.peer);
193 if subs_snapshot.contains(&peer_uri) {
194 affected.insert(peer_uri);
195 }
196 }
197 }
198
199 for uri in affected {
200 let notif = json!({
201 "jsonrpc": "2.0",
202 "method": "notifications/resources/updated",
203 "params": {"uri": uri}
204 });
205 if tx_w.send(notif.to_string()).is_err() {
206 return;
207 }
208 }
209 }
210 });
211
212 let stdin = std::io::stdin();
213 let mut reader = BufReader::new(stdin.lock());
214 let mut line = String::new();
215 loop {
216 line.clear();
217 let n = reader.read_line(&mut line)?;
218 if n == 0 {
219 shutdown.store(true, Ordering::SeqCst);
223 if let Ok(mut g) = state.notif_tx.lock() {
224 *g = None;
225 }
226 drop(tx);
227 let _ = watcher_handle.join();
228 let _ = writer_handle.join();
229 return Ok(());
230 }
231 let trimmed = line.trim();
232 if trimmed.is_empty() {
233 continue;
234 }
235 let request: Value = match serde_json::from_str(trimmed) {
236 Ok(v) => v,
237 Err(e) => {
238 let err = error_response(&Value::Null, -32700, &format!("parse error: {e}"));
239 let _ = tx.send(err.to_string());
240 continue;
241 }
242 };
243 let response = handle_request(&request, &state);
244 if response.get("id").is_some() || response.get("error").is_some() {
246 let _ = tx.send(response.to_string());
247 }
248 }
249}
250
251fn handle_request(req: &Value, state: &McpState) -> Value {
252 let id = req.get("id").cloned().unwrap_or(Value::Null);
253 let method = match req.get("method").and_then(Value::as_str) {
254 Some(m) => m,
255 None => return error_response(&id, -32600, "missing method"),
256 };
257 match method {
258 "initialize" => handle_initialize(&id),
259 "notifications/initialized" => Value::Null, "tools/list" => handle_tools_list(&id),
261 "tools/call" => handle_tools_call(&id, req.get("params").unwrap_or(&Value::Null), state),
262 "resources/list" => handle_resources_list(&id),
263 "resources/read" => handle_resources_read(&id, req.get("params").unwrap_or(&Value::Null)),
264 "resources/subscribe" => {
265 handle_resources_subscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
266 }
267 "resources/unsubscribe" => {
268 handle_resources_unsubscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
269 }
270 "ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}),
271 other => error_response(&id, -32601, &format!("method not found: {other}")),
272 }
273}
274
275fn handle_resources_list(id: &Value) -> Value {
287 let mut resources = vec![json!({
288 "uri": "wire://inbox/all",
289 "name": "wire inbox (all peers)",
290 "description": "Most recent verified events from all pinned peers, JSONL.",
291 "mimeType": "application/x-ndjson"
292 })];
293
294 if let Ok(trust) = crate::config::read_trust() {
295 let agents = trust
296 .get("agents")
297 .and_then(Value::as_object)
298 .cloned()
299 .unwrap_or_default();
300 let self_did = crate::config::read_agent_card()
301 .ok()
302 .and_then(|c| c.get("did").and_then(Value::as_str).map(str::to_string));
303 for (handle, agent) in agents.iter() {
304 let did = agent
305 .get("did")
306 .and_then(Value::as_str)
307 .unwrap_or("")
308 .to_string();
309 if Some(did.as_str()) == self_did.as_deref() {
310 continue;
311 }
312 resources.push(json!({
313 "uri": format!("wire://inbox/{handle}"),
314 "name": format!("inbox from {handle}"),
315 "description": format!("Recent verified events from did:wire:{handle}."),
316 "mimeType": "application/x-ndjson"
317 }));
318 }
319 }
320
321 json!({
322 "jsonrpc": "2.0",
323 "id": id,
324 "result": {
325 "resources": resources
326 }
327 })
328}
329
330fn handle_resources_subscribe(id: &Value, params: &Value, state: &McpState) -> Value {
331 let uri = match params.get("uri").and_then(Value::as_str) {
332 Some(u) => u.to_string(),
333 None => return error_response(id, -32602, "missing 'uri'"),
334 };
335 let inbox_peer = parse_inbox_uri(&uri);
338 if let Some(ref p) = inbox_peer
339 && p.starts_with("__invalid__")
340 {
341 return error_response(
342 id,
343 -32602,
344 "subscribe URI must be wire://inbox/<peer> or wire://inbox/all",
345 );
346 }
347 if let Ok(mut g) = state.subscribed.lock() {
348 g.insert(uri);
349 }
350 json!({"jsonrpc": "2.0", "id": id, "result": {}})
351}
352
353fn handle_resources_unsubscribe(id: &Value, params: &Value, state: &McpState) -> Value {
354 let uri = match params.get("uri").and_then(Value::as_str) {
355 Some(u) => u.to_string(),
356 None => return error_response(id, -32602, "missing 'uri'"),
357 };
358 if let Ok(mut g) = state.subscribed.lock() {
359 g.remove(&uri);
360 }
361 json!({"jsonrpc": "2.0", "id": id, "result": {}})
362}
363
364fn handle_resources_read(id: &Value, params: &Value) -> Value {
365 let uri = match params.get("uri").and_then(Value::as_str) {
366 Some(u) => u,
367 None => return error_response(id, -32602, "missing 'uri'"),
368 };
369 let peer_opt = parse_inbox_uri(uri);
370 match read_inbox_resource(peer_opt) {
371 Ok(payload) => json!({
372 "jsonrpc": "2.0",
373 "id": id,
374 "result": {
375 "contents": [{
376 "uri": uri,
377 "mimeType": "application/x-ndjson",
378 "text": payload,
379 }]
380 }
381 }),
382 Err(e) => error_response(id, -32603, &e.to_string()),
383 }
384}
385
386fn parse_inbox_uri(uri: &str) -> Option<String> {
389 if let Some(rest) = uri.strip_prefix("wire://inbox/") {
390 if rest == "all" {
391 return None;
392 }
393 if !rest.is_empty() {
394 return Some(rest.to_string());
395 }
396 }
397 Some(format!("__invalid__{uri}"))
398}
399
400fn read_inbox_resource(peer_opt: Option<String>) -> Result<String, String> {
401 const LIMIT: usize = 50;
402 if let Some(ref p) = peer_opt
405 && p.starts_with("__invalid__")
406 {
407 return Err(
408 "unknown resource URI (must be wire://inbox/<peer> or wire://inbox/all)".into(),
409 );
410 }
411 let inbox = crate::config::inbox_dir().map_err(|e| e.to_string())?;
412 if !inbox.exists() {
413 return Ok(String::new());
414 }
415 let trust = crate::config::read_trust().map_err(|e| e.to_string())?;
416
417 let paths: Vec<std::path::PathBuf> = match peer_opt {
418 Some(p) => {
419 let path = inbox.join(format!("{p}.jsonl"));
420 if !path.exists() {
421 return Ok(String::new());
422 }
423 vec![path]
424 }
425 None => std::fs::read_dir(&inbox)
426 .map_err(|e| e.to_string())?
427 .flatten()
428 .map(|e| e.path())
429 .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
430 .collect(),
431 };
432
433 let mut events: Vec<(String, bool, Value)> = Vec::new();
434 for path in paths {
435 let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
436 let peer = path
437 .file_stem()
438 .and_then(|s| s.to_str())
439 .unwrap_or("")
440 .to_string();
441 for line in body.lines() {
442 let event: Value = match serde_json::from_str(line) {
443 Ok(v) => v,
444 Err(_) => continue,
445 };
446 let verified = crate::signing::verify_message_v31(&event, &trust).is_ok();
447 events.push((peer.clone(), verified, event));
448 }
449 }
450 let take_from = events.len().saturating_sub(LIMIT);
452 let tail = &events[take_from..];
453
454 let seed: Option<[u8; 32]> = crate::config::read_private_key()
458 .ok()
459 .and_then(|v| v.get(..32).and_then(|s| <[u8; 32]>::try_from(s).ok()));
460
461 let mut out = String::new();
462 for (_peer, verified, mut event) in tail.iter().cloned() {
463 if event.get("enc").and_then(Value::as_str)
465 == Some(crate::enc::wire_x25519::ENC_DISCRIMINATOR)
466 && let Some(ref s) = seed
467 && let Ok(Some(plain)) = crate::enc::wire_x25519::open_event_body(&event, &trust, s)
468 && let Some(obj) = event.as_object_mut()
469 {
470 obj.insert("body".into(), plain);
471 obj.insert("dec".into(), json!(true));
472 }
473 if let Some(obj) = event.as_object_mut() {
474 obj.insert("verified".into(), json!(verified));
475 }
476 out.push_str(&serde_json::to_string(&event).map_err(|e| e.to_string())?);
477 out.push('\n');
478 }
479 Ok(out)
480}
481
482fn handle_initialize(id: &Value) -> Value {
483 json!({
484 "jsonrpc": "2.0",
485 "id": id,
486 "result": {
487 "protocolVersion": PROTOCOL_VERSION,
488 "capabilities": {
489 "tools": {"listChanged": false},
490 "resources": {
491 "listChanged": false,
492 "subscribe": true
497 }
498 },
499 "serverInfo": {
500 "name": SERVER_NAME,
501 "version": SERVER_VERSION,
502 },
503 "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 sync loop is healthy — `daemon_running:true` + `last_sync_age_seconds < 60`. The daemon is auto-started for you when this MCP server launches, so this is normally already true; if daemon_running is false, run `wire up` (or `wire service install` for a reboot-durable daemon). (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)."
504 }
505 })
506}
507
508fn handle_tools_list(id: &Value) -> Value {
509 json!({
510 "jsonrpc": "2.0",
511 "id": id,
512 "result": {
513 "tools": tool_defs(),
514 }
515 })
516}
517
518fn tool_defs() -> Vec<Value> {
519 vec![
520 json!({
521 "name": "wire_whoami",
522 "description": "Return this agent's DID, fingerprint, key_id, public key, and capabilities. Read-only.",
523 "inputSchema": {"type": "object", "properties": {}, "required": []}
524 }),
525 json!({
526 "name": "wire_peers",
527 "description": "List pinned peers with their tier (UNTRUSTED/VERIFIED/ATTESTED) and advertised capabilities. Read-only.",
528 "inputSchema": {"type": "object", "properties": {}, "required": []}
529 }),
530 json!({
531 "name": "wire_here",
532 "description": "\"Who am I and who can I talk to?\" — the cold-start orientation tool. Returns {self: {handle, did, persona, cwd, wire_home}, sister_sessions: [...], pinned_peers: [...]}. Sister sessions are other agents on THIS machine you can reach with wire_dial by their `session` name (no relay round-trip); pinned_peers are already-paired contacts. Call this first when wire_peers is empty and you need to find a dial target. Read-only.",
533 "inputSchema": {"type": "object", "properties": {}, "required": []}
534 }),
535 json!({
536 "name": "wire_status",
537 "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. The daemon is auto-started for you on MCP launch; a healthy session shows daemon_running:true + last_sync_age_seconds < 60. Default `wire_send` is synchronous (its own status is the delivery verdict); only `queue:true` sends depend on the daemon to drain — a nonzero outbox_count with a stale last_sync means those are stuck. Read-only.",
538 "inputSchema": {"type": "object", "properties": {}, "required": []}
539 }),
540 json!({
541 "name": "wire_send",
542 "description": "Sign and send an event to a peer. Synchronous by default (v0.14.2): the response `status` is the actual relay verdict — `delivered`, `duplicate`, `peer_unknown` (run wire_dial first), `slot_stale` (run wire_dial to re-pair), or `transport_error` (see `reason`). Pass `queue:true` to opt into the legacy outbox→daemon-push path (offline buffer / pre-pair). Returns event_id (SHA-256 of canonical body — content-addressed, so identical bodies dedupe). Body may be plain text or JSON. Concurrent sends to different peers are safe; same-peer sends serialize via a per-path lock.",
543 "inputSchema": {
544 "type": "object",
545 "properties": {
546 "peer": {"type": "string", "description": "Peer handle (without did:wire: prefix). Must be a pinned peer; check wire_peers first."},
547 "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."},
548 "body": {"type": "string", "description": "Event body. Plain text becomes a JSON string; valid JSON is parsed and embedded structurally."},
549 "time_sensitive_until": {"type": "string", "description": "Optional advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp."}
550 },
551 "required": ["peer", "kind", "body"]
552 }
553 }),
554 json!({
555 "name": "wire_pull",
556 "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.",
557 "inputSchema": {"type": "object", "properties": {}, "required": []}
558 }),
559 json!({
560 "name": "wire_tail",
561 "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).",
562 "inputSchema": {
563 "type": "object",
564 "properties": {
565 "peer": {"type": "string", "description": "Optional peer handle to filter inbox by."},
566 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Max events to return."},
567 "oldest": {"type": "boolean", "default": false, "description": "Return the FIRST `limit` events (oldest-N) instead of the default last-N (newest-N)."}
568 },
569 "required": []
570 }
571 }),
572 json!({
573 "name": "wire_verify",
574 "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).",
575 "inputSchema": {
576 "type": "object",
577 "properties": {
578 "event": {"type": "string", "description": "JSON-encoded signed event."}
579 },
580 "required": ["event"]
581 }
582 }),
583 json!({
584 "name": "wire_init",
585 "description": "Rarely needed — identity auto-bootstraps when this MCP server starts. Idempotent manual identity creation: already initialized → returns the existing identity (no-op); different handle → errors (delete config to re-key). The typed handle is vestigial under the one-name rule (your handle is DID-derived). If relay_url is passed and not yet bound, also allocates a relay slot.",
586 "inputSchema": {
587 "type": "object",
588 "properties": {
589 "handle": {"type": "string", "description": "Short handle (becomes did:wire:<handle>). ASCII alphanumeric / '-' / '_' only."},
590 "name": {"type": "string", "description": "Optional display name (defaults to capitalized handle)."},
591 "relay_url": {"type": "string", "description": "Optional relay URL — if set, also binds a relay slot."}
592 },
593 "required": ["handle"]
594 }
595 }),
596 json!({
597 "name": "wire_invite_mint",
598 "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}.",
599 "inputSchema": {
600 "type": "object",
601 "properties": {
602 "relay_url": {"type": "string", "description": "Override relay for first-time auto-allocate."},
603 "ttl_secs": {"type": "integer", "description": "Invite lifetime in seconds (default 86400)."},
604 "uses": {"type": "integer", "description": "Number of distinct peers that can accept before consumption (default 1)."}
605 }
606 }
607 }),
608 json!({
609 "name": "wire_invite_accept",
610 "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}.",
611 "inputSchema": {
612 "type": "object",
613 "properties": {
614 "url": {"type": "string", "description": "Full wire://pair?v=1&inv=... URL."}
615 },
616 "required": ["url"]
617 }
618 }),
619 json!({
621 "name": "wire_add",
622 "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.",
623 "inputSchema": {
624 "type": "object",
625 "properties": {
626 "handle": {"type": "string", "description": "Peer handle like `nick@domain`."},
627 "relay_url": {"type": "string", "description": "Override resolver URL (default: `https://<domain>`)."}
628 },
629 "required": ["handle"]
630 }
631 }),
632 json!({
638 "name": "wire_dial",
639 "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.",
640 "inputSchema": {
641 "type": "object",
642 "properties": {
643 "name": {"type": "string", "description": "Peer name — character nickname / session / handle / DID / `<handle>@<relay>`."}
644 },
645 "required": ["name"]
646 }
647 }),
648 json!({
649 "name": "wire_accept",
650 "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.",
651 "inputSchema": {
652 "type": "object",
653 "properties": {
654 "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle, from wire_pending)."}
655 },
656 "required": ["peer"]
657 }
658 }),
659 json!({
660 "name": "wire_reject",
661 "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.",
662 "inputSchema": {
663 "type": "object",
664 "properties": {
665 "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle)."}
666 },
667 "required": ["peer"]
668 }
669 }),
670 json!({
671 "name": "wire_pending",
672 "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.",
673 "inputSchema": {"type": "object", "properties": {}}
674 }),
675 json!({
676 "name": "wire_claim",
677 "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).",
678 "inputSchema": {
679 "type": "object",
680 "properties": {
681 "nick": {"type": "string", "description": "Optional + advisory. Ignored if it differs from your DID-derived persona (one-name rule)."},
682 "relay_url": {"type": "string", "description": "Relay to claim on. Default = our relay."},
683 "public_url": {"type": "string", "description": "Public URL the relay should advertise to resolvers."}
684 }
685 }
686 }),
687 json!({
688 "name": "wire_whois",
689 "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.",
690 "inputSchema": {
691 "type": "object",
692 "properties": {
693 "handle": {"type": "string", "description": "Optional `nick@domain`. Omit for self."},
694 "relay_url": {"type": "string", "description": "Override resolver URL."}
695 }
696 }
697 }),
698 json!({
699 "name": "wire_profile_set",
700 "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.",
701 "inputSchema": {
702 "type": "object",
703 "properties": {
704 "field": {"type": "string", "description": "One of: display_name, emoji, motto, vibe, pronouns, avatar_url, handle, now."},
705 "value": {"description": "String for most fields; array for vibe; object for now. Pass JSON null to clear a field."}
706 },
707 "required": ["field", "value"]
708 }
709 }),
710 json!({
711 "name": "wire_profile_get",
712 "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.",
713 "inputSchema": {"type": "object", "properties": {}}
714 }),
715 json!({
720 "name": "wire_group_create",
721 "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.",
722 "inputSchema": {
723 "type": "object",
724 "properties": {"name": {"type": "string", "description": "Human label for the group."}},
725 "required": ["name"]
726 }
727 }),
728 json!({
729 "name": "wire_group_add",
730 "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.",
731 "inputSchema": {
732 "type": "object",
733 "properties": {
734 "group": {"type": "string", "description": "Group id or name."},
735 "peer": {"type": "string", "description": "Handle of a VERIFIED pinned peer."}
736 },
737 "required": ["group", "peer"]
738 }
739 }),
740 json!({
741 "name": "wire_group_send",
742 "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).",
743 "inputSchema": {
744 "type": "object",
745 "properties": {
746 "group": {"type": "string", "description": "Group id or name."},
747 "message": {"type": "string", "description": "Message text."}
748 },
749 "required": ["group", "message"]
750 }
751 }),
752 json!({
753 "name": "wire_group_tail",
754 "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.",
755 "inputSchema": {
756 "type": "object",
757 "properties": {
758 "group": {"type": "string", "description": "Group id or name."},
759 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 20, "description": "Max timeline entries to return."}
760 },
761 "required": ["group"]
762 }
763 }),
764 json!({
765 "name": "wire_group_list",
766 "description": "List the groups this agent is in, with each group's members and their GroupTiers (creator/member/introduced). Read-only, local.",
767 "inputSchema": {"type": "object", "properties": {}, "required": []}
768 }),
769 json!({
770 "name": "wire_group_invite",
771 "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.",
772 "inputSchema": {
773 "type": "object",
774 "properties": {"group": {"type": "string", "description": "Group id or name."}},
775 "required": ["group"]
776 }
777 }),
778 json!({
779 "name": "wire_group_join",
780 "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.",
781 "inputSchema": {
782 "type": "object",
783 "properties": {"code": {"type": "string", "description": "The `wire-group:` join code."}},
784 "required": ["code"]
785 }
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_status" => tool_status(),
803 "wire_peers" => tool_peers(),
804 "wire_here" => tool_here(),
805 "wire_send" => tool_send(&args),
806 "wire_pull" => tool_pull(),
807 "wire_tail" => tool_tail(&args),
808 "wire_verify" => tool_verify(&args),
809 "wire_init" => tool_init(&args),
810 "wire_invite_mint" => tool_invite_mint(&args),
811 "wire_invite_accept" => tool_invite_accept(&args),
812 "wire_add" => tool_add(&args),
814 "wire_accept" => tool_pair_accept(&args),
819 "wire_reject" => tool_pair_reject(&args),
820 "wire_pending" => tool_pair_list_inbound(),
821 "wire_pair_accept" => Err("wire_pair_accept was renamed to wire_accept (v0.9+). \
822 Use wire_accept instead."
823 .into()),
824 "wire_pair_reject" => Err("wire_pair_reject was renamed to wire_reject (v0.9+). \
825 Use wire_reject instead."
826 .into()),
827 "wire_pair_list_inbound" => Err(
828 "wire_pair_list_inbound was renamed to wire_pending (v0.9+). \
829 Use wire_pending instead."
830 .into(),
831 ),
832 "wire_dial" => tool_dial(&args),
833 "wire_claim" => tool_claim_handle(&args),
834 "wire_whois" => tool_whois(&args),
835 "wire_profile_set" => tool_profile_set(&args),
836 "wire_profile_get" => tool_profile_get(),
837 "wire_group_create" => tool_group_create(&args),
839 "wire_group_add" => tool_group_add(&args),
840 "wire_group_send" => tool_group_send(&args),
841 "wire_group_tail" => tool_group_tail(&args),
842 "wire_group_list" => tool_group_list(),
843 "wire_group_invite" => tool_group_invite(&args),
844 "wire_group_join" => tool_group_join(&args),
845 "wire_join" => Err("wire_join (SAS code-phrase pairing) was removed. \
849 Use wire_dial(\"<handle>@<relay>\") to pair by handle. \
850 See docs/AGENT_INTEGRATION.md."
851 .into()),
852 other => Err(format!("unknown tool: {other}")),
853 };
854
855 match result {
856 Ok(value) => json!({
857 "jsonrpc": "2.0",
858 "id": id,
859 "result": {
860 "content": [{
861 "type": "text",
862 "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
863 }],
864 "isError": false
865 }
866 }),
867 Err(message) => json!({
868 "jsonrpc": "2.0",
869 "id": id,
870 "result": {
871 "content": [{"type": "text", "text": message}],
872 "isError": true
873 }
874 }),
875 }
876}
877
878fn tool_whoami() -> Result<Value, String> {
881 use crate::config;
882 use crate::signing::{b64decode, fingerprint, make_key_id};
883
884 if !config::is_initialized().map_err(|e| e.to_string())? {
885 return Err("not initialized — operator must run `wire up` first".into());
886 }
887 let card = config::read_agent_card().map_err(|e| e.to_string())?;
888 let did = card
889 .get("did")
890 .and_then(Value::as_str)
891 .unwrap_or("")
892 .to_string();
893 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
894 let pk_b64 = card
895 .get("verify_keys")
896 .and_then(Value::as_object)
897 .and_then(|m| m.values().next())
898 .and_then(|v| v.get("key"))
899 .and_then(Value::as_str)
900 .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
901 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
902 let fp = fingerprint(&pk_bytes);
903 let key_id = make_key_id(&handle, &pk_bytes);
904 let capabilities = card
905 .get("capabilities")
906 .cloned()
907 .unwrap_or_else(|| json!(["wire/v3.2"]));
908 let persona =
912 serde_json::to_value(crate::character::Character::from_card(&card)).unwrap_or(Value::Null);
913 let mut payload = serde_json::Map::new();
919 payload.insert("did".into(), json!(did));
920 payload.insert("handle".into(), json!(handle));
921 payload.insert("persona".into(), persona);
922 payload.insert("fingerprint".into(), json!(fp));
923 payload.insert("key_id".into(), json!(key_id));
924 payload.insert("public_key_b64".into(), json!(pk_b64));
925 payload.insert("capabilities".into(), capabilities);
926 payload.insert(
930 "session_source".into(),
931 json!(crate::session::session_source()),
932 );
933 for (k, v) in crate::cli::op_claims_from_card(&card) {
934 payload.insert(k, v);
935 }
936 Ok(Value::Object(payload))
937}
938
939fn tool_peers() -> Result<Value, String> {
940 use crate::config;
941
942 let trust = config::read_trust().map_err(|e| e.to_string())?;
943 let agents = trust
944 .get("agents")
945 .and_then(Value::as_object)
946 .cloned()
947 .unwrap_or_default();
948 let relay_state =
956 config::read_relay_state().unwrap_or_else(|_| json!({"self": null, "peers": {}}));
957 let mut self_did: Option<String> = None;
958 if let Ok(card) = config::read_agent_card() {
959 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
960 }
961 let mut peers = Vec::new();
962 for (handle, agent) in agents.iter() {
963 let did = agent
964 .get("did")
965 .and_then(Value::as_str)
966 .unwrap_or("")
967 .to_string();
968 if Some(did.as_str()) == self_did.as_deref() {
969 continue;
970 }
971 let persona = match agent.get("card") {
975 Some(c) => crate::character::Character::from_card(c),
976 None => crate::character::Character::from_did(&did),
977 };
978 let peer_op_claims = agent
983 .get("card")
984 .map(crate::cli::op_claims_from_card)
985 .unwrap_or_default();
986 let mut row = serde_json::Map::new();
987 row.insert("handle".into(), json!(handle));
988 row.insert(
989 "persona".into(),
990 serde_json::to_value(&persona).unwrap_or(Value::Null),
991 );
992 row.insert("did".into(), json!(did));
993 row.insert(
994 "tier".into(),
995 json!(crate::trust::effective_tier(&trust, &relay_state, handle)),
996 );
997 row.insert(
998 "capabilities".into(),
999 agent
1000 .get("card")
1001 .and_then(|c| c.get("capabilities"))
1002 .cloned()
1003 .unwrap_or_else(|| json!([])),
1004 );
1005 for (k, v) in peer_op_claims {
1006 row.insert(k, v);
1007 }
1008 peers.push(Value::Object(row));
1009 }
1010 Ok(json!(peers))
1011}
1012
1013fn group_cli_json(args: &[&str]) -> Result<Value, String> {
1018 let exe = std::env::current_exe().map_err(|e| format!("locating wire binary: {e}"))?;
1019 let out = std::process::Command::new(exe)
1020 .arg("group")
1021 .args(args)
1022 .arg("--json")
1023 .env("WIRE_QUIET_AUTOSESSION", "1") .output()
1025 .map_err(|e| format!("spawning `wire group`: {e}"))?;
1026 if !out.status.success() {
1027 let err = String::from_utf8_lossy(&out.stderr);
1028 return Err(err.trim().to_string());
1029 }
1030 let s = String::from_utf8_lossy(&out.stdout);
1031 let line = s
1033 .lines()
1034 .rev()
1035 .find(|l| l.trim_start().starts_with('{'))
1036 .unwrap_or("{}");
1037 serde_json::from_str(line).map_err(|e| format!("parsing `wire group` output: {e}"))
1038}
1039
1040fn tool_group_create(args: &Value) -> Result<Value, String> {
1041 let name = args
1042 .get("name")
1043 .and_then(Value::as_str)
1044 .ok_or("missing 'name'")?;
1045 group_cli_json(&["create", name])
1046}
1047
1048fn tool_group_add(args: &Value) -> Result<Value, String> {
1049 let group = args
1050 .get("group")
1051 .and_then(Value::as_str)
1052 .ok_or("missing 'group'")?;
1053 let peer = args
1054 .get("peer")
1055 .and_then(Value::as_str)
1056 .ok_or("missing 'peer'")?;
1057 group_cli_json(&["add", group, peer])
1058}
1059
1060fn tool_group_send(args: &Value) -> Result<Value, String> {
1061 let group = args
1062 .get("group")
1063 .and_then(Value::as_str)
1064 .ok_or("missing 'group'")?;
1065 let message = args
1066 .get("message")
1067 .and_then(Value::as_str)
1068 .ok_or("missing 'message'")?;
1069 group_cli_json(&["send", group, message])
1070}
1071
1072fn tool_group_tail(args: &Value) -> Result<Value, String> {
1073 let group = args
1074 .get("group")
1075 .and_then(Value::as_str)
1076 .ok_or("missing 'group'")?;
1077 if let Some(n) = args.get("limit").and_then(Value::as_u64) {
1078 group_cli_json(&["tail", group, "--limit", &n.to_string()])
1079 } else {
1080 group_cli_json(&["tail", group])
1081 }
1082}
1083
1084fn tool_group_list() -> Result<Value, String> {
1085 group_cli_json(&["list"])
1086}
1087
1088fn tool_group_invite(args: &Value) -> Result<Value, String> {
1089 let group = args
1090 .get("group")
1091 .and_then(Value::as_str)
1092 .ok_or("missing 'group'")?;
1093 group_cli_json(&["invite", group])
1094}
1095
1096fn tool_group_join(args: &Value) -> Result<Value, String> {
1097 let code = args
1098 .get("code")
1099 .and_then(Value::as_str)
1100 .ok_or("missing 'code'")?;
1101 group_cli_json(&["join", code])
1102}
1103
1104fn tool_status() -> Result<Value, String> {
1114 use crate::config;
1115
1116 let initialized = config::is_initialized().unwrap_or(false);
1117 if !initialized {
1118 return Ok(json!({
1119 "initialized": false,
1120 "daemon_running": false,
1121 "last_sync_age_seconds": Value::Null,
1122 }));
1123 }
1124
1125 let snap = crate::ensure_up::daemon_liveness();
1126 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1127 let last_sync_record = crate::ensure_up::read_last_sync_record();
1128
1129 let mut daemon = json!({
1130 "running": snap.pidfile_alive,
1131 "pid": snap.pidfile_pid,
1132 "all_running_pids": snap.pgrep_pids,
1133 "orphans": snap.orphan_pids,
1134 });
1135 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
1136 daemon["version"] = json!(d.version);
1137 daemon["bin_path"] = json!(d.bin_path);
1138 daemon["did"] = json!(d.did);
1139 daemon["relay_url"] = json!(d.relay_url);
1140 daemon["started_at"] = json!(d.started_at);
1141 }
1142
1143 let (last_sync_at, last_sync_push_n, last_sync_pull_n, last_sync_rejected_n) =
1144 match last_sync_record {
1145 Some(rec) => (
1146 Some(rec.ts),
1147 Some(rec.push_n),
1148 Some(rec.pull_n),
1149 Some(rec.rejected_n),
1150 ),
1151 None => (None, None, None, None),
1152 };
1153
1154 let outbox_count = config::outbox_dir()
1155 .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1156 .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1157 .unwrap_or(0);
1158 let inbox_count = config::inbox_dir()
1159 .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1160 .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1161 .unwrap_or(0);
1162
1163 let pending_push_breakdown = config::compute_pending_push_breakdown();
1171 let pending_push_count: u64 = pending_push_breakdown.iter().map(|p| p.count).sum();
1172
1173 let stream_state = config::read_stream_state();
1179
1180 Ok(json!({
1181 "initialized": true,
1182 "daemon": daemon,
1183 "daemon_running": snap.pidfile_alive,
1184 "last_sync_at": last_sync_at,
1185 "last_sync_age_seconds": last_sync_age,
1186 "last_sync_push_n": last_sync_push_n,
1187 "last_sync_pull_n": last_sync_pull_n,
1188 "last_sync_rejected_n": last_sync_rejected_n,
1189 "stale_sync": config::stale_sync(last_sync_age),
1190 "outbox_count": outbox_count,
1191 "inbox_count": inbox_count,
1192 "pending_push_count": pending_push_count,
1193 "pending_push_breakdown": pending_push_breakdown,
1194 "stream_state": stream_state,
1195 }))
1196}
1197
1198fn tool_send(args: &Value) -> Result<Value, String> {
1199 use crate::config;
1200 use crate::signing::{b64decode, sign_message_v31};
1201
1202 let peer = args
1203 .get("peer")
1204 .and_then(Value::as_str)
1205 .ok_or("missing 'peer'")?;
1206 let peer = crate::agent_card::bare_handle(peer);
1207 let kind = args
1208 .get("kind")
1209 .and_then(Value::as_str)
1210 .ok_or("missing 'kind'")?;
1211 let body = args
1212 .get("body")
1213 .and_then(Value::as_str)
1214 .ok_or("missing 'body'")?;
1215 let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
1216 let queue = args.get("queue").and_then(Value::as_bool).unwrap_or(false);
1221
1222 if !config::is_initialized().map_err(|e| e.to_string())? {
1223 return Err("not initialized — operator must run `wire up` first".into());
1224 }
1225 let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
1226 let card = config::read_agent_card().map_err(|e| e.to_string())?;
1227 let did = card
1228 .get("did")
1229 .and_then(Value::as_str)
1230 .unwrap_or("")
1231 .to_string();
1232 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1233 let pk_b64 = card
1234 .get("verify_keys")
1235 .and_then(Value::as_object)
1236 .and_then(|m| m.values().next())
1237 .and_then(|v| v.get("key"))
1238 .and_then(Value::as_str)
1239 .ok_or("agent-card missing verify_keys[*].key")?;
1240 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1241
1242 let body_value: Value =
1244 serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1245 let kind_id = parse_kind(kind);
1246
1247 let now = time::OffsetDateTime::now_utc()
1248 .format(&time::format_description::well_known::Rfc3339)
1249 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1250
1251 let trust_for_did = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
1259 let to_did = crate::trust::resolve_peer_did(&trust_for_did, peer);
1260 let mut event = json!({
1261 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
1264 "timestamp": now,
1265 "from": did,
1266 "to": to_did,
1267 "type": kind,
1268 "kind": kind_id,
1269 "body": body_value,
1270 });
1271 if let Some(deadline) = deadline {
1272 event["time_sensitive_until"] =
1273 json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1274 }
1275 if let Some(peer_dh) = crate::enc::wire_x25519::peer_dh_pubkey(&trust_for_did, peer) {
1278 crate::enc::wire_x25519::seal_event_body(&mut event, &peer_dh, &sk_seed)
1279 .map_err(|e| e.to_string())?;
1280 }
1281 let signed =
1282 sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1283 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1284
1285 if !queue {
1290 let outcome = crate::send::attempt_deliver(peer, &signed).map_err(|e| e.to_string())?;
1291 let mut v = crate::send::delivery_json(&outcome, peer);
1292 let snap = crate::ensure_up::daemon_liveness();
1298 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1299 if let Some(obj) = v.as_object_mut() {
1300 obj.insert("daemon_seen".into(), json!(snap.pidfile_alive));
1301 obj.insert("last_sync_age_seconds".into(), json!(last_sync_age));
1302 obj.insert(
1303 "stale_sync".into(),
1304 json!(config::stale_sync(last_sync_age)),
1305 );
1306 }
1307 return Ok(v);
1308 }
1309
1310 let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1312 let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1313 let snap = crate::ensure_up::daemon_liveness();
1314 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1315 let peer_pinned_in_trust = trust_for_did
1322 .get("agents")
1323 .and_then(Value::as_object)
1324 .map(|a| a.contains_key(peer))
1325 .unwrap_or(false);
1326 let peer_in_relay_state = config::read_relay_state()
1327 .ok()
1328 .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
1329 .map(|peers| peers.contains_key(peer))
1330 .unwrap_or(false);
1331 let pending_inbound = crate::pending_inbound_pair::list_pending_inbound()
1332 .ok()
1333 .map(|v| v.iter().any(|p| p.peer_handle == peer))
1334 .unwrap_or(false);
1335 let unpushable = !peer_pinned_in_trust && !peer_in_relay_state && !pending_inbound;
1336 let mut out = json!({
1337 "event_id": event_id,
1338 "status": "queued",
1339 "peer": peer,
1340 "outbox": outbox.to_string_lossy(),
1341 "daemon_seen": snap.pidfile_alive,
1342 "last_sync_age_seconds": last_sync_age,
1343 "stale_sync": config::stale_sync(last_sync_age),
1344 });
1345 if unpushable {
1346 out["warning"] = json!(format!(
1347 "`{peer}` is not pinned and has no pending pair — the event will sit in outbox forever unless you pair first (wire_dial)."
1348 ));
1349 }
1350 Ok(out)
1351}
1352
1353fn tool_pull() -> Result<Value, String> {
1360 crate::cli::run_sync_pull().map_err(|e| format!("{e:#}"))
1361}
1362
1363fn tool_tail(args: &Value) -> Result<Value, String> {
1364 use crate::config;
1365 use crate::signing::verify_message_v31;
1366
1367 let peer_filter = args.get("peer").and_then(Value::as_str);
1368 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1369 let oldest = args.get("oldest").and_then(Value::as_bool).unwrap_or(false);
1374 let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1375 if !inbox.exists() {
1376 return Ok(json!([]));
1377 }
1378 let trust = config::read_trust().map_err(|e| e.to_string())?;
1379 let seed = crate::enc::wire_x25519::self_seed_for_read();
1380 let entries: Vec<_> = std::fs::read_dir(&inbox)
1381 .map_err(|e| e.to_string())?
1382 .filter_map(|e| e.ok())
1383 .map(|e| e.path())
1384 .filter(|p| {
1385 p.extension().map(|x| x == "jsonl").unwrap_or(false)
1386 && match peer_filter {
1387 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1388 None => true,
1389 }
1390 })
1391 .collect();
1392
1393 let mut collected: Vec<(String, usize, Value)> = Vec::new();
1396 for path in &entries {
1397 let body = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1398 for (idx, line) in body.lines().enumerate() {
1399 let event: Value = match serde_json::from_str(line) {
1400 Ok(v) => v,
1401 Err(_) => continue,
1402 };
1403 let verified = verify_message_v31(&event, &trust).is_ok();
1404 let mut event_with_meta = match &seed {
1406 Some(s) => crate::enc::wire_x25519::decrypt_event_for_read(&event, &trust, s),
1407 None => event.clone(),
1408 };
1409 if let Some(obj) = event_with_meta.as_object_mut() {
1410 obj.insert("verified".into(), json!(verified));
1411 }
1412 let ts = event
1413 .get("timestamp")
1414 .and_then(Value::as_str)
1415 .unwrap_or("")
1416 .to_string();
1417 collected.push((ts, idx, event_with_meta));
1418 }
1419 }
1420 collected.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
1421
1422 let total = collected.len();
1423 let window: Vec<Value> = if limit == 0 {
1424 collected.into_iter().map(|(_, _, e)| e).collect()
1425 } else if oldest {
1426 collected
1427 .into_iter()
1428 .take(limit)
1429 .map(|(_, _, e)| e)
1430 .collect()
1431 } else {
1432 let start = total.saturating_sub(limit);
1433 collected
1434 .into_iter()
1435 .skip(start)
1436 .map(|(_, _, e)| e)
1437 .collect()
1438 };
1439 Ok(Value::Array(window))
1440}
1441
1442fn tool_verify(args: &Value) -> Result<Value, String> {
1443 use crate::config;
1444 use crate::signing::verify_message_v31;
1445
1446 let event_str = args
1447 .get("event")
1448 .and_then(Value::as_str)
1449 .ok_or("missing 'event'")?;
1450 let event: Value =
1451 serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1452 let trust = config::read_trust().map_err(|e| e.to_string())?;
1453 match verify_message_v31(&event, &trust) {
1454 Ok(()) => Ok(json!({"verified": true})),
1455 Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1456 }
1457}
1458
1459fn ensure_session_bootstrapped() {
1468 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
1469 return;
1470 }
1471 if crate::config::is_initialized().unwrap_or(false) {
1472 return; }
1474 let (did, relay_url, slot_id, slot_token) =
1475 match crate::pair_invite::ensure_self_with_relay(None) {
1476 Ok(t) => t,
1477 Err(_) => return, };
1479 if let Ok(card) = crate::config::read_agent_card() {
1480 let persona = crate::agent_card::display_handle_from_did(&did).to_string();
1481 let client = crate::relay_client::RelayClient::new(&relay_url);
1482 let _ = client.handle_claim_v2(&persona, &slot_id, &slot_token, None, &card, None);
1483 }
1484}
1485
1486fn tool_init(args: &Value) -> Result<Value, String> {
1487 let handle = args
1488 .get("handle")
1489 .and_then(Value::as_str)
1490 .ok_or("missing 'handle'")?;
1491 let name = args.get("name").and_then(Value::as_str);
1492 let relay = args.get("relay_url").and_then(Value::as_str);
1493 crate::init::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1494}
1495
1496fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1499 let relay_url = args.get("relay_url").and_then(Value::as_str);
1500 let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1501 let uses = args
1502 .get("uses")
1503 .and_then(Value::as_u64)
1504 .map(|u| u as u32)
1505 .unwrap_or(1);
1506 let url =
1507 crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1508 let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1509 Ok(json!({
1510 "invite_url": url,
1511 "ttl_secs": ttl_resolved,
1512 "uses": uses,
1513 }))
1514}
1515
1516fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1517 let url = args
1518 .get("url")
1519 .and_then(Value::as_str)
1520 .ok_or("missing 'url'")?;
1521 crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1522}
1523
1524fn tool_here() -> Result<Value, String> {
1529 crate::cli::here_summary().map_err(|e| format!("{e:#}"))
1530}
1531
1532fn tool_dial(args: &Value) -> Result<Value, String> {
1544 let name = args
1545 .get("name")
1546 .and_then(Value::as_str)
1547 .or_else(|| args.get("handle").and_then(Value::as_str))
1548 .ok_or("missing 'name'")?;
1549
1550 if name.contains('@') {
1551 let mut a = args.clone();
1553 if let Some(obj) = a.as_object_mut() {
1554 obj.insert("handle".into(), Value::String(name.to_string()));
1555 }
1556 return tool_add(&a);
1557 }
1558
1559 match crate::cli::resolve_name_to_target(name) {
1564 Ok(crate::cli::DialTarget::PinnedPeer {
1565 handle, did, tier, ..
1566 }) => Ok(json!({
1567 "name_input": name,
1568 "status": "already_pinned",
1569 "peer_handle": handle,
1570 "did": did,
1571 "tier": tier,
1572 })),
1573 Ok(crate::cli::DialTarget::LocalSister { session_name, .. }) => {
1574 let drop =
1575 crate::cli::add_local_sister_core(&session_name).map_err(|e| format!("{e:#}"))?;
1576 Ok(json!({
1577 "name_input": name,
1578 "status": "paired_local_sister",
1579 "peer_handle": drop.peer_handle,
1580 "paired_with": drop.paired_with_did,
1581 "event_id": drop.event_id,
1582 "delivered_via": drop.delivered_via,
1583 }))
1584 }
1585 Err(e) => Err(format!("{e:#}")),
1588 }
1589}
1590
1591fn tool_add(args: &Value) -> Result<Value, String> {
1592 let handle = args
1593 .get("handle")
1594 .and_then(Value::as_str)
1595 .ok_or("missing 'handle'")?;
1596 let relay_override = args.get("relay_url").and_then(Value::as_str);
1597
1598 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1599
1600 let (our_did, our_relay, our_slot_id, our_slot_token) =
1602 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1603
1604 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1606 .map_err(|e| format!("{e:#}"))?;
1607 let peer_card = resolved
1608 .get("card")
1609 .cloned()
1610 .ok_or("resolved missing card")?;
1611 let peer_did = resolved
1612 .get("did")
1613 .and_then(Value::as_str)
1614 .ok_or("resolved missing did")?
1615 .to_string();
1616 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1617 let peer_slot_id = resolved
1618 .get("slot_id")
1619 .and_then(Value::as_str)
1620 .ok_or("resolved missing slot_id")?
1621 .to_string();
1622 let peer_relay = resolved
1623 .get("relay_url")
1624 .and_then(Value::as_str)
1625 .map(str::to_string)
1626 .or_else(|| relay_override.map(str::to_string))
1627 .unwrap_or_else(|| format!("https://{}", parsed.domain));
1628
1629 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1631 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
1632 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1633 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1634 let existing_token = relay_state
1635 .get("peers")
1636 .and_then(|p| p.get(&peer_handle))
1637 .and_then(|p| p.get("slot_token"))
1638 .and_then(Value::as_str)
1639 .map(str::to_string)
1640 .unwrap_or_default();
1641 crate::endpoints::pin_peer_endpoints(
1643 &mut relay_state,
1644 &peer_handle,
1645 &[crate::endpoints::Endpoint::federation(
1646 peer_relay.clone(),
1647 peer_slot_id.clone(),
1648 existing_token.clone(),
1649 )],
1650 )
1651 .map_err(|e| format!("{e:#}"))?;
1652 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1653
1654 let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1656 let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1657 let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1658 let pk_b64 = our_card
1659 .get("verify_keys")
1660 .and_then(Value::as_object)
1661 .and_then(|m| m.values().next())
1662 .and_then(|v| v.get("key"))
1663 .and_then(Value::as_str)
1664 .ok_or("our card missing verify_keys[*].key")?;
1665 let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1666 let now = time::OffsetDateTime::now_utc()
1667 .format(&time::format_description::well_known::Rfc3339)
1668 .unwrap_or_default();
1669 let event = json!({
1670 "timestamp": now,
1671 "from": our_did,
1672 "to": peer_did,
1673 "type": "pair_drop",
1674 "kind": 1100u32,
1675 "body": {
1676 "card": our_card,
1677 "relay_url": our_relay,
1678 "slot_id": our_slot_id,
1679 "slot_token": our_slot_token,
1680 },
1681 });
1682 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
1683 .map_err(|e| format!("{e:#}"))?;
1684
1685 let client = crate::relay_client::RelayClient::new(&peer_relay);
1686 let resp = client
1687 .handle_intro(&parsed.nick, &signed)
1688 .map_err(|e| format!("{e:#}"))?;
1689 let event_id = signed
1690 .get("event_id")
1691 .and_then(Value::as_str)
1692 .unwrap_or("")
1693 .to_string();
1694 Ok(json!({
1695 "handle": handle,
1696 "paired_with": peer_did,
1697 "peer_handle": peer_handle,
1698 "event_id": event_id,
1699 "drop_response": resp,
1700 "status": "drop_sent",
1701 }))
1702}
1703
1704fn tool_pair_accept(args: &Value) -> Result<Value, String> {
1709 let peer = args
1710 .get("peer")
1711 .and_then(Value::as_str)
1712 .ok_or("missing 'peer'")?;
1713 let nick = crate::agent_card::bare_handle(peer);
1714 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
1715 .map_err(|e| format!("{e:#}"))?
1716 .ok_or_else(|| {
1717 format!(
1718 "no pending pair request from {nick}. Call wire_pending to enumerate, \
1719 or wire_add to send a fresh outbound pair request."
1720 )
1721 })?;
1722
1723 let mut trust = crate::config::read_trust().map_err(|e| format!("{e:#}"))?;
1726 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
1727 crate::config::write_trust(&trust).map_err(|e| format!("{e:#}"))?;
1728
1729 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1731 relay_state["peers"][&pending.peer_handle] = json!({
1732 "relay_url": pending.peer_relay_url,
1733 "slot_id": pending.peer_slot_id,
1734 "slot_token": pending.peer_slot_token,
1735 });
1736 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1737
1738 let ack_endpoints: Vec<crate::endpoints::Endpoint> = if pending.peer_endpoints.is_empty() {
1745 vec![crate::endpoints::Endpoint::federation(
1746 pending.peer_relay_url.clone(),
1747 pending.peer_slot_id.clone(),
1748 pending.peer_slot_token.clone(),
1749 )]
1750 } else {
1751 pending.peer_endpoints.clone()
1752 };
1753 crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &ack_endpoints).map_err(|e| {
1754 format!(
1755 "pair_drop_ack send to {} (across {} endpoint(s)) failed: {e:#}",
1756 pending.peer_handle,
1757 ack_endpoints.len()
1758 )
1759 })?;
1760
1761 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1762
1763 Ok(json!({
1764 "status": "bilateral_accepted",
1765 "peer_handle": pending.peer_handle,
1766 "peer_did": pending.peer_did,
1767 "peer_relay_url": pending.peer_relay_url,
1768 "via": "pending_inbound",
1769 }))
1770}
1771
1772fn tool_pair_reject(args: &Value) -> Result<Value, String> {
1776 let peer = args
1777 .get("peer")
1778 .and_then(Value::as_str)
1779 .ok_or("missing 'peer'")?;
1780 let nick = crate::agent_card::bare_handle(peer);
1781 let existed =
1782 crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1783 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1784 Ok(json!({
1785 "peer": nick,
1786 "rejected": existed.is_some(),
1787 "had_pending": existed.is_some(),
1788 }))
1789}
1790
1791fn tool_pair_list_inbound() -> Result<Value, String> {
1795 let items =
1796 crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
1797 Ok(json!(items))
1798}
1799
1800fn tool_claim_handle(args: &Value) -> Result<Value, String> {
1801 let typed = args.get("nick").and_then(Value::as_str);
1802 let relay_override = args.get("relay_url").and_then(Value::as_str);
1803 let public_url = args.get("public_url").and_then(Value::as_str);
1804
1805 let (_, our_relay, our_slot_id, our_slot_token) =
1807 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1808 let claim_relay = relay_override.unwrap_or(&our_relay);
1809 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1810
1811 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
1816 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
1817 let nick = if canonical.is_empty() {
1818 typed.unwrap_or_default().to_string()
1819 } else {
1820 canonical
1821 };
1822 let typed_nick_ignored = typed.map(|t| t != nick).unwrap_or(false);
1823
1824 let client = crate::relay_client::RelayClient::new(claim_relay);
1825 let resp = client
1826 .handle_claim(&nick, &our_slot_id, &our_slot_token, public_url, &card)
1827 .map_err(|e| format!("{e:#}"))?;
1828 Ok(json!({
1829 "nick": nick,
1830 "relay": claim_relay,
1831 "response": resp,
1832 "one_name": true,
1833 "typed_nick_ignored": typed_nick_ignored,
1834 }))
1835}
1836
1837fn tool_whois(args: &Value) -> Result<Value, String> {
1838 if let Some(handle) = args.get("handle").and_then(Value::as_str) {
1839 if !handle.contains('@')
1849 && let Ok(target) = crate::cli::resolve_name_to_target(handle)
1850 {
1851 return Ok(dial_target_to_whois_json(&target));
1852 }
1853 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1854 let relay_override = args.get("relay_url").and_then(Value::as_str);
1855 crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
1856 } else {
1857 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1861 let mut payload = serde_json::Map::new();
1862 payload.insert(
1863 "did".into(),
1864 card.get("did").cloned().unwrap_or(Value::Null),
1865 );
1866 payload.insert(
1867 "profile".into(),
1868 card.get("profile").cloned().unwrap_or(Value::Null),
1869 );
1870 for (k, v) in crate::cli::op_claims_from_card(&card) {
1871 payload.insert(k, v);
1872 }
1873 Ok(Value::Object(payload))
1874 }
1875}
1876
1877fn dial_target_to_whois_json(target: &crate::cli::DialTarget) -> Value {
1883 use crate::cli::DialTarget;
1884 match target {
1885 DialTarget::PinnedPeer {
1886 handle,
1887 did,
1888 nickname,
1889 emoji,
1890 tier,
1891 } => {
1892 let op_claims = crate::config::read_trust()
1893 .ok()
1894 .and_then(|t| {
1895 t.get("agents")
1896 .and_then(Value::as_object)
1897 .and_then(|m| m.get(handle))
1898 .and_then(|a| a.get("card").cloned())
1899 })
1900 .map(|c| crate::cli::op_claims_from_card(&c))
1901 .unwrap_or_default();
1902 let mut payload = serde_json::Map::new();
1903 payload.insert("kind".into(), json!("pinned_peer"));
1904 payload.insert("handle".into(), json!(handle));
1905 payload.insert("did".into(), json!(did));
1906 payload.insert("nickname".into(), json!(nickname));
1907 payload.insert("emoji".into(), json!(emoji));
1908 payload.insert("tier".into(), json!(tier));
1909 for (k, v) in op_claims {
1910 payload.insert(k, v);
1911 }
1912 Value::Object(payload)
1913 }
1914 DialTarget::LocalSister {
1915 session_name,
1916 handle,
1917 did,
1918 nickname,
1919 emoji,
1920 } => json!({
1921 "kind": "local_sister",
1922 "session_name": session_name,
1923 "handle": handle,
1924 "did": did,
1925 "nickname": nickname,
1926 "emoji": emoji,
1927 }),
1928 }
1929}
1930
1931fn tool_profile_set(args: &Value) -> Result<Value, String> {
1932 let field = args
1933 .get("field")
1934 .and_then(Value::as_str)
1935 .ok_or("missing 'field'")?;
1936 let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
1937 let value = if let Some(s) = raw_value.as_str() {
1941 serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
1942 } else {
1943 raw_value
1944 };
1945 let new_profile =
1946 crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
1947 Ok(json!({
1948 "field": field,
1949 "profile": new_profile,
1950 }))
1951}
1952
1953fn tool_profile_get() -> Result<Value, String> {
1954 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1955 Ok(json!({
1956 "did": card.get("did").cloned().unwrap_or(Value::Null),
1957 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
1958 }))
1959}
1960
1961fn parse_kind(s: &str) -> u32 {
1964 if let Ok(n) = s.parse::<u32>() {
1965 return n;
1966 }
1967 for (id, name) in crate::signing::kinds() {
1968 if *name == s {
1969 return *id;
1970 }
1971 }
1972 1
1973}
1974
1975fn error_response(id: &Value, code: i32, message: &str) -> Value {
1976 json!({
1977 "jsonrpc": "2.0",
1978 "id": id,
1979 "error": {"code": code, "message": message}
1980 })
1981}
1982
1983#[cfg(test)]
1984mod tests {
1985 use super::*;
1986
1987 #[test]
1988 fn unknown_method_returns_jsonrpc_error() {
1989 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
1990 let resp = handle_request(&req, &McpState::default());
1991 assert_eq!(resp["error"]["code"], -32601);
1992 }
1993
1994 #[test]
1995 fn initialize_advertises_tools_capability() {
1996 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
1997 let resp = handle_request(&req, &McpState::default());
1998 assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
1999 assert!(resp["result"]["capabilities"]["tools"].is_object());
2000 assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
2001 }
2002
2003 #[test]
2004 fn tools_list_includes_pairing_and_messaging() {
2005 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2006 let resp = handle_request(&req, &McpState::default());
2007 let names: Vec<&str> = resp["result"]["tools"]
2008 .as_array()
2009 .unwrap()
2010 .iter()
2011 .filter_map(|t| t["name"].as_str())
2012 .collect();
2013 for required in [
2014 "wire_whoami",
2015 "wire_peers",
2016 "wire_send",
2017 "wire_tail",
2018 "wire_verify",
2019 "wire_init",
2020 "wire_dial",
2021 ] {
2022 assert!(
2023 names.contains(&required),
2024 "missing required tool {required}"
2025 );
2026 }
2027 for removed in [
2030 "wire_pair_initiate",
2031 "wire_pair_join",
2032 "wire_pair_check",
2033 "wire_pair_confirm",
2034 "wire_pair_initiate_detached",
2035 "wire_pair_join_detached",
2036 "wire_pair_list_pending",
2037 "wire_pair_confirm_detached",
2038 "wire_pair_cancel_pending",
2039 ] {
2040 assert!(
2041 !names.contains(&removed),
2042 "SAS pair tool {removed} must not be advertised after removal"
2043 );
2044 }
2045 assert!(
2049 !names.contains(&"wire_join"),
2050 "wire_join must not be advertised — SAS pairing removed"
2051 );
2052 }
2053
2054 #[test]
2055 fn agent_docs_match_advertised_tools() {
2056 let advertised: Vec<String> = tool_defs()
2063 .iter()
2064 .filter_map(|t| t["name"].as_str().map(str::to_string))
2065 .collect();
2066 let manifest = env!("CARGO_MANIFEST_DIR");
2067 let plugin = std::fs::read_to_string(format!("{manifest}/docs/PLUGIN.md"))
2068 .expect("read docs/PLUGIN.md");
2069 for name in &advertised {
2070 assert!(
2071 plugin.contains(name.as_str()),
2072 "docs/PLUGIN.md missing advertised MCP tool `{name}` — it drifted from tool_defs()"
2073 );
2074 }
2075 let integ = std::fs::read_to_string(format!("{manifest}/docs/AGENT_INTEGRATION.md"))
2076 .expect("read docs/AGENT_INTEGRATION.md");
2077 for (doc, body) in [
2078 ("docs/PLUGIN.md", &plugin),
2079 ("docs/AGENT_INTEGRATION.md", &integ),
2080 ] {
2081 for ghost in [
2082 "wire_up",
2083 "wire_pair_host",
2084 "wire_pair_join",
2085 "wire_pair_confirm",
2086 "wire_pair_accept",
2087 "wire_pair_reject",
2088 "wire_pair_list_inbound",
2089 ] {
2090 assert!(
2091 !body.contains(ghost),
2092 "{doc} advertises ghost MCP tool `{ghost}` (removed / never existed)"
2093 );
2094 }
2095 }
2096 }
2097
2098 #[test]
2099 fn legacy_wire_join_call_returns_helpful_error() {
2100 let req = json!({
2101 "jsonrpc": "2.0",
2102 "id": 1,
2103 "method": "tools/call",
2104 "params": {"name": "wire_join", "arguments": {}}
2105 });
2106 let resp = handle_request(&req, &McpState::default());
2107 assert_eq!(resp["result"]["isError"], true);
2108 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2109 assert!(
2110 text.contains("wire_dial"),
2111 "expected redirect to wire_dial, got: {text}"
2112 );
2113 }
2114
2115 #[test]
2116 fn tools_list_canonical_present_deprecated_absent() {
2117 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2118 let resp = handle_request(&req, &McpState::default());
2119 let names: Vec<&str> = resp["result"]["tools"]
2120 .as_array()
2121 .unwrap()
2122 .iter()
2123 .filter_map(|t| t["name"].as_str())
2124 .collect();
2125
2126 for required in ["wire_accept", "wire_reject", "wire_pending"] {
2128 assert!(
2129 names.contains(&required),
2130 "canonical tool {required} missing from tools/list"
2131 );
2132 }
2133
2134 for removed in [
2136 "wire_pair_accept",
2137 "wire_pair_reject",
2138 "wire_pair_list_inbound",
2139 ] {
2140 assert!(
2141 !names.contains(&removed),
2142 "deprecated tool {removed} must not appear in tools/list"
2143 );
2144 }
2145 }
2146
2147 #[test]
2148 fn deprecated_pair_accept_call_returns_helpful_error() {
2149 for (old_name, canonical) in [
2150 ("wire_pair_accept", "wire_accept"),
2151 ("wire_pair_reject", "wire_reject"),
2152 ("wire_pair_list_inbound", "wire_pending"),
2153 ] {
2154 let req = json!({
2155 "jsonrpc": "2.0",
2156 "id": 1,
2157 "method": "tools/call",
2158 "params": {"name": old_name, "arguments": {}}
2159 });
2160 let resp = handle_request(&req, &McpState::default());
2161 assert_eq!(
2162 resp["result"]["isError"], true,
2163 "calling {old_name} should return isError:true"
2164 );
2165 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2166 assert!(
2167 text.contains(canonical),
2168 "error for {old_name} should mention {canonical}, got: {text}"
2169 );
2170 }
2171 }
2172
2173 #[test]
2174 fn initialize_advertises_resources_capability() {
2175 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
2176 let resp = handle_request(&req, &McpState::default());
2177 let caps = &resp["result"]["capabilities"];
2178 assert!(
2179 caps["resources"].is_object(),
2180 "resources capability must be present, got {resp}"
2181 );
2182 assert_eq!(
2183 caps["resources"]["subscribe"], true,
2184 "subscribe shipped in v0.2.1"
2185 );
2186 }
2187
2188 #[test]
2189 fn resources_read_with_bad_uri_errors() {
2190 let req = json!({
2191 "jsonrpc": "2.0",
2192 "id": 1,
2193 "method": "resources/read",
2194 "params": {"uri": "http://example.com/not-a-wire-uri"}
2195 });
2196 let resp = handle_request(&req, &McpState::default());
2197 assert!(resp.get("error").is_some(), "expected error, got {resp}");
2198 }
2199
2200 #[test]
2201 fn parse_inbox_uri_handles_variants() {
2202 assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
2203 assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
2204 assert!(
2205 parse_inbox_uri("wire://inbox/")
2206 .unwrap()
2207 .starts_with("__invalid__"),
2208 "empty peer must be invalid"
2209 );
2210 assert!(
2211 parse_inbox_uri("http://other")
2212 .unwrap()
2213 .starts_with("__invalid__"),
2214 "non-wire scheme must be invalid"
2215 );
2216 }
2217
2218 #[test]
2219 fn ping_returns_empty_result() {
2220 let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2221 let resp = handle_request(&req, &McpState::default());
2222 assert_eq!(resp["id"], 7);
2223 assert!(resp["result"].is_object());
2224 }
2225
2226 #[test]
2227 fn notification_returns_null_no_reply() {
2228 let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2229 let resp = handle_request(&req, &McpState::default());
2230 assert_eq!(resp, Value::Null);
2231 }
2232
2233 #[test]
2240 fn detect_session_wire_home_resolves_registered_cwd() {
2241 crate::config::test_support::with_temp_home(|| {
2242 let wire_home = std::env::var("WIRE_HOME").unwrap();
2248 let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2249 std::fs::create_dir_all(&sessions_root).unwrap();
2250 let session_home = crate::session::session_dir("test-alpha").unwrap();
2251 std::fs::create_dir_all(&session_home).unwrap();
2252 let fake_cwd = "/tmp/fake-project-cwd-abc123";
2253 let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2254 std::fs::write(
2255 sessions_root.join("registry.json"),
2256 serde_json::to_vec_pretty(®istry).unwrap(),
2257 )
2258 .unwrap();
2259
2260 let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2262 assert_eq!(
2263 got.as_deref(),
2264 Some(session_home.as_path()),
2265 "registered cwd must resolve to session_home"
2266 );
2267
2268 let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2270 "/tmp/cwd-not-in-registry-xyz789",
2271 ));
2272 assert!(nope.is_none(), "unregistered cwd must return None");
2273
2274 let stale_cwd = "/tmp/stale-session-cwd";
2277 let stale_registry =
2278 json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2279 std::fs::write(
2280 sessions_root.join("registry.json"),
2281 serde_json::to_vec_pretty(&stale_registry).unwrap(),
2282 )
2283 .unwrap();
2284 let stale_got =
2285 crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2286 assert!(
2287 stale_got.is_none(),
2288 "registered cwd whose session dir is missing must return None"
2289 );
2290 });
2291 }
2292
2293 #[test]
2300 fn dial_target_to_whois_json_pinned_peer_shape() {
2301 let target = crate::cli::DialTarget::PinnedPeer {
2302 handle: "slate-lotus".into(),
2303 did: "did:wire:slate-lotus-88232017".into(),
2304 nickname: Some("slate-lotus".into()),
2305 emoji: Some("🪴".into()),
2306 tier: "VERIFIED".into(),
2307 };
2308 crate::config::test_support::with_temp_home(|| {
2309 let out = dial_target_to_whois_json(&target);
2310 assert_eq!(out.get("kind").and_then(Value::as_str), Some("pinned_peer"));
2311 assert_eq!(
2312 out.get("handle").and_then(Value::as_str),
2313 Some("slate-lotus")
2314 );
2315 assert_eq!(out.get("tier").and_then(Value::as_str), Some("VERIFIED"));
2316 assert!(out.get("op_did").is_none());
2320 });
2321 }
2322
2323 #[test]
2324 fn dial_target_to_whois_json_local_sister_shape() {
2325 let target = crate::cli::DialTarget::LocalSister {
2326 session_name: "vesper-valley".into(),
2327 handle: "vesper-valley".into(),
2328 did: Some("did:wire:vesper-valley-deadbeef".into()),
2329 nickname: Some("vesper-valley".into()),
2330 emoji: Some("🦌".into()),
2331 };
2332 let out = dial_target_to_whois_json(&target);
2333 assert_eq!(
2334 out.get("kind").and_then(Value::as_str),
2335 Some("local_sister")
2336 );
2337 assert_eq!(
2338 out.get("session_name").and_then(Value::as_str),
2339 Some("vesper-valley")
2340 );
2341 assert_eq!(
2342 out.get("did").and_then(Value::as_str),
2343 Some("did:wire:vesper-valley-deadbeef")
2344 );
2345 assert!(out.get("tier").is_none());
2348 assert!(out.get("op_did").is_none());
2349 }
2350}