//! MCP diagnostic tools — server logs, CRDT dump, version, line counting, story movement. use crate::agents::move_story_to_stage; use crate::http::context::AppContext; use crate::log_buffer; use crate::slog; use serde_json::{Value, json}; mod permission; mod usage; pub(crate) use permission::tool_prompt_permission; pub(crate) use usage::tool_get_token_usage; pub(crate) fn tool_get_server_logs(args: &Value) -> Result { let lines_count = args .get("lines") .and_then(|v| v.as_u64()) .map(|n| n.min(1000) as usize) .unwrap_or(100); let filter = args.get("filter").and_then(|v| v.as_str()); let severity = args .get("severity") .and_then(|v| v.as_str()) .and_then(log_buffer::LogLevel::from_str_ci); let recent = log_buffer::global().get_recent(lines_count, filter, severity.as_ref()); let joined = recent.join("\n"); // Clamp to lines_count actual lines in case any entry contains embedded newlines. let all_lines: Vec<&str> = joined.lines().collect(); let start = all_lines.len().saturating_sub(lines_count); Ok(all_lines[start..].join("\n")) } /// Rebuild the server binary and re-exec (delegates to `crate::rebuild`). pub(crate) async fn tool_rebuild_and_restart(ctx: &AppContext) -> Result { slog!("[rebuild] Rebuild and restart requested via MCP tool"); // Signal the Matrix bot (if active) so it can send its own shutdown // announcement before the process is replaced. Best-effort: we wait up // to 1.5 s for the message to be delivered. if let Some(ref tx) = ctx.matrix_shutdown_tx { let _ = tx.send(Some(crate::rebuild::ShutdownReason::Rebuild)); tokio::time::sleep(std::time::Duration::from_millis(1500)).await; } let project_root = ctx.state.get_project_root().unwrap_or_default(); let notifier = ctx.bot_shutdown.as_deref(); crate::rebuild::rebuild_and_restart(&ctx.services.agents, &project_root, notifier).await } /// MCP tool called by Claude Code via `--permission-prompt-tool`. /// /// Forwards the permission request through the shared channel to the active /// WebSocket session, which presents a dialog to the user. Blocks until the /// user approves or denies (with a 5-minute timeout). pub(crate) fn tool_move_story(args: &Value, _ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: story_id")?; let target_stage = args .get("target_stage") .and_then(|v| v.as_str()) .ok_or("Missing required argument: target_stage")?; let (from_stage, to_stage) = move_story_to_stage(story_id, target_stage)?; serde_json::to_string_pretty(&json!({ "story_id": story_id, "from_stage": from_stage, "to_stage": to_stage, "message": format!("Work item '{story_id}' moved from '{from_stage}' to '{to_stage}'.") })) .map_err(|e| format!("Serialization error: {e}")) } /// MCP tool: dump the raw in-memory CRDT state for debugging. /// /// **Debug tool only** — for normal pipeline introspection use `get_pipeline_status`. pub(crate) fn tool_dump_crdt(args: &Value) -> Result { let story_id_filter = args.get("story_id").and_then(|v| v.as_str()); let dump = crate::crdt_state::dump_crdt_state(story_id_filter); let items: Vec = dump .items .into_iter() .map(|item| { json!({ "story_id": item.story_id, "stage": item.stage, "name": item.name, "agent": item.agent, "retry_count": item.retry_count, "blocked": item.blocked, "depends_on": item.depends_on, "claimed_by": item.claimed_by, "claimed_at": item.claimed_at, "content_index": item.content_index, "is_deleted": item.is_deleted, }) }) .collect(); serde_json::to_string_pretty(&json!({ "metadata": { "in_memory_state_loaded": dump.in_memory_state_loaded, "total_items": dump.total_items, "total_ops_in_list": dump.total_ops_in_list, "max_seq_in_list": dump.max_seq_in_list, "persisted_ops_count": dump.persisted_ops_count, "pending_persist_ops_count": null, }, "items": items, })) .map_err(|e| format!("Serialization error: {e}")) } /// MCP tool: return the server version, build hash, and running port. pub(crate) fn tool_get_version(ctx: &AppContext) -> Result { let build_hash = std::fs::read_to_string(".huskies/build_hash").unwrap_or_else(|_| "unknown".to_string()); serde_json::to_string_pretty(&json!({ "version": env!("CARGO_PKG_VERSION"), "build_hash": build_hash.trim(), "port": ctx.services.agents.port(), })) .map_err(|e| format!("Serialization error: {e}")) } /// MCP tool: return read-only peer mesh status. /// /// Returns a JSON object with `local_node_id` and a `peers` array. Each peer /// has `node_id`, `pubkey` (same value — the hex-encoded Ed25519 public key), /// `last_seen` (Unix timestamp), and `is_self` (true for the local node). /// /// This tool is read-only and does not mutate any state. pub(crate) fn tool_mesh_status(_args: &Value) -> Result { let local_id = crate::crdt_state::our_node_id().unwrap_or_default(); let nodes = crate::crdt_state::read_all_node_presence().unwrap_or_default(); let peers: Vec = nodes .into_iter() .map(|n| { let is_self = n.node_id == local_id; json!({ "node_id": n.node_id, "pubkey": n.node_id, "last_seen": n.last_seen, "is_self": is_self, }) }) .collect(); serde_json::to_string_pretty(&json!({ "local_node_id": local_id, "peers": peers, })) .map_err(|e| format!("Serialization error: {e}")) } /// MCP tool: count lines in a specific file relative to the project root. pub(crate) fn tool_loc_file(args: &Value, ctx: &AppContext) -> Result { let file_path = args .get("file_path") .and_then(|v| v.as_str()) .ok_or_else(|| "Missing required argument: file_path".to_string())?; let project_root = ctx.state.get_project_root()?; Ok(crate::chat::commands::loc::loc_single_file( &project_root, file_path, )) } #[cfg(test)] mod tests { use super::*; #[test] fn tool_mesh_status_returns_expected_shape_with_local_node_flagged() { // Initialise a test CRDT so our_node_id() and write_node_presence() work. crate::crdt_state::init_for_test(); let local_id = crate::crdt_state::our_node_id() .expect("CRDT must be initialised before calling tool_mesh_status"); // Write the local node's presence so it appears in read_all_node_presence(). crate::crdt_state::write_node_presence( &local_id, "ws://127.0.0.1:3001/crdt-sync", 1_700_000_000.0, true, ); let result = tool_mesh_status(&json!({})).expect("tool_mesh_status must not fail"); let parsed: serde_json::Value = serde_json::from_str(&result).expect("result must be valid JSON"); // local_node_id field present assert_eq!( parsed["local_node_id"].as_str(), Some(local_id.as_str()), "local_node_id must match our_node_id" ); // peers array present let peers = parsed["peers"].as_array().expect("peers must be an array"); assert!(!peers.is_empty(), "peers must include the local node"); // Find the local peer and verify it is flagged is_self: true let self_peer = peers .iter() .find(|p| p["node_id"].as_str() == Some(&local_id)) .expect("local node must appear in peers list"); assert_eq!( self_peer["is_self"].as_bool(), Some(true), "local node must have is_self: true" ); assert_eq!( self_peer["pubkey"].as_str(), Some(local_id.as_str()), "pubkey must equal node_id" ); assert!( self_peer["last_seen"].is_number(), "last_seen must be a number" ); } #[test] fn tool_mesh_status_is_read_only_no_panic_without_crdt() { // When CRDT is not initialised (different process / test order), // the tool must return a valid (empty) result rather than panicking. let result = tool_mesh_status(&json!({})); // Must succeed — empty peers list is acceptable assert!( result.is_ok(), "tool_mesh_status must not error without CRDT" ); let parsed: serde_json::Value = serde_json::from_str(&result.unwrap()).expect("must be valid JSON"); assert!(parsed["peers"].is_array(), "peers field must be an array"); } #[test] fn tool_get_server_logs_no_args_returns_string() { let result = tool_get_server_logs(&json!({})).unwrap(); // Returns recent log lines (possibly empty in tests) — just verify no panic let _ = result; } #[test] fn tool_get_server_logs_with_filter_returns_matching_lines() { let result = tool_get_server_logs(&json!({"filter": "xyz_unlikely_match_999"})).unwrap(); assert_eq!( result, "", "filter with no matches should return empty string" ); } #[test] fn tool_get_server_logs_with_line_limit() { let result = tool_get_server_logs(&json!({"lines": 5})).unwrap(); assert!(result.lines().count() <= 5); } #[test] fn tool_get_server_logs_max_cap_is_1000() { // Lines > 1000 are capped — just verify it returns without error let result = tool_get_server_logs(&json!({"lines": 9999})).unwrap(); let _ = result; } }