2026-04-27 01:51:36 +00:00
|
|
|
//! 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<String, String> {
|
|
|
|
|
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<String, String> {
|
|
|
|
|
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).
|
2026-04-27 19:51:27 +00:00
|
|
|
pub(crate) fn tool_move_story(args: &Value, _ctx: &AppContext) -> Result<String, String> {
|
2026-04-27 01:51:36 +00:00
|
|
|
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")?;
|
|
|
|
|
|
2026-04-27 19:51:27 +00:00
|
|
|
let (from_stage, to_stage) = move_story_to_stage(story_id, target_stage)?;
|
2026-04-27 01:51:36 +00:00
|
|
|
|
|
|
|
|
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<String, String> {
|
|
|
|
|
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<Value> = 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<String, String> {
|
|
|
|
|
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}"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 18:13:45 +00:00
|
|
|
/// 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<String, String> {
|
|
|
|
|
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<serde_json::Value> = 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}"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 01:51:36 +00:00
|
|
|
/// MCP tool: count lines in a specific file relative to the project root.
|
|
|
|
|
pub(crate) fn tool_loc_file(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
|
|
|
|
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::*;
|
|
|
|
|
|
2026-04-27 18:13:45 +00:00
|
|
|
#[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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 01:51:36 +00:00
|
|
|
#[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;
|
|
|
|
|
}
|
|
|
|
|
}
|