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_if_unexpected_session_source("mcp");
126 let _ = crate::ensure_up::write_self_role_pid("mcp");
140 crate::session::warn_on_identity_collision(std::process::id(), "mcp");
141
142 let state = McpState::default();
143 let shutdown = Arc::new(AtomicBool::new(false));
144
145 let (tx, rx) = mpsc::channel::<String>();
146
147 if let Ok(mut g) = state.notif_tx.lock() {
150 *g = Some(tx.clone());
151 }
152
153 let writer_handle = std::thread::spawn(move || {
155 let stdout = std::io::stdout();
156 let mut w = stdout.lock();
157 while let Ok(line) = rx.recv() {
158 if writeln!(w, "{line}").is_err() {
159 break;
160 }
161 if w.flush().is_err() {
162 break;
163 }
164 }
165 });
166
167 let subs_w = state.subscribed.clone();
172 let tx_w = tx.clone();
173 let shutdown_w = shutdown.clone();
174 let watcher_handle = std::thread::spawn(move || {
175 let mut watcher = match crate::inbox_watch::InboxWatcher::from_head() {
176 Ok(w) => w,
177 Err(_) => return,
178 };
179 let poll_interval = Duration::from_secs(2);
180 let mut next_poll = Instant::now() + poll_interval;
181 loop {
182 if shutdown_w.load(Ordering::SeqCst) {
183 return;
184 }
185 std::thread::sleep(Duration::from_millis(100));
186 if Instant::now() < next_poll {
187 continue;
188 }
189 next_poll = Instant::now() + poll_interval;
190 let subs_snapshot = match subs_w.lock() {
191 Ok(g) => g.clone(),
192 Err(_) => return,
193 };
194
195 let mut affected: HashSet<String> = HashSet::new();
196
197 if !subs_snapshot.is_empty()
199 && let Ok(events) = watcher.poll()
200 {
201 for ev in &events {
202 if subs_snapshot.contains("wire://inbox/all") {
203 affected.insert("wire://inbox/all".to_string());
204 }
205 let peer_uri = format!("wire://inbox/{}", ev.peer);
206 if subs_snapshot.contains(&peer_uri) {
207 affected.insert(peer_uri);
208 }
209 }
210 }
211
212 for uri in affected {
213 let notif = json!({
214 "jsonrpc": "2.0",
215 "method": "notifications/resources/updated",
216 "params": {"uri": uri}
217 });
218 if tx_w.send(notif.to_string()).is_err() {
219 return;
220 }
221 }
222 }
223 });
224
225 let stdin = std::io::stdin();
226 let mut reader = BufReader::new(stdin.lock());
227 let mut line = String::new();
228 loop {
229 line.clear();
230 let n = reader.read_line(&mut line)?;
231 if n == 0 {
232 shutdown.store(true, Ordering::SeqCst);
236 if let Ok(mut g) = state.notif_tx.lock() {
237 *g = None;
238 }
239 drop(tx);
240 let _ = watcher_handle.join();
241 let _ = writer_handle.join();
242 return Ok(());
243 }
244 let trimmed = line.trim();
245 if trimmed.is_empty() {
246 continue;
247 }
248 let request: Value = match serde_json::from_str(trimmed) {
249 Ok(v) => v,
250 Err(e) => {
251 let err = error_response(&Value::Null, -32700, &format!("parse error: {e}"));
252 let _ = tx.send(err.to_string());
253 continue;
254 }
255 };
256 let response = handle_request(&request, &state);
257 if response.get("id").is_some() || response.get("error").is_some() {
259 let _ = tx.send(response.to_string());
260 }
261 }
262}
263
264fn handle_request(req: &Value, state: &McpState) -> Value {
265 let id = req.get("id").cloned().unwrap_or(Value::Null);
266 let method = match req.get("method").and_then(Value::as_str) {
267 Some(m) => m,
268 None => return error_response(&id, -32600, "missing method"),
269 };
270 match method {
271 "initialize" => handle_initialize(&id),
272 "notifications/initialized" => Value::Null, "tools/list" => handle_tools_list(&id),
274 "tools/call" => handle_tools_call(&id, req.get("params").unwrap_or(&Value::Null), state),
275 "resources/list" => handle_resources_list(&id),
276 "resources/read" => handle_resources_read(&id, req.get("params").unwrap_or(&Value::Null)),
277 "resources/subscribe" => {
278 handle_resources_subscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
279 }
280 "resources/unsubscribe" => {
281 handle_resources_unsubscribe(&id, req.get("params").unwrap_or(&Value::Null), state)
282 }
283 "ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}),
284 other => error_response(&id, -32601, &format!("method not found: {other}")),
285 }
286}
287
288fn handle_resources_list(id: &Value) -> Value {
300 let mut resources = vec![json!({
301 "uri": "wire://inbox/all",
302 "name": "wire inbox (all peers)",
303 "description": "Most recent verified events from all pinned peers, JSONL.",
304 "mimeType": "application/x-ndjson"
305 })];
306
307 if let Ok(trust) = crate::config::read_trust() {
308 let agents = trust
309 .get("agents")
310 .and_then(Value::as_object)
311 .cloned()
312 .unwrap_or_default();
313 let self_did = crate::config::read_agent_card()
314 .ok()
315 .and_then(|c| c.get("did").and_then(Value::as_str).map(str::to_string));
316 for (handle, agent) in agents.iter() {
317 let did = agent
318 .get("did")
319 .and_then(Value::as_str)
320 .unwrap_or("")
321 .to_string();
322 if Some(did.as_str()) == self_did.as_deref() {
323 continue;
324 }
325 resources.push(json!({
326 "uri": format!("wire://inbox/{handle}"),
327 "name": format!("inbox from {handle}"),
328 "description": format!("Recent verified events from did:wire:{handle}."),
329 "mimeType": "application/x-ndjson"
330 }));
331 }
332 }
333
334 json!({
335 "jsonrpc": "2.0",
336 "id": id,
337 "result": {
338 "resources": resources
339 }
340 })
341}
342
343fn handle_resources_subscribe(id: &Value, params: &Value, state: &McpState) -> Value {
344 let uri = match params.get("uri").and_then(Value::as_str) {
345 Some(u) => u.to_string(),
346 None => return error_response(id, -32602, "missing 'uri'"),
347 };
348 let inbox_peer = parse_inbox_uri(&uri);
351 if let Some(ref p) = inbox_peer
352 && p.starts_with("__invalid__")
353 {
354 return error_response(
355 id,
356 -32602,
357 "subscribe URI must be wire://inbox/<peer> or wire://inbox/all",
358 );
359 }
360 if let Ok(mut g) = state.subscribed.lock() {
361 g.insert(uri);
362 }
363 json!({"jsonrpc": "2.0", "id": id, "result": {}})
364}
365
366fn handle_resources_unsubscribe(id: &Value, params: &Value, state: &McpState) -> Value {
367 let uri = match params.get("uri").and_then(Value::as_str) {
368 Some(u) => u.to_string(),
369 None => return error_response(id, -32602, "missing 'uri'"),
370 };
371 if let Ok(mut g) = state.subscribed.lock() {
372 g.remove(&uri);
373 }
374 json!({"jsonrpc": "2.0", "id": id, "result": {}})
375}
376
377fn handle_resources_read(id: &Value, params: &Value) -> Value {
378 let uri = match params.get("uri").and_then(Value::as_str) {
379 Some(u) => u,
380 None => return error_response(id, -32602, "missing 'uri'"),
381 };
382 let peer_opt = parse_inbox_uri(uri);
383 match read_inbox_resource(peer_opt) {
384 Ok(payload) => json!({
385 "jsonrpc": "2.0",
386 "id": id,
387 "result": {
388 "contents": [{
389 "uri": uri,
390 "mimeType": "application/x-ndjson",
391 "text": payload,
392 }]
393 }
394 }),
395 Err(e) => error_response(id, -32603, &e.to_string()),
396 }
397}
398
399fn parse_inbox_uri(uri: &str) -> Option<String> {
402 if let Some(rest) = uri.strip_prefix("wire://inbox/") {
403 if rest == "all" {
404 return None;
405 }
406 if !rest.is_empty() {
407 return Some(rest.to_string());
408 }
409 }
410 Some(format!("__invalid__{uri}"))
411}
412
413fn read_inbox_resource(peer_opt: Option<String>) -> Result<String, String> {
414 const LIMIT: usize = 50;
415 if let Some(ref p) = peer_opt
418 && p.starts_with("__invalid__")
419 {
420 return Err(
421 "unknown resource URI (must be wire://inbox/<peer> or wire://inbox/all)".into(),
422 );
423 }
424 let inbox = crate::config::inbox_dir().map_err(|e| e.to_string())?;
425 if !inbox.exists() {
426 return Ok(String::new());
427 }
428 let trust = crate::config::read_trust().map_err(|e| e.to_string())?;
429
430 let paths: Vec<std::path::PathBuf> = match peer_opt {
431 Some(p) => {
432 let path = inbox.join(format!("{p}.jsonl"));
433 if !path.exists() {
434 return Ok(String::new());
435 }
436 vec![path]
437 }
438 None => std::fs::read_dir(&inbox)
439 .map_err(|e| e.to_string())?
440 .flatten()
441 .map(|e| e.path())
442 .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
443 .collect(),
444 };
445
446 let mut events: Vec<(String, bool, Value)> = Vec::new();
447 for path in paths {
448 let body = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
449 let peer = path
450 .file_stem()
451 .and_then(|s| s.to_str())
452 .unwrap_or("")
453 .to_string();
454 for line in body.lines() {
455 let event: Value = match serde_json::from_str(line) {
456 Ok(v) => v,
457 Err(_) => continue,
458 };
459 let verified = crate::signing::verify_message_v31(&event, &trust).is_ok();
460 events.push((peer.clone(), verified, event));
461 }
462 }
463 let take_from = events.len().saturating_sub(LIMIT);
465 let tail = &events[take_from..];
466
467 let seed: Option<[u8; 32]> = crate::config::read_private_key()
471 .ok()
472 .and_then(|v| v.get(..32).and_then(|s| <[u8; 32]>::try_from(s).ok()));
473
474 let mut out = String::new();
475 for (_peer, verified, mut event) in tail.iter().cloned() {
476 if event.get("enc").and_then(Value::as_str)
478 == Some(crate::enc::wire_x25519::ENC_DISCRIMINATOR)
479 && let Some(ref s) = seed
480 && let Ok(Some(plain)) = crate::enc::wire_x25519::open_event_body(&event, &trust, s)
481 && let Some(obj) = event.as_object_mut()
482 {
483 obj.insert("body".into(), plain);
484 obj.insert("dec".into(), json!(true));
485 }
486 let undecryptable = crate::enc::wire_x25519::is_encrypted_unreadable(&event);
489 let placeholder = if undecryptable {
490 Some(crate::enc::wire_x25519::undecryptable_body_placeholder(
491 &event,
492 ))
493 } else {
494 None
495 };
496 if let Some(obj) = event.as_object_mut() {
497 obj.insert("verified".into(), json!(verified));
498 if let Some(p) = placeholder {
499 obj.insert("decryptable".into(), json!(false));
500 obj.insert("body".into(), json!(p));
501 }
502 }
503 out.push_str(&serde_json::to_string(&event).map_err(|e| e.to_string())?);
504 out.push('\n');
505 }
506 Ok(out)
507}
508
509fn handle_initialize(id: &Value) -> Value {
510 json!({
511 "jsonrpc": "2.0",
512 "id": id,
513 "result": {
514 "protocolVersion": PROTOCOL_VERSION,
515 "capabilities": {
516 "tools": {"listChanged": false},
517 "resources": {
518 "listChanged": false,
519 "subscribe": true
524 }
525 },
526 "serverInfo": {
527 "name": SERVER_NAME,
528 "version": SERVER_VERSION,
529 },
530 "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` + `identity_split:null`. 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). If `identity_split` is NON-null, this MCP process is frozen to a stale wire identity (`identity_split.operational`) while your live session is a different one (`identity_split.live`) — the \"two names\" bug: you will send/pair as the wrong identity. Surface it to the operator and recommend `/mcp reconnect` (or restarting this session); do not pair or send until resolved. (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)."
531 }
532 })
533}
534
535fn handle_tools_list(id: &Value) -> Value {
536 json!({
537 "jsonrpc": "2.0",
538 "id": id,
539 "result": {
540 "tools": tool_defs(),
541 }
542 })
543}
544
545fn tool_defs() -> Vec<Value> {
546 vec![
547 json!({
548 "name": "wire_whoami",
549 "description": "Return this agent's DID, fingerprint, key_id, public key, and capabilities. Also `identity_split`: null when healthy, or {operational, live, hint} when THIS MCP process is frozen to a stale identity while the live Claude session is a different one (the \"two names\" bug) — surface it and /mcp reconnect. Read-only.",
550 "inputSchema": {"type": "object", "properties": {}, "required": []}
551 }),
552 json!({
553 "name": "wire_peers",
554 "description": "List pinned peers with their tier (UNTRUSTED/VERIFIED/ATTESTED) and advertised capabilities. Read-only.",
555 "inputSchema": {"type": "object", "properties": {}, "required": []}
556 }),
557 json!({
558 "name": "wire_here",
559 "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.",
560 "inputSchema": {"type": "object", "properties": {}, "required": []}
561 }),
562 json!({
563 "name": "wire_status",
564 "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 + identity_split:null. `identity_split` is non-null ({operational, live, hint}) when this MCP process is frozen to a stale wire identity while the live Claude session is a different one (the \"two names\" bug — you'd send/pair as the wrong identity); surface it and /mcp reconnect. 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.",
565 "inputSchema": {"type": "object", "properties": {}, "required": []}
566 }),
567 json!({
568 "name": "wire_send",
569 "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.",
570 "inputSchema": {
571 "type": "object",
572 "properties": {
573 "peer": {"type": "string", "description": "Peer handle (without did:wire: prefix). Must be a pinned peer; check wire_peers first."},
574 "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."},
575 "body": {"type": "string", "description": "Event body. Plain text becomes a JSON string; valid JSON is parsed and embedded structurally."},
576 "time_sensitive_until": {"type": "string", "description": "Optional advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp."},
577 "queue": {"type": "boolean", "description": "Default false (synchronous send; status is the live relay verdict). Set true to write to the outbox for the daemon to push later — the legacy offline-buffer / pre-pair path. (#284.7: this was documented but missing from the schema.)"}
578 },
579 "required": ["peer", "kind", "body"]
580 }
581 }),
582 json!({
583 "name": "wire_pull",
584 "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.",
585 "inputSchema": {"type": "object", "properties": {}, "required": []}
586 }),
587 json!({
588 "name": "wire_tail",
589 "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).",
590 "inputSchema": {
591 "type": "object",
592 "properties": {
593 "peer": {"type": "string", "description": "Optional peer handle to filter inbox by."},
594 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Max events to return."},
595 "oldest": {"type": "boolean", "default": false, "description": "Return the FIRST `limit` events (oldest-N) instead of the default last-N (newest-N)."}
596 },
597 "required": []
598 }
599 }),
600 json!({
601 "name": "wire_verify",
602 "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).",
603 "inputSchema": {
604 "type": "object",
605 "properties": {
606 "event": {"type": "string", "description": "JSON-encoded signed event."}
607 },
608 "required": ["event"]
609 }
610 }),
611 json!({
612 "name": "wire_init",
613 "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.",
614 "inputSchema": {
615 "type": "object",
616 "properties": {
617 "handle": {"type": "string", "description": "Short handle (becomes did:wire:<handle>). ASCII alphanumeric / '-' / '_' only."},
618 "name": {"type": "string", "description": "Optional display name (defaults to capitalized handle)."},
619 "relay_url": {"type": "string", "description": "Optional relay URL — if set, also binds a relay slot."}
620 },
621 "required": ["handle"]
622 }
623 }),
624 json!({
625 "name": "wire_invite_mint",
626 "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}.",
627 "inputSchema": {
628 "type": "object",
629 "properties": {
630 "relay_url": {"type": "string", "description": "Override relay for first-time auto-allocate."},
631 "ttl_secs": {"type": "integer", "description": "Invite lifetime in seconds (default 86400)."},
632 "uses": {"type": "integer", "description": "Number of distinct peers that can accept before consumption (default 1)."}
633 }
634 }
635 }),
636 json!({
637 "name": "wire_invite_accept",
638 "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}.",
639 "inputSchema": {
640 "type": "object",
641 "properties": {
642 "url": {"type": "string", "description": "Full wire://pair?v=1&inv=... URL."}
643 },
644 "required": ["url"]
645 }
646 }),
647 json!({
649 "name": "wire_add",
650 "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.",
651 "inputSchema": {
652 "type": "object",
653 "properties": {
654 "handle": {"type": "string", "description": "Peer handle like `nick@domain`."},
655 "relay_url": {"type": "string", "description": "Override resolver URL (default: `https://<domain>`)."}
656 },
657 "required": ["handle"]
658 }
659 }),
660 json!({
666 "name": "wire_dial",
667 "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.",
668 "inputSchema": {
669 "type": "object",
670 "properties": {
671 "name": {"type": "string", "description": "Peer name — character nickname / session / handle / DID / `<handle>@<relay>`."}
672 },
673 "required": ["name"]
674 }
675 }),
676 json!({
677 "name": "wire_accept",
678 "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.",
679 "inputSchema": {
680 "type": "object",
681 "properties": {
682 "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle, from wire_pending)."}
683 },
684 "required": ["peer"]
685 }
686 }),
687 json!({
688 "name": "wire_reject",
689 "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.",
690 "inputSchema": {
691 "type": "object",
692 "properties": {
693 "peer": {"type": "string", "description": "Pending peer name (character nickname or card handle)."}
694 },
695 "required": ["peer"]
696 }
697 }),
698 json!({
699 "name": "wire_pending",
700 "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.",
701 "inputSchema": {"type": "object", "properties": {}}
702 }),
703 json!({
704 "name": "wire_claim",
705 "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).",
706 "inputSchema": {
707 "type": "object",
708 "properties": {
709 "nick": {"type": "string", "description": "Optional + advisory. Ignored if it differs from your DID-derived persona (one-name rule)."},
710 "relay_url": {"type": "string", "description": "Relay to claim on. Default = our relay."},
711 "public_url": {"type": "string", "description": "Public URL the relay should advertise to resolvers."}
712 }
713 }
714 }),
715 json!({
716 "name": "wire_whois",
717 "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.",
718 "inputSchema": {
719 "type": "object",
720 "properties": {
721 "handle": {"type": "string", "description": "Optional `nick@domain`. Omit for self."},
722 "relay_url": {"type": "string", "description": "Override resolver URL."}
723 }
724 }
725 }),
726 json!({
727 "name": "wire_profile_set",
728 "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.",
729 "inputSchema": {
730 "type": "object",
731 "properties": {
732 "field": {"type": "string", "description": "One of: display_name, emoji, motto, vibe, pronouns, avatar_url, handle, now."},
733 "value": {"description": "String for most fields; array for vibe; object for now. Pass JSON null to clear a field."}
734 },
735 "required": ["field", "value"]
736 }
737 }),
738 json!({
739 "name": "wire_profile_get",
740 "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.",
741 "inputSchema": {"type": "object", "properties": {}}
742 }),
743 json!({
748 "name": "wire_group_create",
749 "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.",
750 "inputSchema": {
751 "type": "object",
752 "properties": {"name": {"type": "string", "description": "Human label for the group."}},
753 "required": ["name"]
754 }
755 }),
756 json!({
757 "name": "wire_group_add",
758 "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.",
759 "inputSchema": {
760 "type": "object",
761 "properties": {
762 "group": {"type": "string", "description": "Group id or name."},
763 "peer": {"type": "string", "description": "Handle of a VERIFIED pinned peer."}
764 },
765 "required": ["group", "peer"]
766 }
767 }),
768 json!({
769 "name": "wire_group_send",
770 "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).",
771 "inputSchema": {
772 "type": "object",
773 "properties": {
774 "group": {"type": "string", "description": "Group id or name."},
775 "message": {"type": "string", "description": "Message text."}
776 },
777 "required": ["group", "message"]
778 }
779 }),
780 json!({
781 "name": "wire_group_tail",
782 "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.",
783 "inputSchema": {
784 "type": "object",
785 "properties": {
786 "group": {"type": "string", "description": "Group id or name."},
787 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 20, "description": "Max timeline entries to return."}
788 },
789 "required": ["group"]
790 }
791 }),
792 json!({
793 "name": "wire_group_list",
794 "description": "List the groups this agent is in, with each group's members and their GroupTiers (creator/member/introduced). Read-only, local.",
795 "inputSchema": {"type": "object", "properties": {}, "required": []}
796 }),
797 json!({
798 "name": "wire_group_invite",
799 "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.",
800 "inputSchema": {
801 "type": "object",
802 "properties": {"group": {"type": "string", "description": "Group id or name."}},
803 "required": ["group"]
804 }
805 }),
806 json!({
807 "name": "wire_group_join",
808 "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.",
809 "inputSchema": {
810 "type": "object",
811 "properties": {"code": {"type": "string", "description": "The `wire-group:` join code."}},
812 "required": ["code"]
813 }
814 }),
815 ]
816}
817
818fn handle_tools_call(id: &Value, params: &Value, _state: &McpState) -> Value {
819 let name = match params.get("name").and_then(Value::as_str) {
820 Some(n) => n,
821 None => return error_response(id, -32602, "missing tool name"),
822 };
823 let args = params
824 .get("arguments")
825 .cloned()
826 .unwrap_or_else(|| json!({}));
827
828 let result = match name {
829 "wire_whoami" => tool_whoami(),
830 "wire_status" => tool_status(),
831 "wire_peers" => tool_peers(),
832 "wire_here" => tool_here(),
833 "wire_send" => tool_send(&args),
834 "wire_pull" => tool_pull(),
835 "wire_tail" => tool_tail(&args),
836 "wire_verify" => tool_verify(&args),
837 "wire_init" => tool_init(&args),
838 "wire_invite_mint" => tool_invite_mint(&args),
839 "wire_invite_accept" => tool_invite_accept(&args),
840 "wire_add" => tool_add(&args),
842 "wire_accept" => tool_pair_accept(&args),
847 "wire_reject" => tool_pair_reject(&args),
848 "wire_pending" => tool_pair_list_inbound(),
849 "wire_pair_accept" => Err("wire_pair_accept was renamed to wire_accept (v0.9+). \
850 Use wire_accept instead."
851 .into()),
852 "wire_pair_reject" => Err("wire_pair_reject was renamed to wire_reject (v0.9+). \
853 Use wire_reject instead."
854 .into()),
855 "wire_pair_list_inbound" => Err(
856 "wire_pair_list_inbound was renamed to wire_pending (v0.9+). \
857 Use wire_pending instead."
858 .into(),
859 ),
860 "wire_dial" => tool_dial(&args),
861 "wire_claim" => tool_claim_handle(&args),
862 "wire_whois" => tool_whois(&args),
863 "wire_profile_set" => tool_profile_set(&args),
864 "wire_profile_get" => tool_profile_get(),
865 "wire_group_create" => tool_group_create(&args),
867 "wire_group_add" => tool_group_add(&args),
868 "wire_group_send" => tool_group_send(&args),
869 "wire_group_tail" => tool_group_tail(&args),
870 "wire_group_list" => tool_group_list(),
871 "wire_group_invite" => tool_group_invite(&args),
872 "wire_group_join" => tool_group_join(&args),
873 "wire_join" => Err("wire_join (SAS code-phrase pairing) was removed. \
877 Use wire_dial(\"<handle>@<relay>\") to pair by handle. \
878 See docs/AGENT_INTEGRATION.md."
879 .into()),
880 other => Err(format!("unknown tool: {other}")),
881 };
882
883 match result {
884 Ok(value) => json!({
885 "jsonrpc": "2.0",
886 "id": id,
887 "result": {
888 "content": [{
889 "type": "text",
890 "text": serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
891 }],
892 "isError": false
893 }
894 }),
895 Err(message) => json!({
896 "jsonrpc": "2.0",
897 "id": id,
898 "result": {
899 "content": [{"type": "text", "text": message}],
900 "isError": true
901 }
902 }),
903 }
904}
905
906fn tool_whoami() -> Result<Value, String> {
909 use crate::config;
910 use crate::signing::{b64decode, fingerprint, make_key_id};
911
912 if !config::is_initialized().map_err(|e| e.to_string())? {
913 return Err("not initialized — operator must run `wire up` first".into());
914 }
915 let card = config::read_agent_card().map_err(|e| e.to_string())?;
916 let did = card
917 .get("did")
918 .and_then(Value::as_str)
919 .unwrap_or("")
920 .to_string();
921 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
922 let pk_b64 = card
923 .get("verify_keys")
924 .and_then(Value::as_object)
925 .and_then(|m| m.values().next())
926 .and_then(|v| v.get("key"))
927 .and_then(Value::as_str)
928 .ok_or_else(|| "agent-card missing verify_keys[*].key".to_string())?;
929 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
930 let fp = fingerprint(&pk_bytes);
931 let key_id = make_key_id(&handle, &pk_bytes);
932 let capabilities = card
933 .get("capabilities")
934 .cloned()
935 .unwrap_or_else(|| json!(["wire/v3.2"]));
936 let persona =
940 serde_json::to_value(crate::character::Character::from_card(&card)).unwrap_or(Value::Null);
941 let mut payload = serde_json::Map::new();
947 payload.insert("did".into(), json!(did));
948 payload.insert("handle".into(), json!(handle));
949 payload.insert("persona".into(), persona);
950 payload.insert("fingerprint".into(), json!(fp));
951 payload.insert("key_id".into(), json!(key_id));
952 payload.insert("public_key_b64".into(), json!(pk_b64));
953 payload.insert("capabilities".into(), capabilities);
954 payload.insert(
958 "session_source".into(),
959 json!(crate::session::session_source()),
960 );
961 payload.insert("identity_split".into(), identity_split_json());
965 for (k, v) in crate::cli::op_claims_from_card(&card) {
966 payload.insert(k, v);
967 }
968 let daemon_version = match crate::ensure_up::read_pid_record("daemon") {
976 crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
977 _ => None,
978 };
979 payload.insert("server_version".into(), json!(SERVER_VERSION));
980 if let Some(dv) = &daemon_version {
981 payload.insert("daemon_version".into(), json!(dv));
982 }
983 if let Some(note) = mcp_stale_binary_note(SERVER_VERSION, daemon_version.as_deref()) {
984 payload.insert("stale_binary".into(), json!(true));
985 payload.insert("stale_binary_note".into(), json!(note));
986 }
987 Ok(Value::Object(payload))
988}
989
990fn mcp_stale_binary_note(server_version: &str, daemon_version: Option<&str>) -> Option<String> {
997 let dv = daemon_version?;
998 if dv == server_version {
999 return None;
1000 }
1001 Some(format!(
1002 "this wire MCP server is running v{server_version} but the on-disk daemon is v{dv} — the server is serving drifted code in memory. Reconnect (/mcp) to respawn it on the current binary."
1003 ))
1004}
1005
1006fn tool_peers() -> Result<Value, String> {
1007 use crate::config;
1008
1009 let trust = config::read_trust().map_err(|e| e.to_string())?;
1010 let agents = trust
1011 .get("agents")
1012 .and_then(Value::as_object)
1013 .cloned()
1014 .unwrap_or_default();
1015 let relay_state =
1023 config::read_relay_state().unwrap_or_else(|_| json!({"self": null, "peers": {}}));
1024 let mut self_did: Option<String> = None;
1025 if let Ok(card) = config::read_agent_card() {
1026 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
1027 }
1028 let mut peers = Vec::new();
1029 for (handle, agent) in agents.iter() {
1030 let did = agent
1031 .get("did")
1032 .and_then(Value::as_str)
1033 .unwrap_or("")
1034 .to_string();
1035 if Some(did.as_str()) == self_did.as_deref() {
1036 continue;
1037 }
1038 let persona = match agent.get("card") {
1042 Some(c) => crate::character::Character::from_card(c),
1043 None => crate::character::Character::from_did(&did),
1044 };
1045 let peer_op_claims = agent
1050 .get("card")
1051 .map(crate::cli::op_claims_from_card)
1052 .unwrap_or_default();
1053 let mut row = serde_json::Map::new();
1054 row.insert("handle".into(), json!(handle));
1055 row.insert(
1056 "persona".into(),
1057 serde_json::to_value(&persona).unwrap_or(Value::Null),
1058 );
1059 row.insert("did".into(), json!(did));
1060 row.insert(
1061 "tier".into(),
1062 json!(crate::trust::effective_tier(&trust, &relay_state, handle)),
1063 );
1064 row.insert(
1065 "capabilities".into(),
1066 agent
1067 .get("card")
1068 .and_then(|c| c.get("capabilities"))
1069 .cloned()
1070 .unwrap_or_else(|| json!([])),
1071 );
1072 for (k, v) in peer_op_claims {
1073 row.insert(k, v);
1074 }
1075 peers.push(Value::Object(row));
1076 }
1077 Ok(json!(peers))
1078}
1079
1080fn group_cli_json(args: &[&str]) -> Result<Value, String> {
1085 let exe = std::env::current_exe().map_err(|e| format!("locating wire binary: {e}"))?;
1086 let out = std::process::Command::new(exe)
1087 .arg("group")
1088 .args(args)
1089 .arg("--json")
1090 .env("WIRE_QUIET_AUTOSESSION", "1") .output()
1092 .map_err(|e| format!("spawning `wire group`: {e}"))?;
1093 if !out.status.success() {
1094 let err = String::from_utf8_lossy(&out.stderr);
1095 return Err(err.trim().to_string());
1096 }
1097 let s = String::from_utf8_lossy(&out.stdout);
1098 let line = s
1100 .lines()
1101 .rev()
1102 .find(|l| l.trim_start().starts_with('{'))
1103 .unwrap_or("{}");
1104 serde_json::from_str(line).map_err(|e| format!("parsing `wire group` output: {e}"))
1105}
1106
1107fn tool_group_create(args: &Value) -> Result<Value, String> {
1108 let name = args
1109 .get("name")
1110 .and_then(Value::as_str)
1111 .ok_or("missing 'name'")?;
1112 group_cli_json(&["create", name])
1113}
1114
1115fn tool_group_add(args: &Value) -> Result<Value, String> {
1116 let group = args
1117 .get("group")
1118 .and_then(Value::as_str)
1119 .ok_or("missing 'group'")?;
1120 let peer = args
1121 .get("peer")
1122 .and_then(Value::as_str)
1123 .ok_or("missing 'peer'")?;
1124 group_cli_json(&["add", group, peer])
1125}
1126
1127fn tool_group_send(args: &Value) -> Result<Value, String> {
1128 let group = args
1129 .get("group")
1130 .and_then(Value::as_str)
1131 .ok_or("missing 'group'")?;
1132 let message = args
1133 .get("message")
1134 .and_then(Value::as_str)
1135 .ok_or("missing 'message'")?;
1136 group_cli_json(&["send", group, message])
1137}
1138
1139fn tool_group_tail(args: &Value) -> Result<Value, String> {
1140 let group = args
1141 .get("group")
1142 .and_then(Value::as_str)
1143 .ok_or("missing 'group'")?;
1144 if let Some(n) = args.get("limit").and_then(Value::as_u64) {
1145 group_cli_json(&["tail", group, "--limit", &n.to_string()])
1146 } else {
1147 group_cli_json(&["tail", group])
1148 }
1149}
1150
1151fn tool_group_list() -> Result<Value, String> {
1152 group_cli_json(&["list"])
1153}
1154
1155fn tool_group_invite(args: &Value) -> Result<Value, String> {
1156 let group = args
1157 .get("group")
1158 .and_then(Value::as_str)
1159 .ok_or("missing 'group'")?;
1160 group_cli_json(&["invite", group])
1161}
1162
1163fn tool_group_join(args: &Value) -> Result<Value, String> {
1164 let code = args
1165 .get("code")
1166 .and_then(Value::as_str)
1167 .ok_or("missing 'code'")?;
1168 group_cli_json(&["join", code])
1169}
1170
1171fn tool_status() -> Result<Value, String> {
1181 use crate::config;
1182
1183 let initialized = config::is_initialized().unwrap_or(false);
1184 if !initialized {
1185 return Ok(json!({
1186 "initialized": false,
1187 "daemon_running": false,
1188 "last_sync_age_seconds": Value::Null,
1189 }));
1190 }
1191
1192 let snap = crate::ensure_up::daemon_liveness();
1193 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1194 let last_sync_record = crate::ensure_up::read_last_sync_record();
1195
1196 let mut daemon = json!({
1197 "running": snap.pidfile_alive,
1198 "pid": snap.pidfile_pid,
1199 "all_running_pids": snap.pgrep_pids,
1200 "orphans": snap.orphan_pids,
1201 });
1202 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
1203 daemon["version"] = json!(d.version);
1204 daemon["bin_path"] = json!(d.bin_path);
1205 daemon["did"] = json!(d.did);
1206 daemon["relay_url"] = json!(d.relay_url);
1207 daemon["started_at"] = json!(d.started_at);
1208 }
1209
1210 let (last_sync_at, last_sync_push_n, last_sync_pull_n, last_sync_rejected_n) =
1211 match last_sync_record {
1212 Some(rec) => (
1213 Some(rec.ts),
1214 Some(rec.push_n),
1215 Some(rec.pull_n),
1216 Some(rec.rejected_n),
1217 ),
1218 None => (None, None, None, None),
1219 };
1220
1221 let outbox_count = config::outbox_dir()
1222 .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1223 .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1224 .unwrap_or(0);
1225 let inbox_count = config::inbox_dir()
1226 .and_then(|p| crate::cli::scan_jsonl_dir(&p))
1227 .map(|v| v.get("total_events").and_then(Value::as_u64).unwrap_or(0))
1228 .unwrap_or(0);
1229
1230 let pending_push_breakdown = config::compute_pending_push_breakdown();
1238 let pending_push_count: u64 = pending_push_breakdown.iter().map(|p| p.count).sum();
1239
1240 let stream_state = config::read_stream_state();
1246
1247 Ok(json!({
1248 "initialized": true,
1249 "daemon": daemon,
1250 "daemon_running": snap.pidfile_alive,
1251 "last_sync_at": last_sync_at,
1252 "last_sync_age_seconds": last_sync_age,
1253 "last_sync_push_n": last_sync_push_n,
1254 "last_sync_pull_n": last_sync_pull_n,
1255 "last_sync_rejected_n": last_sync_rejected_n,
1256 "stale_sync": config::stale_sync(last_sync_age),
1257 "outbox_count": outbox_count,
1258 "inbox_count": inbox_count,
1259 "pending_push_count": pending_push_count,
1260 "pending_push_breakdown": pending_push_breakdown,
1261 "stream_state": stream_state,
1262 "identity_split": identity_split_json(),
1265 }))
1266}
1267
1268fn identity_split_json() -> Value {
1275 match crate::session::detect_identity_split() {
1276 Some(s) => json!({
1277 "operational": s.operational_handle,
1278 "live": s.live_handle,
1279 "hint": "this MCP process is frozen to a stale wire identity; the live Claude session is different. Fix: reconnect it (/mcp) or restart this session.",
1280 }),
1281 None => Value::Null,
1282 }
1283}
1284
1285fn tool_send(args: &Value) -> Result<Value, String> {
1286 use crate::config;
1287 use crate::signing::{b64decode, sign_message_v31};
1288
1289 let peer = args
1290 .get("peer")
1291 .and_then(Value::as_str)
1292 .ok_or("missing 'peer'")?;
1293 let peer = crate::agent_card::bare_handle(peer);
1294 let kind = args
1295 .get("kind")
1296 .and_then(Value::as_str)
1297 .ok_or("missing 'kind'")?;
1298 let body = args
1299 .get("body")
1300 .and_then(Value::as_str)
1301 .ok_or("missing 'body'")?;
1302 let deadline = args.get("time_sensitive_until").and_then(Value::as_str);
1303 let queue = args.get("queue").and_then(Value::as_bool).unwrap_or(false);
1308
1309 if !config::is_initialized().map_err(|e| e.to_string())? {
1310 return Err("not initialized — operator must run `wire up` first".into());
1311 }
1312 let sk_seed = config::read_private_key().map_err(|e| e.to_string())?;
1313 let card = config::read_agent_card().map_err(|e| e.to_string())?;
1314 let did = card
1315 .get("did")
1316 .and_then(Value::as_str)
1317 .unwrap_or("")
1318 .to_string();
1319 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
1320 let pk_b64 = card
1321 .get("verify_keys")
1322 .and_then(Value::as_object)
1323 .and_then(|m| m.values().next())
1324 .and_then(|v| v.get("key"))
1325 .and_then(Value::as_str)
1326 .ok_or("agent-card missing verify_keys[*].key")?;
1327 let pk_bytes = b64decode(pk_b64).map_err(|e| e.to_string())?;
1328
1329 let body_value: Value =
1331 serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()));
1332 let kind_id = parse_kind(kind);
1333
1334 let now = time::OffsetDateTime::now_utc()
1335 .format(&time::format_description::well_known::Rfc3339)
1336 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1337
1338 let trust_for_did = config::read_trust().map_err(|e| e.to_string())?;
1351 let to_did = crate::trust::resolve_peer_did(&trust_for_did, peer);
1352 let mut event = json!({
1353 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
1356 "timestamp": now,
1357 "from": did,
1358 "to": to_did,
1359 "type": kind,
1360 "kind": kind_id,
1361 "body": body_value,
1362 });
1363 if let Some(deadline) = deadline {
1364 event["time_sensitive_until"] =
1365 json!(crate::cli::parse_deadline_until(deadline).map_err(|e| e.to_string())?);
1366 }
1367 if let Some(peer_dh) = crate::enc::wire_x25519::peer_dh_pubkey(&trust_for_did, peer) {
1370 crate::enc::wire_x25519::seal_event_body(&mut event, &peer_dh, &sk_seed)
1371 .map_err(|e| e.to_string())?;
1372 }
1373 let signed =
1374 sign_message_v31(&event, &sk_seed, &pk_bytes, &handle).map_err(|e| e.to_string())?;
1375 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1376
1377 if !queue {
1382 let outcome = crate::send::attempt_deliver(peer, &signed).map_err(|e| e.to_string())?;
1383 let mut v = crate::send::delivery_json(&outcome, peer);
1384 let daemon_seen = crate::ensure_up::daemon_pidfile_alive();
1394 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1395 if let Some(obj) = v.as_object_mut() {
1396 obj.insert("daemon_seen".into(), json!(daemon_seen));
1397 obj.insert("last_sync_age_seconds".into(), json!(last_sync_age));
1398 obj.insert(
1399 "stale_sync".into(),
1400 json!(config::stale_sync(last_sync_age)),
1401 );
1402 }
1403 return Ok(v);
1404 }
1405
1406 let line = serde_json::to_vec(&signed).map_err(|e| e.to_string())?;
1408 let outbox = config::append_outbox_record(peer, &line).map_err(|e| e.to_string())?;
1409 let daemon_seen = crate::ensure_up::daemon_pidfile_alive();
1412 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
1413 let peer_pinned_in_trust = trust_for_did
1420 .get("agents")
1421 .and_then(Value::as_object)
1422 .map(|a| a.contains_key(peer))
1423 .unwrap_or(false);
1424 let peer_in_relay_state = config::read_relay_state()
1425 .ok()
1426 .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
1427 .map(|peers| peers.contains_key(peer))
1428 .unwrap_or(false);
1429 let pending_inbound = crate::pending_inbound_pair::list_pending_inbound()
1430 .ok()
1431 .map(|v| v.iter().any(|p| p.peer_handle == peer))
1432 .unwrap_or(false);
1433 let unpushable = !peer_pinned_in_trust && !peer_in_relay_state && !pending_inbound;
1434 let mut out = json!({
1435 "event_id": event_id,
1436 "status": "queued",
1437 "peer": peer,
1438 "outbox": outbox.to_string_lossy(),
1439 "daemon_seen": daemon_seen,
1440 "last_sync_age_seconds": last_sync_age,
1441 "stale_sync": config::stale_sync(last_sync_age),
1442 });
1443 if unpushable {
1444 out["warning"] = json!(format!(
1445 "`{peer}` is not pinned and has no pending pair — the event will sit in outbox forever unless you pair first (wire_dial)."
1446 ));
1447 }
1448 Ok(out)
1449}
1450
1451fn tool_pull() -> Result<Value, String> {
1458 crate::cli::run_sync_pull().map_err(|e| format!("{e:#}"))
1459}
1460
1461fn tool_tail(args: &Value) -> Result<Value, String> {
1462 use crate::config;
1463 use crate::signing::verify_message_v31;
1464
1465 let peer_filter = args.get("peer").and_then(Value::as_str);
1466 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1467 let oldest = args.get("oldest").and_then(Value::as_bool).unwrap_or(false);
1472 let inbox = config::inbox_dir().map_err(|e| e.to_string())?;
1473 if !inbox.exists() {
1474 return Ok(json!([]));
1475 }
1476 let trust = config::read_trust().map_err(|e| e.to_string())?;
1477 let seed = crate::enc::wire_x25519::self_seed_for_read();
1478 let entries: Vec<_> = std::fs::read_dir(&inbox)
1479 .map_err(|e| e.to_string())?
1480 .filter_map(|e| e.ok())
1481 .map(|e| e.path())
1482 .filter(|p| {
1483 p.extension().map(|x| x == "jsonl").unwrap_or(false)
1484 && match peer_filter {
1485 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1486 None => true,
1487 }
1488 })
1489 .collect();
1490
1491 let mut collected: Vec<(String, usize, Value)> = Vec::new();
1494 for path in &entries {
1495 let body = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1496 for (idx, line) in body.lines().enumerate() {
1497 let event: Value = match serde_json::from_str(line) {
1498 Ok(v) => v,
1499 Err(_) => continue,
1500 };
1501 let verified = verify_message_v31(&event, &trust).is_ok();
1502 let mut event_with_meta = match &seed {
1504 Some(s) => crate::enc::wire_x25519::decrypt_event_for_read(&event, &trust, s),
1505 None => event.clone(),
1506 };
1507 let undecryptable = crate::enc::wire_x25519::is_encrypted_unreadable(&event_with_meta);
1511 if let Some(obj) = event_with_meta.as_object_mut() {
1512 obj.insert("verified".into(), json!(verified));
1513 if undecryptable {
1514 let placeholder =
1515 crate::enc::wire_x25519::undecryptable_body_placeholder(&event);
1516 obj.insert("decryptable".into(), json!(false));
1517 obj.insert("body".into(), json!(placeholder));
1518 }
1519 }
1520 let ts = event
1521 .get("timestamp")
1522 .and_then(Value::as_str)
1523 .unwrap_or("")
1524 .to_string();
1525 collected.push((ts, idx, event_with_meta));
1526 }
1527 }
1528 collected.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
1529
1530 let total = collected.len();
1531 let window: Vec<Value> = if limit == 0 {
1532 collected.into_iter().map(|(_, _, e)| e).collect()
1533 } else if oldest {
1534 collected
1535 .into_iter()
1536 .take(limit)
1537 .map(|(_, _, e)| e)
1538 .collect()
1539 } else {
1540 let start = total.saturating_sub(limit);
1541 collected
1542 .into_iter()
1543 .skip(start)
1544 .map(|(_, _, e)| e)
1545 .collect()
1546 };
1547 Ok(Value::Array(window))
1548}
1549
1550fn tool_verify(args: &Value) -> Result<Value, String> {
1551 use crate::config;
1552 use crate::signing::verify_message_v31;
1553
1554 let event_str = args
1555 .get("event")
1556 .and_then(Value::as_str)
1557 .ok_or("missing 'event'")?;
1558 let event: Value =
1559 serde_json::from_str(event_str).map_err(|e| format!("invalid event JSON: {e}"))?;
1560 let trust = config::read_trust().map_err(|e| e.to_string())?;
1561 match verify_message_v31(&event, &trust) {
1562 Ok(()) => Ok(json!({"verified": true})),
1563 Err(e) => Ok(json!({"verified": false, "reason": e.to_string()})),
1564 }
1565}
1566
1567fn ensure_session_bootstrapped() {
1576 if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
1577 return;
1578 }
1579 if crate::config::is_initialized().unwrap_or(false) {
1580 return; }
1582 let (did, relay_url, slot_id, slot_token) =
1583 match crate::pair_invite::ensure_self_with_relay(None) {
1584 Ok(t) => t,
1585 Err(_) => return, };
1587 if let Ok(card) = crate::config::read_agent_card() {
1588 let persona = crate::agent_card::display_handle_from_did(&did).to_string();
1589 let client = crate::relay_client::RelayClient::new(&relay_url);
1590 let _ = client.handle_claim_v2(&persona, &slot_id, &slot_token, None, &card, None);
1591 }
1592}
1593
1594fn tool_init(args: &Value) -> Result<Value, String> {
1595 let handle = args
1596 .get("handle")
1597 .and_then(Value::as_str)
1598 .ok_or("missing 'handle'")?;
1599 let name = args.get("name").and_then(Value::as_str);
1600 let relay = args.get("relay_url").and_then(Value::as_str);
1601 crate::init::init_self_idempotent(handle, name, relay).map_err(|e| e.to_string())
1602}
1603
1604fn tool_invite_mint(args: &Value) -> Result<Value, String> {
1607 let relay_url = args.get("relay_url").and_then(Value::as_str);
1608 let ttl_secs = args.get("ttl_secs").and_then(Value::as_u64);
1609 let uses = args
1610 .get("uses")
1611 .and_then(Value::as_u64)
1612 .map(|u| u as u32)
1613 .unwrap_or(1);
1614 let url =
1615 crate::pair_invite::mint_invite(ttl_secs, uses, relay_url).map_err(|e| format!("{e:#}"))?;
1616 let ttl_resolved = ttl_secs.unwrap_or(crate::pair_invite::DEFAULT_TTL_SECS);
1617 Ok(json!({
1618 "invite_url": url,
1619 "ttl_secs": ttl_resolved,
1620 "uses": uses,
1621 }))
1622}
1623
1624fn tool_invite_accept(args: &Value) -> Result<Value, String> {
1625 let url = args
1626 .get("url")
1627 .and_then(Value::as_str)
1628 .ok_or("missing 'url'")?;
1629 crate::pair_invite::accept_invite(url).map_err(|e| format!("{e:#}"))
1630}
1631
1632fn tool_here() -> Result<Value, String> {
1637 crate::cli::here_summary().map_err(|e| format!("{e:#}"))
1638}
1639
1640fn tool_dial(args: &Value) -> Result<Value, String> {
1652 let name = args
1653 .get("name")
1654 .and_then(Value::as_str)
1655 .or_else(|| args.get("handle").and_then(Value::as_str))
1656 .ok_or("missing 'name'")?;
1657
1658 if name.contains('@') {
1659 let mut a = args.clone();
1661 if let Some(obj) = a.as_object_mut() {
1662 obj.insert("handle".into(), Value::String(name.to_string()));
1663 }
1664 return tool_add(&a);
1665 }
1666
1667 match crate::cli::resolve_name_to_target(name) {
1672 Ok(crate::cli::DialTarget::PinnedPeer {
1673 handle, did, tier, ..
1674 }) => {
1675 let reason = crate::send::unsendable_reason(&handle);
1685 let mut out = json!({
1686 "name_input": name,
1687 "status": "already_pinned",
1688 "peer_handle": handle,
1689 "did": did,
1690 "tier": tier,
1691 "sendable": reason.is_none(),
1692 });
1693 if let Some(r) = reason
1694 && let Some(obj) = out.as_object_mut()
1695 {
1696 obj.insert("reason".into(), json!(r));
1697 }
1698 Ok(out)
1699 }
1700 Ok(crate::cli::DialTarget::LocalSister { session_name, .. }) => {
1701 let drop =
1702 crate::cli::add_local_sister_core(&session_name).map_err(|e| format!("{e:#}"))?;
1703 Ok(json!({
1704 "name_input": name,
1705 "status": "paired_local_sister",
1706 "peer_handle": drop.peer_handle,
1707 "paired_with": drop.paired_with_did,
1708 "event_id": drop.event_id,
1709 "delivered_via": drop.delivered_via,
1710 }))
1711 }
1712 Err(e) => Err(format!("{e:#}")),
1715 }
1716}
1717
1718fn tool_add(args: &Value) -> Result<Value, String> {
1719 let handle = args
1720 .get("handle")
1721 .and_then(Value::as_str)
1722 .ok_or("missing 'handle'")?;
1723 let relay_override = args.get("relay_url").and_then(Value::as_str);
1724
1725 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
1726
1727 let (our_did, our_relay, our_slot_id, our_slot_token) =
1729 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1730
1731 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)
1733 .map_err(|e| format!("{e:#}"))?;
1734 let peer_card = resolved
1735 .get("card")
1736 .cloned()
1737 .ok_or("resolved missing card")?;
1738 let peer_did = resolved
1739 .get("did")
1740 .and_then(Value::as_str)
1741 .ok_or("resolved missing did")?
1742 .to_string();
1743
1744 let (peer_fp, _did_fp, fp_matches) =
1752 crate::cli::resolved_key_fingerprint(&peer_card, &peer_did);
1753 if peer_fp.is_some() && !fp_matches {
1754 return Err(format!(
1755 "REFUSING to pair `{handle}` — the resolved card's key fingerprint ({}) does not match its claimed DID `{peer_did}` (poisoned discovery: the relay served a card whose key ≠ its identity). Verify with the peer out-of-band.",
1756 peer_fp.as_deref().unwrap_or("?")
1757 ));
1758 }
1759
1760 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
1761 let peer_slot_id = resolved
1762 .get("slot_id")
1763 .and_then(Value::as_str)
1764 .ok_or("resolved missing slot_id")?
1765 .to_string();
1766 let peer_relay = resolved
1767 .get("relay_url")
1768 .and_then(Value::as_str)
1769 .map(str::to_string)
1770 .or_else(|| relay_override.map(str::to_string))
1771 .unwrap_or_else(|| crate::pair_profile::relay_url_for_domain(&parsed.domain));
1772
1773 crate::config::update_trust(|trust| {
1775 crate::trust::add_agent_card_pin(trust, &peer_card, Some("VERIFIED"))
1776 .map_err(anyhow::Error::msg)
1777 })
1778 .map_err(|e| format!("{e:#}"))?;
1779 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1780 let existing_token =
1785 crate::endpoints::peer_federation_token(&relay_state, &peer_handle, &peer_relay);
1786 crate::endpoints::pin_peer_endpoints(
1788 &mut relay_state,
1789 &peer_handle,
1790 &[crate::endpoints::Endpoint::federation(
1791 peer_relay.clone(),
1792 peer_slot_id.clone(),
1793 existing_token.clone(),
1794 )],
1795 )
1796 .map_err(|e| format!("{e:#}"))?;
1797 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1798
1799 let our_card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1801 let sk_seed = crate::config::read_private_key().map_err(|e| format!("{e:#}"))?;
1802 let our_handle_str = crate::agent_card::display_handle_from_did(&our_did).to_string();
1803 let pk_b64 = our_card
1804 .get("verify_keys")
1805 .and_then(Value::as_object)
1806 .and_then(|m| m.values().next())
1807 .and_then(|v| v.get("key"))
1808 .and_then(Value::as_str)
1809 .ok_or("our card missing verify_keys[*].key")?;
1810 let pk_bytes = crate::signing::b64decode(pk_b64).map_err(|e| format!("{e:#}"))?;
1811 let now = time::OffsetDateTime::now_utc()
1812 .format(&time::format_description::well_known::Rfc3339)
1813 .unwrap_or_default();
1814 let event = json!({
1815 "timestamp": now,
1816 "from": our_did,
1817 "to": peer_did,
1818 "type": "pair_drop",
1819 "kind": 1100u32,
1820 "body": {
1821 "card": our_card,
1822 "relay_url": our_relay,
1823 "slot_id": our_slot_id,
1824 "slot_token": our_slot_token,
1825 },
1826 });
1827 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle_str)
1828 .map_err(|e| format!("{e:#}"))?;
1829
1830 let client = crate::relay_client::RelayClient::new(&peer_relay);
1831 let resp = client
1832 .handle_intro(&parsed.nick, &signed)
1833 .map_err(|e| format!("{e:#}"))?;
1834 let event_id = signed
1835 .get("event_id")
1836 .and_then(Value::as_str)
1837 .unwrap_or("")
1838 .to_string();
1839 Ok(json!({
1840 "handle": handle,
1841 "paired_with": peer_did,
1842 "peer_handle": peer_handle,
1843 "event_id": event_id,
1844 "drop_response": resp,
1845 "status": "drop_sent",
1846 }))
1847}
1848
1849fn tool_pair_accept(args: &Value) -> Result<Value, String> {
1854 let peer = args
1855 .get("peer")
1856 .and_then(Value::as_str)
1857 .ok_or("missing 'peer'")?;
1858 let nick = crate::agent_card::bare_handle(peer);
1859 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)
1860 .map_err(|e| format!("{e:#}"))?
1861 .ok_or_else(|| {
1862 format!(
1863 "no pending pair request from {nick}. Call wire_pending to enumerate, \
1864 or wire_add to send a fresh outbound pair request."
1865 )
1866 })?;
1867
1868 crate::config::update_trust(|trust| {
1871 crate::trust::add_agent_card_pin(trust, &pending.peer_card, Some("VERIFIED"))
1872 .map_err(anyhow::Error::msg)
1873 })
1874 .map_err(|e| format!("{e:#}"))?;
1875
1876 let ack_endpoints: Vec<crate::endpoints::Endpoint> = if pending.peer_endpoints.is_empty() {
1881 vec![crate::endpoints::Endpoint::federation(
1882 pending.peer_relay_url.clone(),
1883 pending.peer_slot_id.clone(),
1884 pending.peer_slot_token.clone(),
1885 )]
1886 } else {
1887 pending.peer_endpoints.clone()
1888 };
1889 let mut relay_state = crate::config::read_relay_state().map_err(|e| format!("{e:#}"))?;
1894 crate::endpoints::pin_peer_endpoints(&mut relay_state, &pending.peer_handle, &ack_endpoints)
1895 .map_err(|e| format!("{e:#}"))?;
1896 crate::config::write_relay_state(&relay_state).map_err(|e| format!("{e:#}"))?;
1897
1898 crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &ack_endpoints).map_err(|e| {
1901 format!(
1902 "pair_drop_ack send to {} (across {} endpoint(s)) failed: {e:#}",
1903 pending.peer_handle,
1904 ack_endpoints.len()
1905 )
1906 })?;
1907
1908 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1909
1910 let reply_path_reachable = !crate::endpoints::endpoints_are_local_only(&ack_endpoints);
1913
1914 Ok(json!({
1915 "status": "bilateral_accepted",
1916 "peer_handle": pending.peer_handle,
1917 "peer_did": pending.peer_did,
1918 "peer_relay_url": pending.peer_relay_url,
1919 "via": "pending_inbound",
1920 "reply_path_reachable": reply_path_reachable,
1921 }))
1922}
1923
1924fn tool_pair_reject(args: &Value) -> Result<Value, String> {
1928 let peer = args
1929 .get("peer")
1930 .and_then(Value::as_str)
1931 .ok_or("missing 'peer'")?;
1932 let nick = crate::agent_card::bare_handle(peer);
1933 let existed =
1934 crate::pending_inbound_pair::read_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1935 crate::pending_inbound_pair::consume_pending_inbound(nick).map_err(|e| format!("{e:#}"))?;
1936 Ok(json!({
1937 "peer": nick,
1938 "rejected": existed.is_some(),
1939 "had_pending": existed.is_some(),
1940 }))
1941}
1942
1943fn tool_pair_list_inbound() -> Result<Value, String> {
1947 let items =
1948 crate::pending_inbound_pair::list_pending_inbound().map_err(|e| format!("{e:#}"))?;
1949 Ok(json!(items))
1950}
1951
1952fn tool_claim_handle(args: &Value) -> Result<Value, String> {
1953 let typed = args.get("nick").and_then(Value::as_str);
1954 let relay_override = args.get("relay_url").and_then(Value::as_str);
1955 let public_url = args.get("public_url").and_then(Value::as_str);
1956
1957 let (_, our_relay, our_slot_id, our_slot_token) =
1959 crate::pair_invite::ensure_self_with_relay(relay_override).map_err(|e| format!("{e:#}"))?;
1960 let claim_relay = relay_override.unwrap_or(&our_relay);
1961 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
1962
1963 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
1968 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
1969 let nick = if canonical.is_empty() {
1970 typed.unwrap_or_default().to_string()
1971 } else {
1972 canonical
1973 };
1974 let typed_nick_ignored = typed.map(|t| t != nick).unwrap_or(false);
1975
1976 let client = crate::relay_client::RelayClient::new(claim_relay);
1977 let resp = client
1978 .handle_claim(&nick, &our_slot_id, &our_slot_token, public_url, &card)
1979 .map_err(|e| format!("{e:#}"))?;
1980 Ok(json!({
1981 "nick": nick,
1982 "relay": claim_relay,
1983 "response": resp,
1984 "one_name": true,
1985 "typed_nick_ignored": typed_nick_ignored,
1986 }))
1987}
1988
1989fn tool_whois(args: &Value) -> Result<Value, String> {
1990 if let Some(handle) = args.get("handle").and_then(Value::as_str) {
1991 if !handle.contains('@')
2001 && let Ok(target) = crate::cli::resolve_name_to_target(handle)
2002 {
2003 return Ok(dial_target_to_whois_json(&target));
2004 }
2005 let parsed = crate::pair_profile::parse_handle(handle).map_err(|e| format!("{e:#}"))?;
2006 let relay_override = args.get("relay_url").and_then(Value::as_str);
2007 crate::pair_profile::resolve_handle(&parsed, relay_override).map_err(|e| format!("{e:#}"))
2008 } else {
2009 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2013 let mut payload = serde_json::Map::new();
2014 payload.insert(
2015 "did".into(),
2016 card.get("did").cloned().unwrap_or(Value::Null),
2017 );
2018 payload.insert(
2019 "profile".into(),
2020 card.get("profile").cloned().unwrap_or(Value::Null),
2021 );
2022 for (k, v) in crate::cli::op_claims_from_card(&card) {
2023 payload.insert(k, v);
2024 }
2025 Ok(Value::Object(payload))
2026 }
2027}
2028
2029fn dial_target_to_whois_json(target: &crate::cli::DialTarget) -> Value {
2035 use crate::cli::DialTarget;
2036 match target {
2037 DialTarget::PinnedPeer {
2038 handle,
2039 did,
2040 nickname,
2041 emoji,
2042 tier,
2043 } => {
2044 let op_claims = crate::config::read_trust()
2045 .ok()
2046 .and_then(|t| {
2047 t.get("agents")
2048 .and_then(Value::as_object)
2049 .and_then(|m| m.get(handle))
2050 .and_then(|a| a.get("card").cloned())
2051 })
2052 .map(|c| crate::cli::op_claims_from_card(&c))
2053 .unwrap_or_default();
2054 let mut payload = serde_json::Map::new();
2055 payload.insert("kind".into(), json!("pinned_peer"));
2056 payload.insert("handle".into(), json!(handle));
2057 payload.insert("did".into(), json!(did));
2058 payload.insert("nickname".into(), json!(nickname));
2059 payload.insert("emoji".into(), json!(emoji));
2060 payload.insert("tier".into(), json!(tier));
2061 for (k, v) in op_claims {
2062 payload.insert(k, v);
2063 }
2064 Value::Object(payload)
2065 }
2066 DialTarget::LocalSister {
2067 session_name,
2068 handle,
2069 did,
2070 nickname,
2071 emoji,
2072 } => json!({
2073 "kind": "local_sister",
2074 "session_name": session_name,
2075 "handle": handle,
2076 "did": did,
2077 "nickname": nickname,
2078 "emoji": emoji,
2079 }),
2080 }
2081}
2082
2083fn tool_profile_set(args: &Value) -> Result<Value, String> {
2084 let field = args
2085 .get("field")
2086 .and_then(Value::as_str)
2087 .ok_or("missing 'field'")?;
2088 let raw_value = args.get("value").cloned().ok_or("missing 'value'")?;
2089 let value = if let Some(s) = raw_value.as_str() {
2093 serde_json::from_str(s).unwrap_or(Value::String(s.to_string()))
2094 } else {
2095 raw_value
2096 };
2097 let new_profile =
2098 crate::pair_profile::write_profile_field(field, value).map_err(|e| format!("{e:#}"))?;
2099 Ok(json!({
2100 "field": field,
2101 "profile": new_profile,
2102 }))
2103}
2104
2105fn tool_profile_get() -> Result<Value, String> {
2106 let card = crate::config::read_agent_card().map_err(|e| format!("{e:#}"))?;
2107 Ok(json!({
2108 "did": card.get("did").cloned().unwrap_or(Value::Null),
2109 "profile": card.get("profile").cloned().unwrap_or(Value::Null),
2110 }))
2111}
2112
2113fn parse_kind(s: &str) -> u32 {
2116 if let Ok(n) = s.parse::<u32>() {
2117 return n;
2118 }
2119 for (id, name) in crate::signing::kinds() {
2120 if *name == s {
2121 return *id;
2122 }
2123 }
2124 1
2125}
2126
2127fn error_response(id: &Value, code: i32, message: &str) -> Value {
2128 json!({
2129 "jsonrpc": "2.0",
2130 "id": id,
2131 "error": {"code": code, "message": message}
2132 })
2133}
2134
2135#[cfg(test)]
2136mod tests {
2137 use super::*;
2138
2139 #[test]
2140 fn mcp_stale_binary_note_flags_only_real_mismatch() {
2141 assert!(mcp_stale_binary_note("0.16.0", None).is_none());
2143 assert!(mcp_stale_binary_note("0.16.0", Some("0.16.0")).is_none());
2145 let n = mcp_stale_binary_note("0.14.1", Some("0.16.0")).expect("mismatch must flag");
2147 assert!(n.contains("0.14.1") && n.contains("0.16.0"), "{n}");
2148 assert!(n.contains("/mcp"), "{n}");
2149 }
2150
2151 #[test]
2152 fn unknown_method_returns_jsonrpc_error() {
2153 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "nonsense"});
2154 let resp = handle_request(&req, &McpState::default());
2155 assert_eq!(resp["error"]["code"], -32601);
2156 }
2157
2158 #[test]
2159 fn initialize_advertises_tools_capability() {
2160 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}});
2161 let resp = handle_request(&req, &McpState::default());
2162 assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
2163 assert!(resp["result"]["capabilities"]["tools"].is_object());
2164 assert_eq!(resp["result"]["serverInfo"]["name"], SERVER_NAME);
2165 }
2166
2167 #[test]
2168 fn tools_list_includes_pairing_and_messaging() {
2169 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2170 let resp = handle_request(&req, &McpState::default());
2171 let names: Vec<&str> = resp["result"]["tools"]
2172 .as_array()
2173 .unwrap()
2174 .iter()
2175 .filter_map(|t| t["name"].as_str())
2176 .collect();
2177 for required in [
2178 "wire_whoami",
2179 "wire_peers",
2180 "wire_send",
2181 "wire_tail",
2182 "wire_verify",
2183 "wire_init",
2184 "wire_dial",
2185 ] {
2186 assert!(
2187 names.contains(&required),
2188 "missing required tool {required}"
2189 );
2190 }
2191 for removed in [
2194 "wire_pair_initiate",
2195 "wire_pair_join",
2196 "wire_pair_check",
2197 "wire_pair_confirm",
2198 "wire_pair_initiate_detached",
2199 "wire_pair_join_detached",
2200 "wire_pair_list_pending",
2201 "wire_pair_confirm_detached",
2202 "wire_pair_cancel_pending",
2203 ] {
2204 assert!(
2205 !names.contains(&removed),
2206 "SAS pair tool {removed} must not be advertised after removal"
2207 );
2208 }
2209 assert!(
2213 !names.contains(&"wire_join"),
2214 "wire_join must not be advertised — SAS pairing removed"
2215 );
2216 }
2217
2218 fn catalog_shape() -> Vec<String> {
2223 tool_defs()
2224 .iter()
2225 .map(|t| {
2226 let name = t["name"].as_str().unwrap_or("<noname>");
2227 let mut props: Vec<String> = t["inputSchema"]["properties"]
2228 .as_object()
2229 .map(|m| m.keys().cloned().collect())
2230 .unwrap_or_default();
2231 props.sort();
2232 let mut req: Vec<String> = t["inputSchema"]["required"]
2233 .as_array()
2234 .map(|a| {
2235 a.iter()
2236 .filter_map(|v| v.as_str().map(String::from))
2237 .collect()
2238 })
2239 .unwrap_or_default();
2240 req.sort();
2241 format!("{name}({})[req:{}]", props.join(","), req.join(","))
2242 })
2243 .collect()
2244 }
2245
2246 #[test]
2247 fn mcp_catalog_schema_is_frozen() {
2248 let golden: &[&str] = &[
2256 "wire_whoami()[req:]",
2257 "wire_peers()[req:]",
2258 "wire_here()[req:]",
2259 "wire_status()[req:]",
2260 "wire_send(body,kind,peer,queue,time_sensitive_until)[req:body,kind,peer]",
2261 "wire_pull()[req:]",
2262 "wire_tail(limit,oldest,peer)[req:]",
2263 "wire_verify(event)[req:event]",
2264 "wire_init(handle,name,relay_url)[req:handle]",
2265 "wire_invite_mint(relay_url,ttl_secs,uses)[req:]",
2266 "wire_invite_accept(url)[req:url]",
2267 "wire_add(handle,relay_url)[req:handle]",
2268 "wire_dial(name)[req:name]",
2269 "wire_accept(peer)[req:peer]",
2270 "wire_reject(peer)[req:peer]",
2271 "wire_pending()[req:]",
2272 "wire_claim(nick,public_url,relay_url)[req:]",
2273 "wire_whois(handle,relay_url)[req:]",
2274 "wire_profile_set(field,value)[req:field,value]",
2275 "wire_profile_get()[req:]",
2276 "wire_group_create(name)[req:name]",
2277 "wire_group_add(group,peer)[req:group,peer]",
2278 "wire_group_send(group,message)[req:group,message]",
2279 "wire_group_tail(group,limit)[req:group]",
2280 "wire_group_list()[req:]",
2281 "wire_group_invite(group)[req:group]",
2282 "wire_group_join(code)[req:code]",
2283 ];
2284 let actual = catalog_shape();
2285 assert_eq!(
2286 actual, golden,
2287 "MCP catalog drifted from the frozen 1.0 surface — see docs/DEPRECATION_POLICY.md"
2288 );
2289 assert_eq!(actual.len(), 27, "the frozen 1.0 MCP catalog is 27 tools");
2290 }
2291
2292 #[test]
2293 fn agent_docs_match_advertised_tools() {
2294 let advertised: Vec<String> = tool_defs()
2301 .iter()
2302 .filter_map(|t| t["name"].as_str().map(str::to_string))
2303 .collect();
2304 let manifest = env!("CARGO_MANIFEST_DIR");
2305 let plugin = std::fs::read_to_string(format!("{manifest}/docs/PLUGIN.md"))
2306 .expect("read docs/PLUGIN.md");
2307 for name in &advertised {
2308 assert!(
2309 plugin.contains(name.as_str()),
2310 "docs/PLUGIN.md missing advertised MCP tool `{name}` — it drifted from tool_defs()"
2311 );
2312 }
2313 let integ = std::fs::read_to_string(format!("{manifest}/docs/AGENT_INTEGRATION.md"))
2314 .expect("read docs/AGENT_INTEGRATION.md");
2315 for (doc, body) in [
2316 ("docs/PLUGIN.md", &plugin),
2317 ("docs/AGENT_INTEGRATION.md", &integ),
2318 ] {
2319 for ghost in [
2320 "wire_up",
2321 "wire_pair_host",
2322 "wire_pair_join",
2323 "wire_pair_confirm",
2324 "wire_pair_accept",
2325 "wire_pair_reject",
2326 "wire_pair_list_inbound",
2327 ] {
2328 assert!(
2329 !body.contains(ghost),
2330 "{doc} advertises ghost MCP tool `{ghost}` (removed / never existed)"
2331 );
2332 }
2333 }
2334 }
2335
2336 #[test]
2337 fn legacy_wire_join_call_returns_helpful_error() {
2338 let req = json!({
2339 "jsonrpc": "2.0",
2340 "id": 1,
2341 "method": "tools/call",
2342 "params": {"name": "wire_join", "arguments": {}}
2343 });
2344 let resp = handle_request(&req, &McpState::default());
2345 assert_eq!(resp["result"]["isError"], true);
2346 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2347 assert!(
2348 text.contains("wire_dial"),
2349 "expected redirect to wire_dial, got: {text}"
2350 );
2351 }
2352
2353 #[test]
2354 fn tools_list_canonical_present_deprecated_absent() {
2355 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"});
2356 let resp = handle_request(&req, &McpState::default());
2357 let names: Vec<&str> = resp["result"]["tools"]
2358 .as_array()
2359 .unwrap()
2360 .iter()
2361 .filter_map(|t| t["name"].as_str())
2362 .collect();
2363
2364 for required in ["wire_accept", "wire_reject", "wire_pending"] {
2366 assert!(
2367 names.contains(&required),
2368 "canonical tool {required} missing from tools/list"
2369 );
2370 }
2371
2372 for removed in [
2374 "wire_pair_accept",
2375 "wire_pair_reject",
2376 "wire_pair_list_inbound",
2377 ] {
2378 assert!(
2379 !names.contains(&removed),
2380 "deprecated tool {removed} must not appear in tools/list"
2381 );
2382 }
2383 }
2384
2385 #[test]
2386 fn deprecated_pair_accept_call_returns_helpful_error() {
2387 for (old_name, canonical) in [
2388 ("wire_pair_accept", "wire_accept"),
2389 ("wire_pair_reject", "wire_reject"),
2390 ("wire_pair_list_inbound", "wire_pending"),
2391 ] {
2392 let req = json!({
2393 "jsonrpc": "2.0",
2394 "id": 1,
2395 "method": "tools/call",
2396 "params": {"name": old_name, "arguments": {}}
2397 });
2398 let resp = handle_request(&req, &McpState::default());
2399 assert_eq!(
2400 resp["result"]["isError"], true,
2401 "calling {old_name} should return isError:true"
2402 );
2403 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
2404 assert!(
2405 text.contains(canonical),
2406 "error for {old_name} should mention {canonical}, got: {text}"
2407 );
2408 }
2409 }
2410
2411 #[test]
2412 fn initialize_advertises_resources_capability() {
2413 let req = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"});
2414 let resp = handle_request(&req, &McpState::default());
2415 let caps = &resp["result"]["capabilities"];
2416 assert!(
2417 caps["resources"].is_object(),
2418 "resources capability must be present, got {resp}"
2419 );
2420 assert_eq!(
2421 caps["resources"]["subscribe"], true,
2422 "subscribe shipped in v0.2.1"
2423 );
2424 }
2425
2426 #[test]
2427 fn resources_read_with_bad_uri_errors() {
2428 let req = json!({
2429 "jsonrpc": "2.0",
2430 "id": 1,
2431 "method": "resources/read",
2432 "params": {"uri": "http://example.com/not-a-wire-uri"}
2433 });
2434 let resp = handle_request(&req, &McpState::default());
2435 assert!(resp.get("error").is_some(), "expected error, got {resp}");
2436 }
2437
2438 #[test]
2439 fn parse_inbox_uri_handles_variants() {
2440 assert_eq!(parse_inbox_uri("wire://inbox/paul"), Some("paul".into()));
2441 assert_eq!(parse_inbox_uri("wire://inbox/all"), None);
2442 assert!(
2443 parse_inbox_uri("wire://inbox/")
2444 .unwrap()
2445 .starts_with("__invalid__"),
2446 "empty peer must be invalid"
2447 );
2448 assert!(
2449 parse_inbox_uri("http://other")
2450 .unwrap()
2451 .starts_with("__invalid__"),
2452 "non-wire scheme must be invalid"
2453 );
2454 }
2455
2456 #[test]
2457 fn ping_returns_empty_result() {
2458 let req = json!({"jsonrpc": "2.0", "id": 7, "method": "ping"});
2459 let resp = handle_request(&req, &McpState::default());
2460 assert_eq!(resp["id"], 7);
2461 assert!(resp["result"].is_object());
2462 }
2463
2464 #[test]
2465 fn notification_returns_null_no_reply() {
2466 let req = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
2467 let resp = handle_request(&req, &McpState::default());
2468 assert_eq!(resp, Value::Null);
2469 }
2470
2471 #[test]
2478 fn detect_session_wire_home_resolves_registered_cwd() {
2479 crate::config::test_support::with_temp_home(|| {
2480 let wire_home = std::env::var("WIRE_HOME").unwrap();
2486 let sessions_root = std::path::PathBuf::from(&wire_home).join("sessions");
2487 std::fs::create_dir_all(&sessions_root).unwrap();
2488 let session_home = crate::session::session_dir("test-alpha").unwrap();
2489 std::fs::create_dir_all(&session_home).unwrap();
2490 let fake_cwd = "/tmp/fake-project-cwd-abc123";
2491 let registry = json!({"by_cwd": {fake_cwd: "test-alpha"}});
2492 std::fs::write(
2493 sessions_root.join("registry.json"),
2494 serde_json::to_vec_pretty(®istry).unwrap(),
2495 )
2496 .unwrap();
2497
2498 let got = crate::session::detect_session_wire_home(std::path::Path::new(fake_cwd));
2500 assert_eq!(
2501 got.as_deref(),
2502 Some(session_home.as_path()),
2503 "registered cwd must resolve to session_home"
2504 );
2505
2506 let nope = crate::session::detect_session_wire_home(std::path::Path::new(
2508 "/tmp/cwd-not-in-registry-xyz789",
2509 ));
2510 assert!(nope.is_none(), "unregistered cwd must return None");
2511
2512 let stale_cwd = "/tmp/stale-session-cwd";
2515 let stale_registry =
2516 json!({"by_cwd": {fake_cwd: "test-alpha", stale_cwd: "test-stale"}});
2517 std::fs::write(
2518 sessions_root.join("registry.json"),
2519 serde_json::to_vec_pretty(&stale_registry).unwrap(),
2520 )
2521 .unwrap();
2522 let stale_got =
2523 crate::session::detect_session_wire_home(std::path::Path::new(stale_cwd));
2524 assert!(
2525 stale_got.is_none(),
2526 "registered cwd whose session dir is missing must return None"
2527 );
2528 });
2529 }
2530
2531 #[test]
2538 fn dial_target_to_whois_json_pinned_peer_shape() {
2539 let target = crate::cli::DialTarget::PinnedPeer {
2540 handle: "slate-lotus".into(),
2541 did: "did:wire:slate-lotus-88232017".into(),
2542 nickname: Some("slate-lotus".into()),
2543 emoji: Some("🪴".into()),
2544 tier: "VERIFIED".into(),
2545 };
2546 crate::config::test_support::with_temp_home(|| {
2547 let out = dial_target_to_whois_json(&target);
2548 assert_eq!(out.get("kind").and_then(Value::as_str), Some("pinned_peer"));
2549 assert_eq!(
2550 out.get("handle").and_then(Value::as_str),
2551 Some("slate-lotus")
2552 );
2553 assert_eq!(out.get("tier").and_then(Value::as_str), Some("VERIFIED"));
2554 assert!(out.get("op_did").is_none());
2558 });
2559 }
2560
2561 #[test]
2562 fn dial_target_to_whois_json_local_sister_shape() {
2563 let target = crate::cli::DialTarget::LocalSister {
2564 session_name: "vesper-valley".into(),
2565 handle: "vesper-valley".into(),
2566 did: Some("did:wire:vesper-valley-deadbeef".into()),
2567 nickname: Some("vesper-valley".into()),
2568 emoji: Some("🦌".into()),
2569 };
2570 let out = dial_target_to_whois_json(&target);
2571 assert_eq!(
2572 out.get("kind").and_then(Value::as_str),
2573 Some("local_sister")
2574 );
2575 assert_eq!(
2576 out.get("session_name").and_then(Value::as_str),
2577 Some("vesper-valley")
2578 );
2579 assert_eq!(
2580 out.get("did").and_then(Value::as_str),
2581 Some("did:wire:vesper-valley-deadbeef")
2582 );
2583 assert!(out.get("tier").is_none());
2586 assert!(out.get("op_did").is_none());
2587 }
2588}