From 62e205b662ef52b1b960ddc56b530fbfb13aac5d Mon Sep 17 00:00:00 2001 From: DenzelPenzel Date: Sat, 20 Jun 2026 13:41:04 +0100 Subject: [PATCH 1/4] feat(mcp): add read-only zombie-mcp diagnostics server --- Cargo.lock | 179 +++++++ Cargo.toml | 3 + crates/mcp/Cargo.toml | 36 ++ crates/mcp/src/diagnostics/evidence.rs | 80 +++ crates/mcp/src/diagnostics/live.rs | 98 ++++ crates/mcp/src/diagnostics/metrics.rs | 300 +++++++++++ crates/mcp/src/diagnostics/mod.rs | 165 ++++++ crates/mcp/src/diagnostics/nodes.rs | 486 ++++++++++++++++++ crates/mcp/src/diagnostics/startup.rs | 391 ++++++++++++++ crates/mcp/src/diagnostics/test_helpers.rs | 39 ++ crates/mcp/src/input.rs | 208 ++++++++ crates/mcp/src/install.rs | 269 ++++++++++ crates/mcp/src/lib.rs | 7 + crates/mcp/src/log_patterns.rs | 185 +++++++ crates/mcp/src/main.rs | 36 ++ crates/mcp/src/recent_runs.rs | 267 ++++++++++ crates/mcp/src/report.rs | 165 ++++++ crates/mcp/src/server.rs | 75 +++ crates/mcp/src/tools.rs | 74 +++ crates/mcp/tests/fixtures/invalid.toml | 2 + crates/mcp/tests/fixtures/startup-failure.log | 3 + crates/mcp/tests/live_diagnostics.rs | 150 ++++++ crates/mcp/tests/mcp_stdio.rs | 131 +++++ docs/src/SUMMARY.md | 1 + docs/src/examples.md | 1 + docs/src/intro.md | 2 +- docs/src/mcp.md | 25 + docs/src/network-spec/global-settings.md | 36 ++ 28 files changed, 3413 insertions(+), 1 deletion(-) create mode 100644 crates/mcp/Cargo.toml create mode 100644 crates/mcp/src/diagnostics/evidence.rs create mode 100644 crates/mcp/src/diagnostics/live.rs create mode 100644 crates/mcp/src/diagnostics/metrics.rs create mode 100644 crates/mcp/src/diagnostics/mod.rs create mode 100644 crates/mcp/src/diagnostics/nodes.rs create mode 100644 crates/mcp/src/diagnostics/startup.rs create mode 100644 crates/mcp/src/diagnostics/test_helpers.rs create mode 100644 crates/mcp/src/input.rs create mode 100644 crates/mcp/src/install.rs create mode 100644 crates/mcp/src/lib.rs create mode 100644 crates/mcp/src/log_patterns.rs create mode 100644 crates/mcp/src/main.rs create mode 100644 crates/mcp/src/recent_runs.rs create mode 100644 crates/mcp/src/report.rs create mode 100644 crates/mcp/src/server.rs create mode 100644 crates/mcp/src/tools.rs create mode 100644 crates/mcp/tests/fixtures/invalid.toml create mode 100644 crates/mcp/tests/fixtures/startup-failure.log create mode 100644 crates/mcp/tests/live_diagnostics.rs create mode 100644 crates/mcp/tests/mcp_stdio.rs create mode 100644 docs/src/mcp.md diff --git a/Cargo.lock b/Cargo.lock index 0682b3b98..03183e90c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5067,6 +5067,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -5458,6 +5470,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -5908,6 +5926,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd1395947e69c07400ef4d43db0051d6f773c21f647ad8b97382fc01f0204c60" +dependencies = [ + "futures", + "indexmap", + "nix 0.30.1", + "tokio", + "tracing", + "windows 0.62.2", +] + [[package]] name = "prometheus" version = "0.13.4" @@ -6482,6 +6514,43 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "process-wrap", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90c23c8f26cae4da838fbc3eadfaecf2d549d97c04b558e7bd90526a9c28b42a" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.107", +] + [[package]] name = "rtnetlink" version = "0.13.1" @@ -7156,6 +7225,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.107", +] + [[package]] name = "schnellru" version = "0.2.4" @@ -7378,6 +7473,17 @@ dependencies = [ "syn 2.0.107", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.107", +] + [[package]] name = "serde_json" version = "1.0.145" @@ -10174,6 +10280,27 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.53.0" @@ -10209,6 +10336,17 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.57.0" @@ -10265,6 +10403,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.5.3" @@ -10438,6 +10586,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -10905,6 +11062,28 @@ dependencies = [ "zombienet-sdk", ] +[[package]] +name = "zombie-mcp" +version = "0.4.12" +dependencies = [ + "anyhow", + "clap", + "futures", + "reqwest", + "rmcp", + "schemars", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "toml 0.8.23", + "tracing", + "tracing-subscriber", + "walkdir", + "zombienet-prom-metrics-parser", + "zombienet-sdk", +] + [[package]] name = "zombie-tui" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 8740a9dce..1d75d2743 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/file-server", "crates/cli", "crates/tui", + "crates/mcp", ] default-members = [ @@ -83,6 +84,8 @@ clap = { version = "4.5", features = ["derive"] } notify = "7.0" sysinfo = "0.32" walkdir = "2.5" +rmcp = { version = "0.16", features = ["server", "transport-io"] } +schemars = "1" codec = { package = "parity-scale-codec", version = "3.6.12", default-features = false, features = ["derive"] } # Zombienet workspace crates: diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 000000000..da0832b41 --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "zombie-mcp" +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false +license.workspace = true +repository.workspace = true +description = "Read-only MCP diagnostics server for Zombienet SDK" +keywords = ["zombienet", "mcp", "diagnostics"] + +[[bin]] +name = "zombie-mcp" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true } +futures = { workspace = true } +prom-metrics-parser = { workspace = true } +reqwest = { workspace = true } +rmcp = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +walkdir = { workspace = true } +zombienet-sdk = { workspace = true } + +[dev-dependencies] +rmcp = { workspace = true, features = ["client", "transport-child-process"] } +toml = { workspace = true } diff --git a/crates/mcp/src/diagnostics/evidence.rs b/crates/mcp/src/diagnostics/evidence.rs new file mode 100644 index 000000000..17d13d10b --- /dev/null +++ b/crates/mcp/src/diagnostics/evidence.rs @@ -0,0 +1,80 @@ +use std::{collections::BTreeMap, path::Path}; + +use crate::report::{ + bounded_tail, status_from_evidence, Category, DiagnosticReport, Evidence, Severity, +}; + +const EXCERPT_MAX_BYTES: usize = 8 * 1024; +const EXCERPT_MAX_LINES: usize = 20; + +/// Append `Evidence` to `report`, trimming the optional `excerpt` to a safe size. +#[allow(clippy::too_many_arguments)] +pub(super) fn push( + report: &mut DiagnosticReport, + severity: Severity, + id: impl Into, + category: Category, + subject: impl Into, + message: impl Into, + source: impl Into, + excerpt: Option, +) { + report.push_evidence(Evidence { + id: id.into(), + severity, + category, + subject: subject.into(), + message: message.into(), + source: source.into(), + excerpt: excerpt.map(|text| bounded_tail(&text, EXCERPT_MAX_LINES, EXCERPT_MAX_BYTES)), + }); +} + +/// Record an `input.invalid` error if `result` is `Err`. Returns `true` when input is valid. +pub(super) fn validate_input( + report: &mut DiagnosticReport, + result: Result<(), E>, + subject: impl Into, + source: &Path, + message: &'static str, +) -> bool { + match result { + Ok(()) => true, + Err(error) => { + push( + report, + Severity::Error, + "input.invalid", + Category::Config, + subject, + message, + source.display().to_string(), + Some(error.to_string()), + ); + false + }, + } +} + +/// Recompute `summary` and `status` from the current evidence. +pub(super) fn finalize(report: &mut DiagnosticReport) { + report.summary = summarize(&report.evidence); + report.status = status_from_evidence(&report.evidence); +} + +pub(super) fn summarize(evidence: &[Evidence]) -> String { + if evidence.is_empty() { + return "No startup diagnostics were found".to_string(); + } + + let mut counts: BTreeMap = BTreeMap::new(); + for item in evidence { + *counts.entry(item.severity).or_default() += 1; + } + + counts + .into_iter() + .map(|(severity, count)| format!("{severity:?}: {count}")) + .collect::>() + .join(", ") +} diff --git a/crates/mcp/src/diagnostics/live.rs b/crates/mcp/src/diagnostics/live.rs new file mode 100644 index 000000000..5ed702391 --- /dev/null +++ b/crates/mcp/src/diagnostics/live.rs @@ -0,0 +1,98 @@ +use std::{panic::AssertUnwindSafe, path::Path, time::Duration}; + +use futures::FutureExt; +use tokio::time::timeout; +use zombienet_sdk::{AttachToLive, AttachToLiveNetwork, LocalFileSystem, Network, NetworkNode}; + +use super::evidence; +use crate::report::{Category, DiagnosticReport, Severity}; + +const ATTACH_TIMEOUT: Duration = Duration::from_secs(5); + +pub(super) async fn attach_network( + zombie_json_path: &Path, +) -> Result, anyhow::Error> { + let zombie_json_path = zombie_json_path.to_path_buf(); + Ok(AttachToLiveNetwork::attach_native(zombie_json_path).await?) +} + +/// Attach with a hard timeout and convert panics into errors +/// `Network` is not unwind-safe; `AssertUnwindSafe` is required to call `catch_unwind` +pub(super) async fn attach_network_bounded( + zombie_json_path: &Path, +) -> Result, anyhow::Error> { + match timeout( + ATTACH_TIMEOUT, + AssertUnwindSafe(attach_network(zombie_json_path)).catch_unwind(), + ) + .await + { + Ok(Ok(result)) => result, + Ok(Err(panic)) => Err(anyhow::anyhow!( + "network attach panicked: {}", + panic_message(&panic) + )), + Err(_) => Err(anyhow::anyhow!( + "network attach timed out after {} seconds", + ATTACH_TIMEOUT.as_secs() + )), + } +} + +fn panic_message(panic: &(dyn std::any::Any + Send)) -> String { + if let Some(message) = panic.downcast_ref::<&str>() { + return (*message).to_string(); + } + if let Some(message) = panic.downcast_ref::() { + return message.clone(); + } + "unknown panic payload".to_string() +} + +/// Attach to the live native network, pushing evidence on failure +pub(super) async fn open_network( + report: &mut DiagnosticReport, + zombie_json_path: &Path, +) -> Option> { + match attach_network_bounded(zombie_json_path).await { + Ok(network) => Some(network), + Err(error) => { + evidence::push( + report, + Severity::Error, + "network.attach_failed", + Category::Startup, + zombie_json_path.display().to_string(), + "Could not attach to network", + zombie_json_path.display().to_string(), + Some(error.to_string()), + ); + None + }, + } +} + +/// Look up a node by name, pushing `node.{name}.missing` evidence on failure +pub(super) fn lookup_node<'a>( + report: &mut DiagnosticReport, + network: &'a Network, + node_name: &str, + source: &Path, +) -> Option<&'a NetworkNode> { + match network.get_node(node_name) { + Ok(node) => Some(node), + Err(error) => { + evidence::push( + report, + Severity::Error, + format!("node.{node_name}.missing"), + Category::Liveness, + node_name.to_string(), + "Node was not found", + source.display().to_string(), + Some(error.to_string()), + ); + None + }, + } +} diff --git a/crates/mcp/src/diagnostics/metrics.rs b/crates/mcp/src/diagnostics/metrics.rs new file mode 100644 index 000000000..780344927 --- /dev/null +++ b/crates/mcp/src/diagnostics/metrics.rs @@ -0,0 +1,300 @@ +use std::time::Duration; + +use serde_json::Value; +use tokio::time::timeout; +use zombienet_sdk::NetworkNode; + +use super::{ + evidence, + live::{lookup_node, open_network}, +}; +use crate::{ + input::MetricInput, + report::{Category, DiagnosticReport, Severity}, +}; + +const MAX_METRICS_BYTES: usize = 1024 * 1024; // 1 MiB + +pub async fn query_metric(input: MetricInput) -> DiagnosticReport { + let mut report = DiagnosticReport::new("Metric query completed"); + + if !evidence::validate_input( + &mut report, + input.validate(), + input.node_name.clone(), + &input.zombie_json_path, + "Metric input is invalid", + ) { + evidence::finalize(&mut report); + return report; + } + + let Some(network) = open_network(&mut report, &input.zombie_json_path).await else { + evidence::finalize(&mut report); + return report; + }; + + let Some(node) = lookup_node( + &mut report, + &network, + &input.node_name, + &input.zombie_json_path, + ) else { + evidence::finalize(&mut report); + return report; + }; + + let prometheus_uri = match node_prometheus_uri(node) { + Ok(uri) => uri, + Err(error) => { + evidence::push( + &mut report, + Severity::Warning, + format!( + "node.{}.metric.{}_endpoint_failed", + input.node_name, input.metric_name + ), + Category::Metrics, + input.node_name.clone(), + "Prometheus endpoint could not be resolved", + input.zombie_json_path.display().to_string(), + Some(error.to_string()), + ); + evidence::finalize(&mut report); + return report; + }, + }; + + let result = timeout( + Duration::from_secs(input.timeout_secs), + fetch_metric_value(&prometheus_uri, &input.metric_name), + ) + .await; + + let metric_id = format!("node.{}.metric.{}", input.node_name, input.metric_name); + match result { + Ok(Ok(value)) => evidence::push( + &mut report, + Severity::Info, + &metric_id, + Category::Metrics, + input.node_name.clone(), + format!("Metric {} reported {}", input.metric_name, value), + prometheus_uri, + None, + ), + Ok(Err(error)) => evidence::push( + &mut report, + Severity::Warning, + format!("{metric_id}_failed"), + Category::Metrics, + input.node_name.clone(), + "Metric could not be queried", + prometheus_uri, + Some(error.to_string()), + ), + Err(_) => evidence::push( + &mut report, + Severity::Warning, + format!("{metric_id}_timeout"), + Category::Metrics, + input.node_name.clone(), + "Timed out querying metric", + prometheus_uri, + Some(format!("timeout_secs={}", input.timeout_secs)), + ), + } + + evidence::finalize(&mut report); + report +} + +pub(super) async fn fetch_metric_value( + prometheus_uri: &str, + metric_name: &str, +) -> Result { + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let response = client + .get(prometheus_uri) + .send() + .await? + .error_for_status()?; + + if response.status().is_redirection() { + return Err(anyhow::anyhow!( + "prometheus response redirected and will not be followed: status={}", + response.status(), + )); + } + + if let Some(content_length) = response.content_length() { + if content_length > MAX_METRICS_BYTES as u64 { + return Err(anyhow::anyhow!( + "prometheus response exceeded byte limit: content_length={}, max_bytes={}", + content_length, + MAX_METRICS_BYTES, + )); + } + } + + let mut response = response; + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await? { + let next_len = body.len().saturating_add(chunk.len()); + if next_len > MAX_METRICS_BYTES { + return Err(anyhow::anyhow!( + "prometheus response exceeded byte limit: bytes={}, max_bytes={}", + next_len, + MAX_METRICS_BYTES, + )); + } + body.extend_from_slice(&chunk); + } + + parse_metric_value(&String::from_utf8_lossy(&body), metric_name) +} + +/// Read the node's Prometheus endpoint URL. +// TODO: zombienet-sdk does not expose a `prometheus_uri()` getter, so we round-trip +// the node through serde to read the field. Replace with a public accessor once it lands. +pub(super) fn node_prometheus_uri(node: &NetworkNode) -> Result { + let value = serde_json::to_value(node)?; + value + .get("prometheus_uri") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("node prometheus_uri was not present")) +} + +pub(super) fn parse_metric_value( + metrics_raw: &str, + metric_name: &str, +) -> Result { + let metrics = prom_metrics_parser::parse(metrics_raw)?; + metrics + .get(metric_name) + .copied() + .ok_or_else(|| anyhow::anyhow!("MetricNotFound: {metric_name}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{diagnostics::test_helpers::temp_zombie_json, report::Status}; + + #[tokio::test] + async fn fetch_metric_value_rejects_oversized_stream_without_content_length() { + use tokio::{io::AsyncWriteExt, net::TcpListener, time::Duration}; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test server can bind"); + let address = listener.local_addr().expect("test server has local addr"); + + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("client can connect"); + socket + .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n") + .await + .expect("headers can be written"); + socket + .write_all(&vec![b'a'; MAX_METRICS_BYTES + 1]) + .await + .expect("body can be written"); + std::future::pending::<()>().await; + }); + + let error = timeout( + Duration::from_secs(2), + fetch_metric_value( + &format!("http://{address}/metrics"), + "process_start_time_seconds", + ), + ) + .await + .expect("oversized metrics stream should fail before EOF") + .expect_err("oversized metrics stream should be rejected"); + + server.abort(); + + let message = error.to_string(); + assert!(message.contains("prometheus response exceeded byte limit")); + assert!(message.contains(&format!("max_bytes={MAX_METRICS_BYTES}"))); + } + + #[tokio::test] + async fn fetch_metric_value_rejects_redirects() { + use tokio::{io::AsyncWriteExt, net::TcpListener}; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test server can bind"); + let address = listener.local_addr().expect("test server has local addr"); + + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("client can connect"); + socket + .write_all( + b"HTTP/1.1 302 Found\r\nLocation: http://example.com/metrics\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .expect("redirect response can be written"); + }); + + let error = fetch_metric_value( + &format!("http://{address}/metrics"), + "process_start_time_seconds", + ) + .await + .expect_err("prometheus redirects should be rejected"); + + server.await.expect("test server can finish"); + + assert!(error.to_string().contains("redirected")); + } + + #[test] + fn parse_metric_value_returns_zero_when_metric_reports_zero() { + let value = parse_metric_value( + "# HELP polkadot_node_roles Node roles\n# TYPE polkadot_node_roles gauge\npolkadot_node_roles 0\n", + "polkadot_node_roles", + ) + .expect("zero-valued metric should be returned"); + + assert_eq!(value, 0.0); + } + + #[test] + fn parse_metric_value_rejects_absent_metric() { + let error = parse_metric_value( + "# HELP polkadot_node_roles Node roles\n# TYPE polkadot_node_roles gauge\npolkadot_node_roles 0\n", + "polkadot_missing_metric", + ) + .expect_err("absent metrics should not be reported as zero"); + + assert!(error.to_string().contains("MetricNotFound")); + } + + #[tokio::test] + async fn query_metric_reports_native_attach_error_as_evidence() { + let path = temp_zombie_json("metric-native-attach-error", r#"{"relay": {"nodes": []}}"#); + + let report = query_metric(MetricInput { + zombie_json_path: path.path().clone(), + node_name: "alice".to_string(), + metric_name: "process_start_time_seconds".to_string(), + timeout_secs: 10, + }) + .await; + + assert_eq!(report.status, Status::Failed); + assert!(report + .evidence + .iter() + .any(|e| e.id == "network.attach_failed" + && e.severity == Severity::Error + && e.source == path.path().display().to_string())); + } +} diff --git a/crates/mcp/src/diagnostics/mod.rs b/crates/mcp/src/diagnostics/mod.rs new file mode 100644 index 000000000..981b557d8 --- /dev/null +++ b/crates/mcp/src/diagnostics/mod.rs @@ -0,0 +1,165 @@ +mod evidence; +mod live; +mod metrics; +mod nodes; +mod startup; + +#[cfg(test)] +mod test_helpers; + +pub use metrics::query_metric; +pub use nodes::{check_block_production, check_node_liveness, get_node_logs, list_nodes}; +pub use startup::validate_config; + +use crate::{ + input::DiagnoseRunInput, + report::{Category, DiagnosticReport, Severity}, +}; + +const DIAGNOSE_LOG_LINES: usize = 300; + +pub async fn diagnose_run(input: DiagnoseRunInput) -> DiagnosticReport { + let mut report = startup::diagnose_startup_files(&input); + + if !input.zombie_json_path.is_file() { + finalize_run(&mut report); + return report; + } + + let Some(network) = live::open_network(&mut report, &input.zombie_json_path).await else { + finalize_run(&mut report); + return report; + }; + + let source = input.zombie_json_path.display().to_string(); + + let mut live_nodes = network.nodes(); + live_nodes.sort_by(|left, right| left.name().cmp(right.name())); + for node in live_nodes { + let node_report = nodes::check_node_liveness_for_node(node, source.clone()).await; + report.evidence.extend(node_report.evidence); + } + + let mut collators = network + .parachains() + .into_iter() + .flat_map(|parachain| parachain.collators()) + .collect::>(); + collators.sort_by(|left, right| left.name().cmp(right.name())); + for collator in collators { + let logs_report = + nodes::collect_node_logs_for_node(collator, source.clone(), DIAGNOSE_LOG_LINES).await; + report.evidence.extend(logs_report.evidence); + } + + finalize_run(&mut report); + report +} + +fn finalize_run(report: &mut DiagnosticReport) { + evidence::finalize(report); + if report.next_steps.is_empty() { + report.next_steps = next_steps_for(report); + } +} + +pub fn next_steps_for(report: &DiagnosticReport) -> Vec { + if report.evidence.iter().any(|item| { + item.severity == Severity::Error + && (item.category == Category::Startup + || item.id.starts_with("config.") + || item.id.starts_with("logs.")) + }) { + return vec!["Fix the startup error and rerun the same command".to_string()]; + } + + if report + .evidence + .iter() + .any(|item| item.severity == Severity::Error && item.category == Category::Rpc) + { + return vec!["Inspect node logs and verify the RPC port is reachable".to_string()]; + } + + if report + .evidence + .iter() + .any(|item| item.severity == Severity::Error && item.category == Category::Parachain) + { + return vec![ + "Check parachain registration, collator status, and relay finalization".to_string(), + ]; + } + + vec!["No immediate failure detected".to_string()] +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + use crate::{ + diagnostics::test_helpers::{fixture_path, temp_zombie_json, unique_temp_path}, + report::Status, + }; + + #[tokio::test] + async fn scans_logs_next_to_missing_zombie_json() { + let base_dir = unique_temp_path("zombie-mcp-missing-json-log-scan", "dir"); + let node_dir = base_dir.join("alice"); + fs::create_dir_all(&node_dir).expect("node dir fixture can be created"); + fs::copy( + fixture_path("startup-failure.log"), + node_dir.join("alice.log"), + ) + .expect("log fixture can be copied"); + + let report = diagnose_run(DiagnoseRunInput { + zombie_json_path: base_dir.join("zombie.json"), + }) + .await; + + fs::remove_dir_all(&base_dir).expect("base dir fixture can be removed"); + + assert_eq!(report.status, Status::Failed); + assert!(report + .evidence + .iter() + .any(|e| e.id == "zombie_json.missing" && e.severity == Severity::Warning)); + assert!(report + .evidence + .iter() + .any(|e| e.id == "logs.address already in use" && e.severity == Severity::Error)); + assert!(!report + .evidence + .iter() + .any(|e| e.id == "network.attach_failed")); + assert_eq!( + report.next_steps, + vec!["Fix the startup error and rerun the same command"] + ); + } + + #[tokio::test] + async fn reports_native_attach_error_as_evidence() { + let path = temp_zombie_json("run-native-attach-error", r#"{"relay": {"nodes": []}}"#); + + let report = diagnose_run(DiagnoseRunInput { + zombie_json_path: path.path().clone(), + }) + .await; + + assert_eq!(report.status, Status::Failed); + assert!(report + .evidence + .iter() + .any(|e| e.id == "network.attach_failed" + && e.severity == Severity::Error + && e.source == path.path().display().to_string())); + assert_eq!( + report.next_steps, + vec!["Fix the startup error and rerun the same command"] + ); + } +} diff --git a/crates/mcp/src/diagnostics/nodes.rs b/crates/mcp/src/diagnostics/nodes.rs new file mode 100644 index 000000000..bda319b04 --- /dev/null +++ b/crates/mcp/src/diagnostics/nodes.rs @@ -0,0 +1,486 @@ +use std::time::Duration; + +use tokio::time::timeout; +use zombienet_sdk::{subxt, NetworkNode}; + +use super::{ + evidence, + live::{lookup_node, open_network}, + metrics::{fetch_metric_value, node_prometheus_uri}, + startup::MAX_LOG_BYTES, +}; +use crate::{ + input::{BlockProductionInput, ListNodesInput, NodeInput, NodeLogsInput}, + log_patterns::scan_logs, + report::{ + bounded_tail, bounded_tail_bytes, status_from_evidence, Category, DiagnosticReport, + Severity, + }, +}; + +const EXCERPT_MAX_BYTES: usize = 8 * 1024; +const NODE_LOGS_TIMEOUT: Duration = Duration::from_secs(10); +const NODE_LIVENESS_TIMEOUT: Duration = Duration::from_secs(10); + +pub async fn list_nodes(input: ListNodesInput) -> DiagnosticReport { + let mut report = DiagnosticReport::new("Node listing completed"); + + let Some(network) = open_network(&mut report, &input.zombie_json_path).await else { + evidence::finalize(&mut report); + return report; + }; + + let nodes = network.nodes(); + for node in &nodes { + evidence::push( + &mut report, + Severity::Info, + format!("node.{}.listed", node.name()), + Category::Liveness, + node.name().to_string(), + format!( + "Node {} is present with websocket {} and multiaddr {}", + node.name(), + node.ws_uri(), + node.multiaddr() + ), + input.zombie_json_path.display().to_string(), + None, + ); + } + + report.summary = format!("Found {} nodes", nodes.len()); + report.status = status_from_evidence(&report.evidence); + report +} + +pub async fn get_node_logs(input: NodeLogsInput) -> DiagnosticReport { + let mut report = DiagnosticReport::new("Node log retrieval completed"); + + if !evidence::validate_input( + &mut report, + input.validate(), + input.node_name.clone(), + &input.zombie_json_path, + "Node log input is invalid", + ) { + evidence::finalize(&mut report); + return report; + } + + let Some(network) = open_network(&mut report, &input.zombie_json_path).await else { + evidence::finalize(&mut report); + return report; + }; + + let Some(node) = lookup_node( + &mut report, + &network, + &input.node_name, + &input.zombie_json_path, + ) else { + evidence::finalize(&mut report); + return report; + }; + + let node_report = collect_node_logs_for_node( + node, + input.zombie_json_path.display().to_string(), + input.lines, + ) + .await; + report.evidence.extend(node_report.evidence); + + evidence::finalize(&mut report); + report +} + +pub async fn check_node_liveness(input: NodeInput) -> DiagnosticReport { + let mut report = DiagnosticReport::new("Node liveness check completed"); + + let Some(network) = open_network(&mut report, &input.zombie_json_path).await else { + evidence::finalize(&mut report); + return report; + }; + + let Some(node) = lookup_node( + &mut report, + &network, + &input.node_name, + &input.zombie_json_path, + ) else { + evidence::finalize(&mut report); + return report; + }; + + let node_report = + check_node_liveness_for_node(node, input.zombie_json_path.display().to_string()).await; + report.evidence.extend(node_report.evidence); + + evidence::finalize(&mut report); + report +} + +pub async fn check_block_production(input: BlockProductionInput) -> DiagnosticReport { + let mut report = DiagnosticReport::new("Block production check completed"); + + if !evidence::validate_input( + &mut report, + input.validate(), + input.node_name.clone(), + &input.zombie_json_path, + "Block production input is invalid", + ) { + evidence::finalize(&mut report); + return report; + } + + let Some(network) = open_network(&mut report, &input.zombie_json_path).await else { + evidence::finalize(&mut report); + return report; + }; + + let Some(node) = lookup_node( + &mut report, + &network, + &input.node_name, + &input.zombie_json_path, + ) else { + evidence::finalize(&mut report); + return report; + }; + + let observed = timeout( + Duration::from_secs(input.timeout_secs), + collect_finalized_blocks(node, input.blocks), + ) + .await; + + let id_prefix = format!("node.{}.blocks", input.node_name); + let source = input.zombie_json_path.display().to_string(); + match observed { + Ok(Ok(blocks)) => evidence::push( + &mut report, + Severity::Info, + format!("{id_prefix}.finalized"), + Category::Liveness, + input.node_name.clone(), + format!("Observed {} finalized blocks", blocks.len()), + source, + Some(blocks.join("\n")), + ), + Ok(Err(error)) => evidence::push( + &mut report, + Severity::Error, + format!("{id_prefix}.failed"), + Category::Liveness, + input.node_name.clone(), + "Could not observe finalized blocks", + source, + Some(error.to_string()), + ), + Err(_) => evidence::push( + &mut report, + Severity::Error, + format!("{id_prefix}.timeout"), + Category::Liveness, + input.node_name.clone(), + "Timed out observing finalized blocks", + source, + Some(format!( + "expected_blocks={}, timeout_secs={}", + input.blocks, input.timeout_secs + )), + ), + } + + evidence::finalize(&mut report); + report +} + +pub(super) async fn check_node_liveness_for_node( + node: &NetworkNode, + source: String, +) -> DiagnosticReport { + let mut report = DiagnosticReport::new("Node liveness check completed"); + let node_name = node.name().to_string(); + + match timeout(NODE_LIVENESS_TIMEOUT, node.is_responsive()).await { + Ok(true) => evidence::push( + &mut report, + Severity::Info, + format!("node.{node_name}.rpc_responsive"), + Category::Rpc, + node_name.clone(), + "Node RPC endpoint is responsive", + source.clone(), + Some(node.ws_uri().to_string()), + ), + Ok(false) => evidence::push( + &mut report, + Severity::Error, + format!("node.{node_name}.rpc_unresponsive"), + Category::Rpc, + node_name.clone(), + "Node RPC endpoint is not responsive", + source.clone(), + Some(node.ws_uri().to_string()), + ), + Err(_) => evidence::push( + &mut report, + Severity::Error, + format!("node.{node_name}.rpc_timeout"), + Category::Rpc, + node_name.clone(), + "Timed out checking node RPC responsiveness", + source.clone(), + Some(format!("timeout_secs={}", NODE_LIVENESS_TIMEOUT.as_secs())), + ), + } + + let metric_name = "process_start_time_seconds"; + let fetch = async { + let uri = node_prometheus_uri(node)?; + let value = fetch_metric_value(&uri, metric_name).await?; + Ok::<_, anyhow::Error>((uri, value)) + }; + + match timeout(NODE_LIVENESS_TIMEOUT, fetch).await { + Ok(Ok((uri, value))) => evidence::push( + &mut report, + Severity::Info, + format!("node.{node_name}.metric.{metric_name}"), + Category::Metrics, + node_name.clone(), + format!("{metric_name} reported {value}"), + uri, + None, + ), + Ok(Err(error)) => evidence::push( + &mut report, + Severity::Warning, + format!("node.{node_name}.metric.{metric_name}_failed"), + Category::Metrics, + node_name.clone(), + "Could not query process start time metric", + source.clone(), + Some(error.to_string()), + ), + Err(_) => evidence::push( + &mut report, + Severity::Warning, + format!("node.{node_name}.metric.{metric_name}_timeout"), + Category::Metrics, + node_name.clone(), + "Timed out querying process start time metric", + source.clone(), + Some(format!("timeout_secs={}", NODE_LIVENESS_TIMEOUT.as_secs())), + ), + } + + let best_block_metric = "block_height{status=\"best\"}"; + let best_block_fetch = async { + let uri = node_prometheus_uri(node)?; + let value = fetch_metric_value(&uri, best_block_metric).await?; + Ok::<_, anyhow::Error>((uri, value)) + }; + + match timeout(NODE_LIVENESS_TIMEOUT, best_block_fetch).await { + // A node that is up but stuck at block 0 has not produced or imported any + // blocks; surface it as a warning so a stalled network is not reported as + // healthy just because its RPC endpoint answers. + Ok(Ok((uri, 0.0))) => evidence::push( + &mut report, + Severity::Warning, + format!("node.{node_name}.no_block_progress"), + Category::Liveness, + node_name, + "Node is up but its best block is still 0 (no blocks produced or imported)", + uri, + None, + ), + Ok(Ok((uri, value))) => evidence::push( + &mut report, + Severity::Info, + format!("node.{node_name}.best_block"), + Category::Liveness, + node_name, + format!("Node best block height is {value}"), + uri, + None, + ), + Ok(Err(error)) => evidence::push( + &mut report, + Severity::Info, + format!("node.{node_name}.best_block_unavailable"), + Category::Metrics, + node_name, + "Best block metric was not available", + source, + Some(error.to_string()), + ), + Err(_) => evidence::push( + &mut report, + Severity::Warning, + format!("node.{node_name}.best_block_timeout"), + Category::Metrics, + node_name, + "Timed out querying best block metric", + source, + Some(format!("timeout_secs={}", NODE_LIVENESS_TIMEOUT.as_secs())), + ), + } + + report +} + +pub(super) async fn collect_node_logs_for_node( + node: &NetworkNode, + source: String, + lines: usize, +) -> DiagnosticReport { + let mut report = DiagnosticReport::new("Node log retrieval completed"); + let node_name = node.name().to_string(); + + match timeout(NODE_LOGS_TIMEOUT, node.logs()).await { + Ok(Ok(logs)) => { + let bounded_logs = bounded_tail_bytes(&logs, MAX_LOG_BYTES); + let tail = bounded_tail(&bounded_logs, lines, EXCERPT_MAX_BYTES); + let log_matches = scan_logs(&tail); + + evidence::push( + &mut report, + Severity::Info, + format!("node.{node_name}.logs"), + Category::Logs, + node_name.clone(), + "Node logs were collected", + source.clone(), + Some(tail), + ); + + for log_match in log_matches { + evidence::push( + &mut report, + log_match.severity, + format!("logs.{}", log_match.pattern), + log_match.category, + node_name.clone(), + log_match.message, + source.clone(), + Some(log_match.line), + ); + } + }, + Ok(Err(error)) => evidence::push( + &mut report, + Severity::Error, + format!("node.{node_name}.logs_failed"), + Category::Logs, + node_name, + "Could not read node logs", + source, + Some(error.to_string()), + ), + Err(_) => evidence::push( + &mut report, + Severity::Error, + format!("node.{node_name}.logs_timeout"), + Category::Logs, + node_name, + "Timed out reading node logs", + source, + Some(format!("timeout_secs={}", NODE_LOGS_TIMEOUT.as_secs())), + ), + } + + report +} + +async fn collect_finalized_blocks( + node: &NetworkNode, + blocks: usize, +) -> Result, anyhow::Error> { + let client = node.wait_client::().await?; + let mut subscription = client.blocks().subscribe_finalized().await?; + let mut observed = Vec::with_capacity(blocks); + + while observed.len() < blocks { + let Some(block) = subscription.next().await else { + return Err(anyhow::anyhow!( + "finalized block stream ended after {} of {} produced blocks", + observed.len(), + blocks, + )); + }; + + let number = block?.header().number; + // The genesis block (#0) is finalized from the start, so observing it is + // not evidence of ongoing block production. A stalled chain only ever + // emits its current head once, so requiring `blocks` non-genesis blocks + // turns a stuck network into a timeout instead of a false success. + if number > 0 { + observed.push(format!("Block #{number}")); + } + } + + Ok(observed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + diagnostics::test_helpers::{fixture_path, temp_zombie_json}, + report::Status, + }; + + #[tokio::test] + async fn list_nodes_reports_native_attach_error_as_evidence() { + let path = temp_zombie_json("native-attach-error", r#"{"relay": {"nodes": []}}"#); + + let report = list_nodes(ListNodesInput { + zombie_json_path: path.path().clone(), + }) + .await; + + assert_eq!(report.status, Status::Failed); + assert!(report + .evidence + .iter() + .any(|e| e.id == "network.attach_failed" + && e.severity == Severity::Error + && e.source == path.path().display().to_string())); + } + + #[tokio::test] + async fn get_node_logs_reports_invalid_input_as_evidence() { + let report = get_node_logs(NodeLogsInput { + zombie_json_path: fixture_path("does-not-matter.json"), + node_name: "alice".to_string(), + lines: 0, + }) + .await; + + assert_eq!(report.status, Status::Failed); + assert!(report.evidence.iter().any(|e| e.id == "input.invalid" + && e.severity == Severity::Error + && e.category == Category::Config)); + } + + #[tokio::test] + async fn check_block_production_reports_invalid_input_as_evidence() { + let report = check_block_production(BlockProductionInput { + zombie_json_path: fixture_path("does-not-matter.json"), + node_name: "alice".to_string(), + blocks: 0, + timeout_secs: 10, + }) + .await; + + assert_eq!(report.status, Status::Failed); + assert!(report.evidence.iter().any(|e| e.id == "input.invalid" + && e.severity == Severity::Error + && e.category == Category::Config)); + } +} diff --git a/crates/mcp/src/diagnostics/startup.rs b/crates/mcp/src/diagnostics/startup.rs new file mode 100644 index 000000000..135cbada0 --- /dev/null +++ b/crates/mcp/src/diagnostics/startup.rs @@ -0,0 +1,391 @@ +use std::{ + fs::{self, File}, + io::{Read, Seek, SeekFrom}, + path::{Path, PathBuf}, +}; + +use zombienet_sdk::NetworkConfig; + +use super::evidence; +use crate::{ + input::{ConfigInput, DiagnoseRunInput}, + log_patterns::scan_logs, + report::{bounded_tail, Category, DiagnosticReport, Severity}, +}; + +pub(super) const MAX_LOG_BYTES: usize = 1024 * 1024; +const DIAGNOSE_LOG_LINES: usize = 200; +const MAX_DISCOVERED_LOG_FILES: usize = 64; +const MAX_LOG_DISCOVERY_DEPTH: usize = 4; + +pub fn validate_config(input: ConfigInput) -> DiagnosticReport { + match load_config(&input.config_path) { + Ok(config) => { + let summary = summarize_valid_config(&config); + let mut report = DiagnosticReport::new(summary.clone()); + + evidence::push( + &mut report, + Severity::Info, + "config.valid", + Category::Config, + input.config_path.display().to_string(), + summary, + input.config_path.display().to_string(), + None, + ); + + evidence::finalize(&mut report); + report + }, + Err(error) => { + let mut report = DiagnosticReport::new("Configuration is invalid"); + evidence::push( + &mut report, + Severity::Error, + "config.invalid", + Category::Config, + input.config_path.display().to_string(), + "Configuration could not be loaded", + input.config_path.display().to_string(), + Some(error.to_string()), + ); + evidence::finalize(&mut report); + report + }, + } +} + +pub(super) fn diagnose_startup_files(input: &DiagnoseRunInput) -> DiagnosticReport { + let mut report = DiagnosticReport::new("No startup diagnostics were found"); + + let zombie_json_path = &input.zombie_json_path; + let (id, severity, message) = if zombie_json_path.exists() { + ( + "zombie_json.exists", + Severity::Info, + "zombie.json file exists", + ) + } else { + ( + "zombie_json.missing", + Severity::Warning, + "zombie.json file was not found", + ) + }; + + evidence::push( + &mut report, + severity, + id, + Category::Startup, + zombie_json_path.display().to_string(), + message, + zombie_json_path.display().to_string(), + None, + ); + + let log_paths = discover_log_paths(zombie_json_path); + if log_paths.is_empty() { + evidence::push( + &mut report, + Severity::Info, + "logs.none_discovered", + Category::Logs, + zombie_json_path.display().to_string(), + "No log files were discovered from zombie.json or its base directory", + zombie_json_path.display().to_string(), + None, + ); + } + + for log_path in log_paths { + scan_log_file(&mut report, &log_path, DIAGNOSE_LOG_LINES); + } + + evidence::finalize(&mut report); + report +} + +fn scan_log_file(report: &mut DiagnosticReport, log_path: &Path, log_lines: usize) { + const EXCERPT_MAX_BYTES: usize = 8 * 1024; + + match read_bounded_file(log_path, MAX_LOG_BYTES) { + Ok(logs) => { + let tail = bounded_tail(&logs, log_lines, EXCERPT_MAX_BYTES); + for log_match in scan_logs(&tail) { + evidence::push( + report, + log_match.severity, + format!("logs.{}", log_match.pattern), + log_match.category, + log_path.display().to_string(), + log_match.message, + log_path.display().to_string(), + Some(log_match.line), + ); + } + }, + Err(error) => { + evidence::push( + report, + Severity::Warning, + "logs.unreadable", + Category::Logs, + log_path.display().to_string(), + "Log file could not be read", + log_path.display().to_string(), + Some(error.to_string()), + ); + }, + } +} + +pub(super) fn read_bounded_file(path: &Path, max_bytes: usize) -> Result { + let mut file = File::open(path)?; + let len = file.metadata()?.len(); + if len > max_bytes as u64 { + file.seek(SeekFrom::Start(len - max_bytes as u64))?; + } + + let mut bytes = Vec::new(); + file.take(max_bytes as u64).read_to_end(&mut bytes)?; + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +fn discover_log_paths(zombie_json_path: &Path) -> Vec { + let mut paths = Vec::new(); + + if zombie_json_path.is_file() { + if let Ok(log_paths) = log_paths_from_zombie_json(zombie_json_path) { + paths.extend(log_paths); + } + } + + if paths.is_empty() { + if let Some(base_dir) = zombie_json_path.parent() { + discover_log_files(base_dir, 0, &mut paths); + } + } + + paths.sort(); + paths.dedup(); + paths.truncate(MAX_DISCOVERED_LOG_FILES); + paths +} + +fn log_paths_from_zombie_json(zombie_json_path: &Path) -> Result, anyhow::Error> { + let contents = read_bounded_file(zombie_json_path, MAX_LOG_BYTES)?; + let value: serde_json::Value = serde_json::from_str(&contents)?; + let base_dir = zombie_json_path.parent().unwrap_or_else(|| Path::new(".")); + let mut paths = Vec::new(); + + collect_log_paths_from_value(&value, base_dir, &mut paths); + Ok(paths) +} + +fn collect_log_paths_from_value( + value: &serde_json::Value, + base_dir: &Path, + paths: &mut Vec, +) { + match value { + serde_json::Value::Object(object) => { + for (key, value) in object { + if key == "log_path" { + if let Some(path) = value.as_str() { + push_log_path(paths, base_dir, path); + } + } + collect_log_paths_from_value(value, base_dir, paths); + } + }, + serde_json::Value::Array(items) => { + for item in items { + collect_log_paths_from_value(item, base_dir, paths); + } + }, + _ => {}, + } +} + +fn push_log_path(paths: &mut Vec, base_dir: &Path, path: &str) { + let path = PathBuf::from(path); + if path.is_absolute() { + paths.push(path); + } else { + paths.push(base_dir.join(path)); + } +} + +fn discover_log_files(dir: &Path, depth: usize, paths: &mut Vec) { + if depth > MAX_LOG_DISCOVERY_DEPTH || paths.len() >= MAX_DISCOVERED_LOG_FILES { + return; + } + + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + + let mut entries = entries.filter_map(Result::ok).collect::>(); + entries.sort_by_key(|entry| entry.path()); + + for entry in entries { + if paths.len() >= MAX_DISCOVERED_LOG_FILES { + return; + } + + let Ok(file_type) = entry.file_type() else { + continue; + }; + let path = entry.path(); + + if file_type.is_dir() { + discover_log_files(&path, depth + 1, paths); + } else if file_type.is_file() + && path.extension().is_some_and(|extension| extension == "log") + { + paths.push(path); + } + } +} + +fn load_config(path: &Path) -> Result { + NetworkConfig::load_from_toml(&path.to_string_lossy()) +} + +fn summarize_valid_config(config: &NetworkConfig) -> String { + let relaychain_nodes = config.relaychain().nodes().len(); + let parachains = config.parachains(); + let parachain_count = parachains.len(); + let collator_count = parachains + .iter() + .map(|parachain| parachain.collators().len()) + .sum::(); + let custom_process_count = config.custom_processes().len(); + + format!( + "Configuration is valid: relaychain={:?}, relaychain_nodes={}, parachains={}, collators={}, custom_processes={}", + config.relaychain().chain(), + relaychain_nodes, + parachain_count, + collator_count, + custom_process_count, + ) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + use crate::{ + diagnostics::test_helpers::{fixture_path, unique_temp_path}, + report::Status, + }; + + fn startup_input(zombie_json_path: PathBuf) -> DiagnoseRunInput { + DiagnoseRunInput { zombie_json_path } + } + + #[test] + fn invalid_config_returns_error_evidence() { + let report = validate_config(ConfigInput { + config_path: fixture_path("invalid.toml"), + }); + + assert_eq!(report.status, Status::Failed); + assert!(report.evidence.iter().any(|e| e.id == "config.invalid" + && e.severity == Severity::Error + && e.category == Category::Config)); + } + + #[test] + fn startup_logs_are_discovered_when_zombie_json_is_missing() { + let base_dir = unique_temp_path("zombie-mcp-startup-log-discovery", "dir"); + let node_dir = base_dir.join("alice"); + fs::create_dir_all(&node_dir).expect("node dir fixture can be created"); + fs::copy( + fixture_path("startup-failure.log"), + node_dir.join("alice.log"), + ) + .expect("log fixture can be copied"); + + let input = startup_input(base_dir.join("zombie.json")); + + let report = diagnose_startup_files(&input); + + fs::remove_dir_all(&base_dir).expect("base dir fixture can be removed"); + + assert_eq!(report.status, Status::Failed); + assert!(report + .evidence + .iter() + .any(|e| e.id == "logs.address already in use" && e.severity == Severity::Error)); + assert!(report + .evidence + .iter() + .any(|e| e.id == "logs.panicked at" && e.severity == Severity::Error)); + } + + #[test] + fn startup_logs_are_discovered_from_zombie_json() { + let base_dir = unique_temp_path("zombie-mcp-zombie-json-log-discovery", "dir"); + let node_dir = base_dir.join("alice"); + fs::create_dir_all(&node_dir).expect("node dir fixture can be created"); + let log_path = node_dir.join("alice.log"); + fs::copy(fixture_path("startup-failure.log"), &log_path) + .expect("log fixture can be copied"); + let zombie_json_path = base_dir.join("zombie.json"); + fs::write( + &zombie_json_path, + format!( + r#"{{"relay": {{"nodes": [{{"name": "alice", "log_path": "{}"}}]}}}}"#, + log_path.display() + ), + ) + .expect("zombie.json fixture can be written"); + + let report = diagnose_startup_files(&startup_input(zombie_json_path)); + + fs::remove_dir_all(&base_dir).expect("base dir fixture can be removed"); + + assert_eq!(report.status, Status::Failed); + assert!(report.evidence.iter().any(|e| e.id == "zombie_json.exists" + && e.severity == Severity::Info + && e.category == Category::Startup)); + assert!(report + .evidence + .iter() + .any(|e| e.id == "logs.address already in use" && e.severity == Severity::Error)); + } + + #[test] + fn bounded_file_read_limits_large_logs() { + let path = unique_temp_path("zombie-mcp-bounded-log", "log"); + fs::write(&path, "a".repeat(MAX_LOG_BYTES + 4096)).expect("log fixture can be written"); + + let bounded = read_bounded_file(&path, MAX_LOG_BYTES).expect("log fixture can be read"); + + fs::remove_file(path).expect("log fixture can be removed"); + + assert!(bounded.len() <= MAX_LOG_BYTES); + } + + #[test] + fn log_scanning_respects_bounded_tail() { + let path = unique_temp_path("zombie-mcp-tail", "log"); + fs::write( + &path, + "older panicked at startup\nrecent informational line\nfinal line", + ) + .expect("log fixture can be written"); + + let mut report = DiagnosticReport::new("test"); + scan_log_file(&mut report, &path, 2); + + fs::remove_file(path).expect("log fixture can be removed"); + + assert!(!report.evidence.iter().any(|e| e.id == "logs.panicked at")); + } +} diff --git a/crates/mcp/src/diagnostics/test_helpers.rs b/crates/mcp/src/diagnostics/test_helpers.rs new file mode 100644 index 000000000..7e769717a --- /dev/null +++ b/crates/mcp/src/diagnostics/test_helpers.rs @@ -0,0 +1,39 @@ +use std::{fs, path::PathBuf}; + +pub(super) fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(name) +} + +pub(super) struct TempZombieJson { + path: PathBuf, +} + +impl TempZombieJson { + pub(super) fn path(&self) -> &PathBuf { + &self.path + } +} + +impl Drop for TempZombieJson { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +pub(super) fn temp_zombie_json(name: &str, contents: &str) -> TempZombieJson { + let path = unique_temp_path(&format!("zombie-mcp-{name}"), "json"); + fs::write(&path, contents).expect("fixture can be written"); + TempZombieJson { path } +} + +/// Generate a unique path inside the OS temp dir for the current test process+thread. +pub(super) fn unique_temp_path(prefix: &str, extension: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "{prefix}-{}-{:?}.{extension}", + std::process::id(), + std::thread::current().id(), + )) +} diff --git a/crates/mcp/src/input.rs b/crates/mcp/src/input.rs new file mode 100644 index 000000000..a8b93f06b --- /dev/null +++ b/crates/mcp/src/input.rs @@ -0,0 +1,208 @@ +use std::path::PathBuf; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum InputError { + #[error("timeout_secs must be between 1 and 120")] + InvalidTimeout, + #[error("blocks must be between 1 and 100")] + InvalidBlocks, + #[error("log_lines must be between 1 and 1000")] + InvalidLogLines, +} + +fn default_log_lines() -> usize { + 200 +} + +fn default_timeout_secs() -> u64 { + 10 +} + +fn default_blocks() -> usize { + 2 +} + +fn validate_timeout_secs(timeout_secs: u64) -> Result<(), InputError> { + if !(1..=120).contains(&timeout_secs) { + return Err(InputError::InvalidTimeout); + } + + Ok(()) +} + +fn validate_log_lines(log_lines: usize) -> Result<(), InputError> { + if !(1..=1000).contains(&log_lines) { + return Err(InputError::InvalidLogLines); + } + + Ok(()) +} + +fn validate_blocks(blocks: usize) -> Result<(), InputError> { + if !(1..=100).contains(&blocks) { + return Err(InputError::InvalidBlocks); + } + + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DiagnoseRunInput { + #[schemars(description = "Path to zombie.json for the run to diagnose")] + pub zombie_json_path: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ConfigInput { + pub config_path: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ListNodesInput { + pub zombie_json_path: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct NodeInput { + pub zombie_json_path: PathBuf, + pub node_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MetricInput { + pub zombie_json_path: PathBuf, + pub node_name: String, + pub metric_name: String, + #[serde(default = "default_timeout_secs")] + pub timeout_secs: u64, +} + +impl MetricInput { + pub fn validate(&self) -> Result<(), InputError> { + validate_timeout_secs(self.timeout_secs) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct BlockProductionInput { + pub zombie_json_path: PathBuf, + pub node_name: String, + #[serde(default = "default_blocks")] + pub blocks: usize, + #[serde(default = "default_timeout_secs")] + pub timeout_secs: u64, +} + +impl BlockProductionInput { + pub fn validate(&self) -> Result<(), InputError> { + validate_blocks(self.blocks)?; + validate_timeout_secs(self.timeout_secs) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct NodeLogsInput { + pub zombie_json_path: PathBuf, + pub node_name: String, + #[serde(default = "default_log_lines")] + pub lines: usize, +} + +impl NodeLogsInput { + pub fn validate(&self) -> Result<(), InputError> { + validate_log_lines(self.lines) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn diagnose_input_carries_zombie_json_path() { + let input = DiagnoseRunInput { + zombie_json_path: PathBuf::from("zombie.json"), + }; + + assert_eq!(input.zombie_json_path, PathBuf::from("zombie.json")); + } + + #[test] + fn metric_input_rejects_invalid_timeout() { + let input = MetricInput { + zombie_json_path: PathBuf::from("zombie.json"), + node_name: "alice".to_string(), + metric_name: "process_start_time_seconds".to_string(), + timeout_secs: 121, + }; + + assert_eq!(input.validate(), Err(InputError::InvalidTimeout)); + } + + #[test] + fn metric_input_defaults_timeout_to_ten_seconds() { + let input: MetricInput = serde_json::from_value(serde_json::json!({ + "zombie_json_path": "zombie.json", + "node_name": "alice", + "metric_name": "process_start_time_seconds" + })) + .expect("metric input should deserialize with a default timeout"); + + assert_eq!(input.timeout_secs, 10); + assert_eq!(input.validate(), Ok(())); + } + + #[test] + fn node_logs_input_rejects_zero_lines() { + let input = NodeLogsInput { + zombie_json_path: PathBuf::from("zombie.json"), + node_name: "alice".to_string(), + lines: 0, + }; + + assert_eq!(input.validate(), Err(InputError::InvalidLogLines)); + } + + #[test] + fn block_production_input_rejects_invalid_blocks() { + let input = BlockProductionInput { + zombie_json_path: PathBuf::from("zombie.json"), + node_name: "alice".to_string(), + blocks: 0, + timeout_secs: 10, + }; + + assert_eq!(input.validate(), Err(InputError::InvalidBlocks)); + } + + #[test] + fn block_production_input_rejects_invalid_timeout() { + let input = BlockProductionInput { + zombie_json_path: PathBuf::from("zombie.json"), + node_name: "alice".to_string(), + blocks: 2, + timeout_secs: 121, + }; + + assert_eq!(input.validate(), Err(InputError::InvalidTimeout)); + } + + #[test] + fn default_blocks_is_two() { + assert_eq!(default_blocks(), 2); + } + + #[test] + fn list_nodes_input_carries_zombie_json_and_optional_provider() { + let input = ListNodesInput { + zombie_json_path: PathBuf::from("zombie.json"), + }; + + assert_eq!(input.zombie_json_path, PathBuf::from("zombie.json")); + } +} diff --git a/crates/mcp/src/install.rs b/crates/mcp/src/install.rs new file mode 100644 index 000000000..3ace93d65 --- /dev/null +++ b/crates/mcp/src/install.rs @@ -0,0 +1,269 @@ +use std::{ + env, + ffi::OsString, + path::{Path, PathBuf}, + process::Command, +}; + +use clap::{Args, Subcommand}; + +const DEFAULT_SERVER_NAME: &str = "zombie-mcp"; + +#[derive(Args, Debug)] +pub struct InstallArgs { + #[command(subcommand)] + client: Client, +} + +#[derive(Subcommand, Debug)] +enum Client { + /// Register zombie-mcp with the Codex CLI + Codex(CodexArgs), + /// Register zombie-mcp with the Claude Code CLI + Claude(ClaudeArgs), +} + +/// Options shared by every MCP client registration. +#[derive(Args, Debug)] +struct CommonArgs { + /// MCP server name to register + #[arg(long, default_value = DEFAULT_SERVER_NAME)] + name: String, + /// Path to the zombie-mcp binary (defaults to the current executable) + #[arg(long)] + bin: Option, + /// Remove any existing registration before adding + #[arg(long)] + force: bool, +} + +#[derive(Args, Debug)] +struct CodexArgs { + #[command(flatten)] + common: CommonArgs, + /// Codex executable to invoke + #[arg(long, default_value = "codex")] + codex_bin: OsString, +} + +#[derive(Args, Debug)] +struct ClaudeArgs { + #[command(flatten)] + common: CommonArgs, + /// Claude Code executable to invoke + #[arg(long, default_value = "claude")] + claude_bin: OsString, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ClientKind { + Codex, + Claude, +} + +impl ClientKind { + /// Human-readable name used in the success messages. + fn label(self) -> &'static str { + match self { + ClientKind::Codex => "Codex", + ClientKind::Claude => "Claude Code", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InstallOptions { + kind: ClientKind, + name: String, + server_bin: PathBuf, + client_bin: OsString, + force: bool, +} + +pub fn run(args: InstallArgs) -> anyhow::Result<()> { + let options = match args.client { + Client::Codex(args) => { + InstallOptions::resolve(ClientKind::Codex, args.common, args.codex_bin)? + }, + Client::Claude(args) => { + InstallOptions::resolve(ClientKind::Claude, args.common, args.claude_bin)? + }, + }; + + install_client(&options) +} + +fn install_client(options: &InstallOptions) -> anyhow::Result<()> { + if options.force { + let remove_args = [ + OsString::from("mcp"), + OsString::from("remove"), + OsString::from(&options.name), + ]; + run_client(options, &remove_args, true)?; + } + + let add_args = [ + OsString::from("mcp"), + OsString::from("add"), + OsString::from(&options.name), + OsString::from("--"), + options.server_bin.as_os_str().to_os_string(), + ]; + run_client(options, &add_args, false)?; + + println!( + "Installed `{}` as a {} MCP server using {}", + options.name, + options.kind.label(), + options.server_bin.display(), + ); + println!( + "Start a new {} session to load the MCP server", + options.kind.label(), + ); + Ok(()) +} + +fn run_client( + options: &InstallOptions, + args: &[OsString], + allow_failure: bool, +) -> anyhow::Result<()> { + let status = Command::new(&options.client_bin).args(args).status()?; + if !status.success() && !allow_failure { + anyhow::bail!( + "{} command failed with status {status}", + options.kind.label() + ); + } + + Ok(()) +} + +impl InstallOptions { + fn resolve(kind: ClientKind, common: CommonArgs, client_bin: OsString) -> anyhow::Result { + if common.name.trim().is_empty() { + anyhow::bail!("--name cannot be empty"); + } + + let server_bin = match common.bin { + Some(bin) => bin, + None => default_server_bin()?, + }; + + Ok(Self { + kind, + name: common.name, + server_bin, + client_bin, + force: common.force, + }) + } +} + +fn default_server_bin() -> anyhow::Result { + let current_exe = env::current_exe()?; + absolute_path(¤t_exe) +} + +fn absolute_path(path: &Path) -> anyhow::Result { + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + + Ok(env::current_dir()?.join(path)) +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + + #[derive(Parser, Debug)] + struct TestCli { + #[command(subcommand)] + client: Client, + } + + fn parse(args: &[&str]) -> anyhow::Result { + let cli = TestCli::try_parse_from(std::iter::once("install").chain(args.iter().copied()))?; + match cli.client { + Client::Codex(args) => { + InstallOptions::resolve(ClientKind::Codex, args.common, args.codex_bin) + }, + Client::Claude(args) => { + InstallOptions::resolve(ClientKind::Claude, args.common, args.claude_bin) + }, + } + } + + #[test] + fn parse_codex_defaults_to_zombie_mcp_name() { + let options = parse(&["codex"]).unwrap(); + + assert_eq!(options.kind, ClientKind::Codex); + assert_eq!(options.name, DEFAULT_SERVER_NAME); + assert_eq!(options.client_bin, OsString::from("codex")); + assert!(!options.force); + } + + #[test] + fn parse_codex_customizes_options() { + let options = parse(&[ + "codex", + "--name", + "local-zombie", + "--bin", + "/tmp/zombie-mcp", + "--codex-bin", + "/tmp/codex", + "--force", + ]) + .unwrap(); + + assert_eq!(options.name, "local-zombie"); + assert_eq!(options.server_bin, PathBuf::from("/tmp/zombie-mcp")); + assert_eq!(options.client_bin, OsString::from("/tmp/codex")); + assert!(options.force); + } + + #[test] + fn parse_claude_defaults_to_claude_bin() { + let options = parse(&["claude"]).unwrap(); + + assert_eq!(options.kind, ClientKind::Claude); + assert_eq!(options.name, DEFAULT_SERVER_NAME); + assert_eq!(options.client_bin, OsString::from("claude")); + assert!(!options.force); + } + + #[test] + fn parse_claude_customizes_options() { + let options = parse(&[ + "claude", + "--name", + "local-zombie", + "--bin", + "/tmp/zombie-mcp", + "--claude-bin", + "/tmp/claude", + "--force", + ]) + .unwrap(); + + assert_eq!(options.kind, ClientKind::Claude); + assert_eq!(options.name, "local-zombie"); + assert_eq!(options.server_bin, PathBuf::from("/tmp/zombie-mcp")); + assert_eq!(options.client_bin, OsString::from("/tmp/claude")); + assert!(options.force); + } + + #[test] + fn parse_rejects_empty_name() { + let error = parse(&["codex", "--name", ""]).unwrap_err(); + + assert!(error.to_string().contains("--name cannot be empty")); + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 000000000..8b0a8ecac --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -0,0 +1,7 @@ +pub mod diagnostics; +pub mod input; +pub mod log_patterns; +pub mod recent_runs; +pub mod report; +pub mod server; +pub mod tools; diff --git a/crates/mcp/src/log_patterns.rs b/crates/mcp/src/log_patterns.rs new file mode 100644 index 000000000..5c56709c8 --- /dev/null +++ b/crates/mcp/src/log_patterns.rs @@ -0,0 +1,185 @@ +pub use crate::report::{Category, Severity}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogMatch { + pub severity: Severity, + pub category: Category, + pub pattern: &'static str, + pub message: &'static str, + pub line: String, +} + +#[derive(Debug, Clone)] +struct LogPattern { + severity: Severity, + category: Category, + pattern: &'static str, + match_text: &'static str, + message: &'static str, +} + +static LOG_PATTERNS: &[LogPattern] = &[ + LogPattern { + severity: Severity::Error, + category: Category::Startup, + pattern: "address already in use", + match_text: "address already in use", + message: "A port appears to be busy", + }, + LogPattern { + severity: Severity::Error, + category: Category::Startup, + pattern: "No such file or directory", + match_text: "no such file or directory", + message: "A required binary or file is missing", + }, + LogPattern { + severity: Severity::Error, + category: Category::Startup, + pattern: "permission denied", + match_text: "permission denied", + message: "A permission problem prevented startup", + }, + LogPattern { + severity: Severity::Error, + category: Category::Startup, + pattern: "unexpected argument", + match_text: "unexpected argument", + message: "A node command-line argument appears to be invalid", + }, + LogPattern { + severity: Severity::Error, + category: Category::Logs, + pattern: "panicked at", + match_text: "panicked at", + message: "The node process panicked", + }, + LogPattern { + severity: Severity::Error, + category: Category::Logs, + pattern: "Essential task", + match_text: "essential task", + message: "An essential node task failed", + }, + LogPattern { + severity: Severity::Error, + category: Category::Parachain, + pattern: "Failed to register parachain", + match_text: "failed to register parachain", + message: "Parachain registration failed", + }, + LogPattern { + severity: Severity::Warning, + category: Category::Rpc, + pattern: "Connection refused", + match_text: "connection refused", + message: "A local endpoint refused a connection", + }, +]; + +pub fn scan_logs(logs: &str) -> Vec { + logs.lines() + .flat_map(|line| { + let line_lower = line.to_lowercase(); + + LOG_PATTERNS + .iter() + .filter(move |pattern| line_lower.contains(pattern.match_text)) + .map(move |pattern| LogMatch { + severity: pattern.severity, + category: pattern.category, + pattern: pattern.pattern, + message: pattern.message, + line: line.to_string(), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_port_conflict() { + let line = "Error: listen tcp 127.0.0.1:9944: bind: address already in use"; + let matches = scan_logs(line); + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].severity, Severity::Error); + assert_eq!(matches[0].category, Category::Startup); + assert_eq!(matches[0].pattern, "address already in use"); + assert_eq!(matches[0].message, "A port appears to be busy"); + assert_eq!(matches[0].line, line); + } + + #[test] + fn detects_parachain_registration_failure() { + let line = "Failed to register parachain 2000 with relay chain"; + let logs = format!("node booted\n{line}"); + + let matches = scan_logs(&logs); + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].severity, Severity::Error); + assert_eq!(matches[0].category, Category::Parachain); + assert_eq!(matches[0].pattern, "Failed to register parachain"); + assert_eq!(matches[0].message, "Parachain registration failed"); + assert_eq!(matches[0].line, line); + } + + #[test] + fn detects_unexpected_argument_startup_failure() { + let line = "error: unexpected argument '--zombienet-mcp-demo-invalid-flag' found"; + let matches = scan_logs(line); + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].severity, Severity::Error); + assert_eq!(matches[0].category, Category::Startup); + assert_eq!(matches[0].pattern, "unexpected argument"); + assert_eq!( + matches[0].message, + "A node command-line argument appears to be invalid" + ); + assert_eq!(matches[0].line, line); + } + + #[test] + fn pattern_messages_match_reviewed_contract() { + let logs = [ + "Error: listen tcp 127.0.0.1:9944: bind: address already in use", + "No such file or directory: polkadot", + "permission denied: ./polkadot", + "error: unexpected argument '--zombienet-mcp-demo-invalid-flag' found", + "thread 'main' panicked at runtime", + "Essential task `alice` failed. Shutting down service.", + "Failed to register parachain 2000", + "Connection refused (os error 61)", + ] + .join("\n"); + + let messages: Vec<_> = scan_logs(&logs) + .into_iter() + .map(|log_match| log_match.message) + .collect(); + + assert_eq!( + messages, + [ + "A port appears to be busy", + "A required binary or file is missing", + "A permission problem prevented startup", + "A node command-line argument appears to be invalid", + "The node process panicked", + "An essential node task failed", + "Parachain registration failed", + "A local endpoint refused a connection", + ] + ); + } + + #[test] + fn scan_logs_handles_unrelated_input() { + assert!(scan_logs("ordinary log line\nunicode αβγ").is_empty()); + } +} diff --git a/crates/mcp/src/main.rs b/crates/mcp/src/main.rs new file mode 100644 index 000000000..83d0c6388 --- /dev/null +++ b/crates/mcp/src/main.rs @@ -0,0 +1,36 @@ +use clap::{Parser, Subcommand}; +use rmcp::{transport::stdio, ServiceExt}; +use zombie_mcp::server::ZombienetMcpServer; + +mod install; + +#[derive(Parser, Debug)] +#[command(name = "zombie-mcp", author, version, about, long_about = None)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Register zombie-mcp as an MCP server for a client + Install(install::InstallArgs), +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_ansi(false) + .init(); + + if let Some(command) = Cli::parse().command { + match command { + Command::Install(args) => return install::run(args), + } + } + + let service = ZombienetMcpServer::default().serve(stdio()).await?; + service.waiting().await?; + Ok(()) +} diff --git a/crates/mcp/src/recent_runs.rs b/crates/mcp/src/recent_runs.rs new file mode 100644 index 000000000..4853ccd94 --- /dev/null +++ b/crates/mcp/src/recent_runs.rs @@ -0,0 +1,267 @@ +use std::{ + collections::HashSet, + fs, + path::{Path, PathBuf}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use serde::Serialize; +use walkdir::WalkDir; + +const MAX_DEPTH: usize = 6; +const MAX_RUNS: usize = 10; +const RECENT_WINDOW: Duration = Duration::from_secs(24 * 60 * 60); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RecentRunsReport { + pub status: RecentRunsStatus, + pub summary: String, + pub searched_roots: Vec, + pub runs: Vec, + pub next_steps: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RecentRunsStatus { + Ok, + NotFound, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RecentRun { + pub zombie_json_path: String, + pub base_dir: String, + pub modified_unix_secs: u64, + pub age_secs: u64, + pub source: RunSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RunSource { + ZombieJson, + LogDirectory, +} + +pub fn find_recent_runs() -> RecentRunsReport { + find_recent_runs_in_roots(default_search_roots()) +} + +fn default_search_roots() -> Vec { + let mut roots = Vec::new(); + + if let Ok(cwd) = std::env::current_dir() { + roots.push(cwd); + } + + roots.push(std::env::temp_dir()); + roots.push(PathBuf::from("/tmp")); + roots.sort(); + roots.dedup(); + roots +} + +fn find_recent_runs_in_roots(roots: Vec) -> RecentRunsReport { + let now = SystemTime::now(); + let mut seen = HashSet::new(); + let mut runs = roots + .iter() + .flat_map(|root| discover_runs(root, now, is_tempish_root(root))) + .filter(|run| seen.insert(run.zombie_json_path.clone())) + .collect::>(); + + runs.sort_by(|left, right| right.modified_unix_secs.cmp(&left.modified_unix_secs)); + runs.truncate(MAX_RUNS); + + RecentRunsReport { + status: if runs.is_empty() { + RecentRunsStatus::NotFound + } else { + RecentRunsStatus::Ok + }, + summary: if runs.is_empty() { + "No recent zombienet runs were found".to_string() + } else { + format!("Found {} recent zombienet run candidate(s)", runs.len()) + }, + searched_roots: roots + .into_iter() + .map(|root| root.display().to_string()) + .collect(), + runs, + next_steps: vec![ + "Use the newest zombie_json_path with diagnose_run".to_string(), + "If no runs are found, ask the user for zombie_json_path".to_string(), + ], + } +} + +fn discover_runs(root: &Path, now: SystemTime, allow_log_dir_candidates: bool) -> Vec { + if root.is_file() { + return fs::metadata(root) + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|modified| zombie_json_run(root, modified, now)) + .into_iter() + .collect(); + } + + WalkDir::new(root) + .max_depth(MAX_DEPTH) + .into_iter() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_file()) + .filter_map(|entry| { + let path = entry.path(); + let modified = entry.metadata().ok()?.modified().ok()?; + + if path.file_name().is_some_and(|name| name == "zombie.json") { + return zombie_json_run(path, modified, now); + } + + if allow_log_dir_candidates && path.extension().is_some_and(|ext| ext == "log") { + let run_dir = zombienet_ancestor(path.parent()?, root)?; + return log_dir_run(&run_dir, modified, now); + } + + None + }) + .collect() +} + +fn zombie_json_run(path: &Path, modified: SystemTime, now: SystemTime) -> Option { + push_run(path.to_path_buf(), modified, now, RunSource::ZombieJson) +} + +fn log_dir_run(dir: &Path, modified: SystemTime, now: SystemTime) -> Option { + push_run( + dir.join("zombie.json"), + modified, + now, + RunSource::LogDirectory, + ) +} + +fn push_run( + zombie_json_path: PathBuf, + modified: SystemTime, + now: SystemTime, + source: RunSource, +) -> Option { + if !is_recent(modified, now) { + return None; + } + + let base_dir = zombie_json_path.parent().unwrap_or_else(|| Path::new(".")); + Some(RecentRun { + zombie_json_path: zombie_json_path.display().to_string(), + base_dir: base_dir.display().to_string(), + modified_unix_secs: unix_secs(modified), + age_secs: now + .duration_since(modified) + .unwrap_or_else(|_| Duration::from_secs(0)) + .as_secs(), + source, + }) +} + +fn zombienet_ancestor(path: &Path, root: &Path) -> Option { + for ancestor in path.ancestors() { + if looks_like_zombienet_dir(ancestor) { + return Some(ancestor.to_path_buf()); + } + if ancestor == root { + break; + } + } + + None +} + +fn looks_like_zombienet_dir(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.contains("zombie") || name.contains("zombienet")) +} + +fn is_tempish_root(root: &Path) -> bool { + root == Path::new("/tmp") || root.starts_with(std::env::temp_dir()) +} + +fn is_recent(modified: SystemTime, now: SystemTime) -> bool { + now.duration_since(modified) + .unwrap_or_else(|_| Duration::from_secs(0)) + <= RECENT_WINDOW +} + +fn unix_secs(time: SystemTime) -> u64 { + time.duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)) + .as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_temp_path(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "{prefix}-{}-{:?}", + std::process::id(), + std::thread::current().id(), + )) + } + + #[test] + fn finds_recent_zombie_json() { + let root = unique_temp_path("zombie-mcp-recent-runs-json"); + let run_dir = root.join("run"); + fs::create_dir_all(&run_dir).expect("run dir fixture can be created"); + fs::write(run_dir.join("zombie.json"), "{}").expect("zombie.json fixture can be written"); + + let report = find_recent_runs_in_roots(vec![root.clone()]); + + fs::remove_dir_all(&root).expect("run dir fixture can be removed"); + + assert_eq!(report.status, RecentRunsStatus::Ok); + assert_eq!(report.runs.len(), 1); + assert!(report.runs[0].zombie_json_path.ends_with("zombie.json")); + assert_eq!(report.runs[0].source, RunSource::ZombieJson); + } + + #[test] + fn synthesizes_zombie_json_path_for_recent_zombie_log_dir() { + let root = unique_temp_path("zombie-mcp-recent-runs-log-dir"); + let run_dir = root.join("zombienet-sdk-run"); + let node_dir = run_dir.join("alice"); + fs::create_dir_all(&node_dir).expect("node dir fixture can be created"); + fs::write(node_dir.join("alice.log"), "error").expect("log fixture can be written"); + + let report = find_recent_runs_in_roots(vec![root.clone()]); + + fs::remove_dir_all(&root).expect("run dir fixture can be removed"); + + assert_eq!(report.status, RecentRunsStatus::Ok); + assert_eq!(report.runs.len(), 1); + assert!(report.runs[0] + .zombie_json_path + .ends_with("zombienet-sdk-run/zombie.json")); + assert_eq!(report.runs[0].source, RunSource::LogDirectory); + } + + #[test] + fn ignores_plain_log_dirs() { + let root = unique_temp_path("plain-recent-runs-log-dir"); + let run_dir = root.join("plain-run"); + fs::create_dir_all(&run_dir).expect("run dir fixture can be created"); + fs::write(run_dir.join("alice.log"), "error").expect("log fixture can be written"); + + let report = find_recent_runs_in_roots(vec![root.clone()]); + + fs::remove_dir_all(&root).expect("run dir fixture can be removed"); + + assert_eq!(report.status, RecentRunsStatus::NotFound); + assert!(report.runs.is_empty()); + } +} diff --git a/crates/mcp/src/report.rs b/crates/mcp/src/report.rs new file mode 100644 index 000000000..5a62141a5 --- /dev/null +++ b/crates/mcp/src/report.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Status { + Ok, + Warning, + Failed, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Severity { + Info, + Warning, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Category { + Startup, + Liveness, + Logs, + Metrics, + Rpc, + Parachain, + Config, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Evidence { + pub id: String, + pub severity: Severity, + pub category: Category, + pub subject: String, + pub message: String, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub excerpt: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiagnosticReport { + pub status: Status, + pub summary: String, + pub evidence: Vec, + pub next_steps: Vec, +} + +impl DiagnosticReport { + pub fn new(summary: impl Into) -> Self { + Self { + status: Status::Unknown, + summary: summary.into(), + evidence: Vec::new(), + next_steps: Vec::new(), + } + } + + pub fn push_evidence(&mut self, evidence: Evidence) { + self.evidence.push(evidence); + } +} + +pub fn status_from_evidence(evidence: &[Evidence]) -> Status { + if evidence.iter().any(|item| item.severity == Severity::Error) { + Status::Failed + } else if evidence + .iter() + .any(|item| item.severity == Severity::Warning) + { + Status::Warning + } else if evidence.iter().any(|item| item.severity == Severity::Info) { + Status::Ok + } else { + Status::Unknown + } +} + +/// Trim `input` to the last `max_bytes` bytes on a UTF-8 char boundary. +pub fn bounded_tail_bytes(input: &str, max_bytes: usize) -> String { + if input.len() <= max_bytes { + return input.to_string(); + } + + let start = input + .char_indices() + .map(|(index, _)| index) + .find(|index| input.len() - index <= max_bytes) + .unwrap_or(input.len()); + + input[start..].to_string() +} + +/// Keep at most the last `max_lines` lines of `input`, then trim to `max_bytes`. +pub fn bounded_tail(input: &str, max_lines: usize, max_bytes: usize) -> String { + let lines = input.lines().rev().take(max_lines).collect::>(); + let joined = lines.into_iter().rev().collect::>().join("\n"); + bounded_tail_bytes(&joined, max_bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn evidence(id: &str, severity: Severity) -> Evidence { + Evidence { + id: id.to_string(), + severity, + category: Category::Logs, + subject: "alice".to_string(), + message: "message".to_string(), + source: "test".to_string(), + excerpt: None, + } + } + + #[test] + fn status_is_unknown_without_evidence() { + assert_eq!(status_from_evidence(&[]), Status::Unknown); + } + + #[test] + fn status_is_failed_when_any_evidence_is_error() { + let items = vec![ + evidence("a", Severity::Info), + evidence("b", Severity::Error), + ]; + + assert_eq!(status_from_evidence(&items), Status::Failed); + } + + #[test] + fn bounded_tail_limits_lines() { + let tail = bounded_tail("one\ntwo\nthree\nfour", 2, 1024); + + assert_eq!(tail, "three\nfour"); + } + + #[test] + fn bounded_tail_limits_bytes() { + let tail = bounded_tail("abcdef", 1, 3); + + assert_eq!(tail, "def"); + } + + #[test] + fn bounded_tail_bytes_passes_through_short_input() { + assert_eq!(bounded_tail_bytes("abc", 16), "abc"); + } + + #[test] + fn bounded_tail_bytes_respects_char_boundary() { + let input = "αβγδ"; + let tail = bounded_tail_bytes(input, 5); + + assert!(input.ends_with(&tail)); + assert!(tail.len() <= 5); + } +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs new file mode 100644 index 000000000..725e31592 --- /dev/null +++ b/crates/mcp/src/server.rs @@ -0,0 +1,75 @@ +use rmcp::{ + handler::server::router::tool::ToolRouter, + model::{ServerCapabilities, ServerInfo}, + tool_handler, ServerHandler, +}; + +#[derive(Clone)] +pub struct ZombienetMcpServer { + pub(crate) tool_router: ToolRouter, +} + +impl Default for ZombienetMcpServer { + fn default() -> Self { + Self::new() + } +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for ZombienetMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + instructions: Some( + "This is a read-only Zombienet diagnostics server. When the user asks to debug a run without giving a path, call find_recent_runs first, then diagnose_run with the newest zombie_json_path. It inspects configs, logs, live nodes, and metrics, and never spawns or destroys networks.".to_string(), + ), + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..Default::default() + } + } +} + +#[cfg(test)] +mod tests { + use rmcp::ServerHandler; + + use super::ZombienetMcpServer; + + #[test] + fn exposes_diagnostic_tools_with_stable_names() { + let server = ZombienetMcpServer::new(); + let mut names = server + .tool_router + .list_all() + .into_iter() + .map(|tool| tool.name.to_string()) + .collect::>(); + names.sort(); + + assert_eq!( + names, + vec![ + "check_block_production", + "check_node_liveness", + "diagnose_run", + "find_recent_runs", + "get_node_logs", + "list_nodes", + "query_metric", + "validate_config", + ], + ); + } + + #[test] + fn server_info_describes_read_only_diagnostics() { + let info = ZombienetMcpServer::new().get_info(); + let instructions = info + .instructions + .expect("server should provide client instructions"); + + assert!(instructions.contains("read-only")); + assert!(instructions.contains("never spawns")); + assert!(instructions.contains("destroys networks")); + assert!(info.capabilities.tools.is_some()); + } +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs new file mode 100644 index 000000000..d00e1991e --- /dev/null +++ b/crates/mcp/src/tools.rs @@ -0,0 +1,74 @@ +use rmcp::{handler::server::wrapper::Parameters, tool, tool_router}; + +use crate::{ + diagnostics, + input::{ + BlockProductionInput, ConfigInput, DiagnoseRunInput, ListNodesInput, MetricInput, + NodeInput, NodeLogsInput, + }, + recent_runs, + server::ZombienetMcpServer, +}; + +impl ZombienetMcpServer { + pub fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +#[tool_router] +impl ZombienetMcpServer { + #[tool( + description = "Find recent Zombienet run candidates without arguments. Use this first when the user asks to debug a run but did not provide zombie_json_path." + )] + pub fn find_recent_runs(&self) -> String { + serde_json::to_string(&recent_runs::find_recent_runs()) + .expect("recent run reports should serialize") + } + + #[tool( + description = "Diagnose an already-started Zombienet run from zombie_json_path. If zombie.json is missing because startup failed early, logs near that expected path are scanned. Use after find_recent_runs when the user did not provide a path." + )] + pub async fn diagnose_run(&self, Parameters(input): Parameters) -> String { + report_json(&diagnostics::diagnose_run(input).await) + } + + #[tool(description = "Validate a Zombienet configuration file")] + pub fn validate_config(&self, Parameters(input): Parameters) -> String { + report_json(&diagnostics::validate_config(input)) + } + + #[tool(description = "List nodes from an attached live Zombienet network")] + pub async fn list_nodes(&self, Parameters(input): Parameters) -> String { + report_json(&diagnostics::list_nodes(input).await) + } + + #[tool(description = "Read recent logs for a node from an attached live Zombienet network")] + pub async fn get_node_logs(&self, Parameters(input): Parameters) -> String { + report_json(&diagnostics::get_node_logs(input).await) + } + + #[tool(description = "Check whether a node RPC endpoint and process metric are responsive")] + pub async fn check_node_liveness(&self, Parameters(input): Parameters) -> String { + report_json(&diagnostics::check_node_liveness(input).await) + } + + #[tool(description = "Observe finalized block production for a node")] + pub async fn check_block_production( + &self, + Parameters(input): Parameters, + ) -> String { + report_json(&diagnostics::check_block_production(input).await) + } + + #[tool(description = "Query a Prometheus metric from a node")] + pub async fn query_metric(&self, Parameters(input): Parameters) -> String { + report_json(&diagnostics::query_metric(input).await) + } +} + +fn report_json(report: &crate::report::DiagnosticReport) -> String { + serde_json::to_string(report).expect("diagnostic reports should serialize") +} diff --git a/crates/mcp/tests/fixtures/invalid.toml b/crates/mcp/tests/fixtures/invalid.toml new file mode 100644 index 000000000..b36d8e6dd --- /dev/null +++ b/crates/mcp/tests/fixtures/invalid.toml @@ -0,0 +1,2 @@ +[relaychain +chain = "rococo-local" diff --git a/crates/mcp/tests/fixtures/startup-failure.log b/crates/mcp/tests/fixtures/startup-failure.log new file mode 100644 index 000000000..261532d44 --- /dev/null +++ b/crates/mcp/tests/fixtures/startup-failure.log @@ -0,0 +1,3 @@ +2026-05-09T10:00:00Z starting node +Error: address already in use +thread 'main' panicked at runtime startup diff --git a/crates/mcp/tests/live_diagnostics.rs b/crates/mcp/tests/live_diagnostics.rs new file mode 100644 index 000000000..eab3f47bb --- /dev/null +++ b/crates/mcp/tests/live_diagnostics.rs @@ -0,0 +1,150 @@ +//! Live integration test for the "zombie.json exists but the parachain is +//! stalled" failure mode. +//! +//! It spawns a real network whose parachain is registered under the wrong para +//! id: the chain spec is built for para 1004, but it is registered as 2000. Every +//! process therefore starts and `zombie.json` is written, yet the collator is +//! never backed and never produces blocks. The test then asserts that the MCP +//! diagnostics surface the stall instead of reporting the run as healthy. +//! +//! Ignored by default: it requires `polkadot` and `polkadot-parachain` on `PATH` +//! and takes a couple of minutes. Run it explicitly with: +//! +//! ```sh +//! cargo test -p zombie-mcp --test live_diagnostics -- --ignored --nocapture +//! ``` + +use std::path::PathBuf; + +use zombie_mcp::{ + diagnostics::{check_block_production, diagnose_run}, + input::{BlockProductionInput, DiagnoseRunInput}, + report::{Severity, Status}, +}; +use zombienet_sdk::{NetworkConfigBuilder, NetworkConfigExt}; + +const COLLATOR: &str = "people-collator"; +/// The chain spec is built for para 1004; registering it under a different id +/// keeps every process alive while preventing block production. +const REGISTERED_PARA_ID: u32 = 2000; + +fn spec_path() -> PathBuf { + PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../examples/examples/people-westend-local-spec.json" + )) +} + +fn binary_on_path(name: &str) -> bool { + std::process::Command::new(name) + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "spawns a real network; requires polkadot + polkadot-parachain on PATH"] +async fn diagnoses_stalled_parachain_with_zombie_json_present() -> anyhow::Result<()> { + if !binary_on_path("polkadot") || !binary_on_path("polkadot-parachain") { + eprintln!("skipping: polkadot/polkadot-parachain not found on PATH"); + return Ok(()); + } + let spec = spec_path(); + if !spec.is_file() { + eprintln!("skipping: chain spec not found at {}", spec.display()); + return Ok(()); + } + + let base_dir = std::env::temp_dir().join(format!( + "zombie-mcp-live-stalled-{}-{:?}", + std::process::id(), + std::thread::current().id(), + )); + let base_dir_str = base_dir.to_str().expect("temp base dir is valid UTF-8"); + + let config = NetworkConfigBuilder::new() + .with_relaychain(|r| { + r.with_chain("westend-local") + .with_default_command("polkadot") + .with_default_image("docker.io/parity/polkadot:latest") + .with_default_args(vec!["-lparachain=debug".into()]) + .with_validator(|n| n.with_name("validator-0")) + .with_validator(|n| n.with_name("validator-1")) + }) + .with_parachain(|p| { + p.with_id(REGISTERED_PARA_ID) + .with_chain_spec_path(spec.to_str().expect("spec path is valid UTF-8")) + .with_default_command("polkadot-parachain") + .with_default_image("docker.io/parity/polkadot-parachain:latest") + .with_collator(|n| n.with_name(COLLATOR)) + }) + .with_global_settings(|g| { + g.with_base_dir(base_dir_str) + .with_tear_down_on_failure(false) + }) + .build() + .map_err(|errs| anyhow::anyhow!("config errors: {errs:?}"))?; + + let network = config.spawn_native().await?; + + let zombie_json_path = base_dir.join("zombie.json"); + let zombie_json_written = zombie_json_path.is_file(); + + // diagnose_run must flag the stalled collator instead of reporting "ok". + let report = diagnose_run(DiagnoseRunInput { + zombie_json_path: zombie_json_path.clone(), + }) + .await; + let collator_stalled = report.evidence.iter().any(|e| { + e.id == format!("node.{COLLATOR}.no_block_progress") && e.severity == Severity::Warning + }); + let diagnose_status = report.status; + + // check_block_production must not count the genesis block as production. + let block_report = check_block_production(BlockProductionInput { + zombie_json_path: zombie_json_path.clone(), + node_name: COLLATOR.to_string(), + blocks: 1, + timeout_secs: 15, + }) + .await; + let block_timeout = block_report.evidence.iter().any(|e| { + e.id == format!("node.{COLLATOR}.blocks.timeout") && e.severity == Severity::Error + }); + let block_status = block_report.status; + + // Always tear the network down before asserting, so a failure cannot leak + // node processes. + let _ = network.destroy().await; + let _ = std::fs::remove_dir_all(&base_dir); + + assert!( + zombie_json_written, + "zombie.json should be written after spawn" + ); + assert!( + collator_stalled, + "diagnose_run should flag no_block_progress for the collator: {:?}", + report.evidence + ); + assert_ne!( + diagnose_status, + Status::Ok, + "diagnose_run must not report a stalled network as ok: {:?}", + report.evidence + ); + assert_eq!( + block_status, + Status::Failed, + "check_block_production should fail on a stalled chain: {:?}", + block_report.evidence + ); + assert!( + block_timeout, + "check_block_production should time out instead of counting genesis: {:?}", + block_report.evidence + ); + + Ok(()) +} diff --git a/crates/mcp/tests/mcp_stdio.rs b/crates/mcp/tests/mcp_stdio.rs new file mode 100644 index 000000000..e2d1eb9fa --- /dev/null +++ b/crates/mcp/tests/mcp_stdio.rs @@ -0,0 +1,131 @@ +use std::{borrow::Cow, fs, path::PathBuf, time::Duration}; + +use rmcp::{ + model::{CallToolRequestParams, JsonObject}, + serve_client, + service::{RoleClient, RunningService}, + transport::TokioChildProcess, +}; +use serde_json::Value; +use tokio::{process::Command, time::timeout}; + +const TEST_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn stdio_tool_discovery_includes_diagnose_run() -> anyhow::Result<()> { + let mut client = timeout(TEST_TIMEOUT, spawn_client()).await??; + + let tools_result = timeout(TEST_TIMEOUT, client.peer().list_all_tools()).await; + let close_result = close_client(&mut client).await; + + let tools = tools_result??; + close_result?; + + assert!( + tools.iter().any(|tool| tool.name == "diagnose_run"), + "expected diagnose_run in discovered tools: {tools:?}", + ); + assert!( + tools.iter().any(|tool| tool.name == "find_recent_runs"), + "expected find_recent_runs in discovered tools: {tools:?}", + ); + + Ok(()) +} + +#[tokio::test] +async fn find_recent_runs_works_without_arguments_over_stdio() -> anyhow::Result<()> { + let run_dir = std::env::temp_dir().join(format!( + "zombienet-mcp-stdio-{}-{:?}", + std::process::id(), + std::thread::current().id(), + )); + let node_dir = run_dir.join("alice"); + fs::create_dir_all(&node_dir)?; + fs::write(node_dir.join("alice.log"), "startup failed")?; + + let mut client = timeout(TEST_TIMEOUT, spawn_client()).await??; + + let call_result = timeout( + TEST_TIMEOUT, + client.peer().call_tool(CallToolRequestParams { + meta: None, + name: Cow::Borrowed("find_recent_runs"), + arguments: None, + task: None, + }), + ) + .await; + let close_result = close_client(&mut client).await; + + let result = call_result??; + close_result?; + fs::remove_dir_all(&run_dir)?; + + let response_text = result + .content + .iter() + .filter_map(|content| content.as_text().map(|text| text.text.as_str())) + .collect::(); + + assert!( + response_text.contains(&run_dir.join("zombie.json").display().to_string()), + "expected synthesized zombie_json_path in find_recent_runs response: {response_text}", + ); + + Ok(()) +} + +#[tokio::test] +async fn validate_config_reports_invalid_fixture_over_stdio() -> anyhow::Result<()> { + let mut client = timeout(TEST_TIMEOUT, spawn_client()).await??; + let mut arguments = JsonObject::new(); + arguments.insert( + "config_path".to_string(), + Value::String(fixture_path("invalid.toml").display().to_string()), + ); + + let call_result = timeout( + TEST_TIMEOUT, + client.peer().call_tool(CallToolRequestParams { + meta: None, + name: Cow::Borrowed("validate_config"), + arguments: Some(arguments), + task: None, + }), + ) + .await; + let close_result = close_client(&mut client).await; + + let result = call_result??; + close_result?; + let response_text = result + .content + .iter() + .filter_map(|content| content.as_text().map(|text| text.text.as_str())) + .collect::(); + + assert!( + response_text.contains("config.invalid"), + "expected config.invalid in validate_config response: {response_text}", + ); + + Ok(()) +} + +async fn spawn_client() -> anyhow::Result> { + let transport = TokioChildProcess::new(Command::new(env!("CARGO_BIN_EXE_zombie-mcp")))?; + Ok(serve_client((), transport).await?) +} + +async fn close_client(client: &mut RunningService) -> anyhow::Result<()> { + timeout(TEST_TIMEOUT, client.close()).await??; + Ok(()) +} + +fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(name) +} diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 73dcdf406..4dc40f09b 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -16,5 +16,6 @@ - [Examples](./network-spec/examples.md) - [Providers](./providers.md) - [CLI Usage](./cli.md) +- [MCP Diagnostics](./mcp.md) - [Examples](./examples.md) - [Environment Variables](./environment.md) diff --git a/docs/src/examples.md b/docs/src/examples.md index b4d259c2c..347aa693f 100644 --- a/docs/src/examples.md +++ b/docs/src/examples.md @@ -86,6 +86,7 @@ Scale networks with grouped nodes. |---------|-------------| | [`from_live`](https://github.com/paritytech/zombienet-sdk/blob/main/crates/examples/examples/from_live.rs) | Attach to running network via `zombie.json` | | [`test_run_script`](https://github.com/paritytech/zombienet-sdk/blob/main/crates/examples/examples/test_run_script.rs) | Run scripts on nodes | +| [`observability`](https://github.com/paritytech/zombienet-sdk/blob/main/crates/examples/examples/observability.rs) | Start Prometheus + Grafana for a spawned network and print the local URLs | --- diff --git a/docs/src/intro.md b/docs/src/intro.md index f34576c70..585cd163b 100644 --- a/docs/src/intro.md +++ b/docs/src/intro.md @@ -11,7 +11,7 @@ The SDK succeeds the original [ZombieNet](https://github.com/paritytech/zombiene - **Programmatic Network Spawning** - Define and spawn relay chains and parachains from Rust code - **Fluent Builder API** - Compose networks using a chainable builder pattern - **Multiple Providers** - Run on Kubernetes, Podman, Docker, or natively -- **Metrics & Assertions** - Query Prometheus metrics and validate network behavior +- **Metrics, Assertions & Observability** - Query Prometheus metrics, validate network behavior, and start a local Prometheus + Grafana stack - **Subxt Integration** - Interact with networks using [subxt](https://github.com/paritytech/subxt) ## Use Cases diff --git a/docs/src/mcp.md b/docs/src/mcp.md new file mode 100644 index 000000000..73e7843bc --- /dev/null +++ b/docs/src/mcp.md @@ -0,0 +1,25 @@ +# MCP Diagnostics + +`zombie-mcp` lets Codex or Claude Code debug a zombienet run that you already started. It is read-only: it uses `zombie.json` as the run handle and inspects logs, liveness, metrics, and block production, but it does not start or stop the network. + +## Install + +Run one of these once from the repository root, depending on your client: + +```sh +# Codex +cargo run -p zombie-mcp -- install codex --force + +# Claude Code +cargo run -p zombie-mcp -- install claude --force +``` + +Restart the client after installing so the MCP server is loaded. + +## Debug A Run + +Start your network however you normally do. If it hangs or fails, paste this into Codex: + +```text +Debug my zombienet run with zombie-mcp. +``` diff --git a/docs/src/network-spec/global-settings.md b/docs/src/network-spec/global-settings.md index 50cef5331..a705f3cd2 100644 --- a/docs/src/network-spec/global-settings.md +++ b/docs/src/network-spec/global-settings.md @@ -11,6 +11,13 @@ base_dir = "/tmp/zombienet" spawn_concurrency = 4 tear_down_on_failure = true bootnodes_addresses = ["/ip4/127.0.0.1/tcp/30333/p2p/12D3KooW..."] + +[settings.observability] +enabled = true +prometheus_port = 9090 +grafana_port = 3000 +prometheus_image = "prom/prometheus:latest" +grafana_image = "grafana/grafana:latest" ``` ### Builder @@ -30,11 +37,35 @@ let config = NetworkConfigBuilder::new() .with_local_ip("127.0.0.1") .with_base_dir("/tmp/zombienet") .with_spawn_concurrency(4) + .with_observability(|obs| { + obs.with_enabled(true) + .with_prometheus_port(9090) + .with_grafana_port(3000) + }) }) .build() .unwrap(); ``` +### Observability + +Set `[settings.observability]` to start a local Prometheus + Grafana stack after the network is up. The SDK writes a Prometheus config under the network base directory, points it at every node's Prometheus metrics endpoint, provisions Grafana with Prometheus as the default datasource, and returns the local URLs through `network.observability()`. + +The same stack can also be started as an add-on after a network is already running: + +```rust +use configuration::ObservabilityConfigBuilder; + +let obs_config = ObservabilityConfigBuilder::new() + .with_enabled(true) + .with_grafana_port(3000) + .build(); + +let info = network.start_observability(&obs_config).await?; +println!("Prometheus: {}", info.prometheus_url); +println!("Grafana: {}", info.grafana_url); +``` + ### Reference | Option | Type | Default | Description | @@ -46,3 +77,8 @@ let config = NetworkConfigBuilder::new() | `spawn_concurrency` | Number | — | Number of concurrent spawn processes. Override with `ZOMBIE_SPAWN_CONCURRENCY` env var | | `tear_down_on_failure` | Boolean | `true` | Tear down network if nodes become unresponsive | | `bootnodes_addresses` | Array | — | External bootnode multiaddresses | +| `observability.enabled` | Boolean | `false` | Start the local Prometheus + Grafana stack after the network spawns | +| `observability.prometheus_port` | Number | Auto | Host port for Prometheus | +| `observability.grafana_port` | Number | Auto | Host port for Grafana | +| `observability.prometheus_image` | String | `prom/prometheus:latest` | Container image used for Prometheus | +| `observability.grafana_image` | String | `grafana/grafana:latest` | Container image used for Grafana | From eb522a91698db38daf1b95761b96bb7c0e3e2c27 Mon Sep 17 00:00:00 2001 From: DenzelPenzel Date: Mon, 22 Jun 2026 17:32:08 +0100 Subject: [PATCH 2/4] feat(mcp): add LLM-free CLI mode, diagnostics fixes, and live test coverage --- crates/mcp/Cargo.toml | 8 +- crates/mcp/src/cli.rs | 94 +++++++++++ crates/mcp/src/diagnostics/evidence.rs | 54 +++++- crates/mcp/src/diagnostics/mod.rs | 36 +++- crates/mcp/src/lib.rs | 2 + crates/mcp/src/main.rs | 33 +++- crates/mcp/tests/diagnose_cli.rs | 98 +++++++++++ crates/mcp/tests/live_diagnostics.rs | 225 ++++++++++++++++++++----- crates/mcp/tests/mcp_stdio.rs | 2 + crates/mcp/tests/validate_cli.rs | 52 ++++++ docs/src/mcp.md | 28 +++ 11 files changed, 580 insertions(+), 52 deletions(-) create mode 100644 crates/mcp/src/cli.rs create mode 100644 crates/mcp/tests/diagnose_cli.rs create mode 100644 crates/mcp/tests/validate_cli.rs diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index da0832b41..c2c2c5873 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -14,20 +14,24 @@ keywords = ["zombienet", "mcp", "diagnostics"] name = "zombie-mcp" path = "src/main.rs" +[features] +default = ["mcp"] +mcp = ["dep:rmcp"] + [dependencies] anyhow = { workspace = true } clap = { workspace = true } futures = { workspace = true } prom-metrics-parser = { workspace = true } reqwest = { workspace = true } -rmcp = { workspace = true } +rmcp = { workspace = true, optional = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } tracing = { workspace = true } -tracing-subscriber = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } walkdir = { workspace = true } zombienet-sdk = { workspace = true } diff --git a/crates/mcp/src/cli.rs b/crates/mcp/src/cli.rs new file mode 100644 index 000000000..b4014ef05 --- /dev/null +++ b/crates/mcp/src/cli.rs @@ -0,0 +1,94 @@ +use std::path::PathBuf; + +use anyhow::Context; +use clap::Args; +use zombie_mcp::{ + diagnostics, + input::{ConfigInput, DiagnoseRunInput}, + recent_runs, + report::Status, +}; + +#[derive(Args, Debug)] +pub struct DiagnoseArgs { + /// Diagnose a specific run by its zombie.json path. + #[arg(long, conflicts_with = "auto")] + zombie_json: Option, + /// Auto-discover the most recent run and diagnose it. + #[arg(long)] + auto: bool, + /// Exit with code 1 when the report status is `failed`. + #[arg(long)] + fail_on_error: bool, +} + +#[derive(Args, Debug)] +pub struct ValidateArgs { + /// Path to the Zombienet configuration file (.toml) to validate. + #[arg(long)] + config: PathBuf, + /// Exit with code 1 when the report status is `failed`. + #[arg(long)] + fail_on_error: bool, +} + +/// Run read-only diagnostics without an MCP/LLM client and print a JSON report. +/// +/// This is the built-in orchestration the LLM used to perform by hand: resolve a +/// run path (explicitly or via auto-discovery), run the same `diagnose_run` core +/// the MCP tool calls, print the report to stdout, and optionally gate CI on it. +pub async fn run(args: DiagnoseArgs) -> anyhow::Result<()> { + let path = resolve_run_path(args.zombie_json, args.auto)?; + let report = diagnostics::diagnose_run(DiagnoseRunInput { + zombie_json_path: path, + }) + .await; + + let json = serde_json::to_string(&report).context("diagnostic reports should serialize")?; + println!("{json}"); + + if args.fail_on_error && report.status == Status::Failed { + std::process::exit(1); + } + + Ok(()) +} + +/// Validate a Zombienet config file without an MCP/LLM client and print a JSON +/// report. Calls the same `validate_config` core the MCP tool exposes. +pub fn validate(args: ValidateArgs) -> anyhow::Result<()> { + let report = diagnostics::validate_config(ConfigInput { + config_path: args.config, + }); + + let json = serde_json::to_string(&report).context("diagnostic reports should serialize")?; + println!("{json}"); + + if args.fail_on_error && report.status == Status::Failed { + std::process::exit(1); + } + + Ok(()) +} + +fn resolve_run_path(zombie_json: Option, auto: bool) -> anyhow::Result { + match (zombie_json, auto) { + (Some(path), _) => Ok(path), + (None, true) => pick_newest_run(), + (None, false) => anyhow::bail!("pass --zombie-json or --auto"), + } +} + +/// Pick the newest run discovered by `find_recent_runs`. +/// +/// Runs are already sorted newest-first, so this is the `find_recent_runs -> +/// diagnose_run` sequence the LLM used to drive, now built in. +fn pick_newest_run() -> anyhow::Result { + let report = recent_runs::find_recent_runs(); + let newest = report + .runs + .into_iter() + .next() + .context("no recent zombienet runs found; pass --zombie-json ")?; + Ok(PathBuf::from(newest.zombie_json_path)) +} diff --git a/crates/mcp/src/diagnostics/evidence.rs b/crates/mcp/src/diagnostics/evidence.rs index 17d13d10b..66a11660a 100644 --- a/crates/mcp/src/diagnostics/evidence.rs +++ b/crates/mcp/src/diagnostics/evidence.rs @@ -56,12 +56,23 @@ pub(super) fn validate_input( } } -/// Recompute `summary` and `status` from the current evidence. +/// Collapse repeated findings, then recompute `summary` and `status`. pub(super) fn finalize(report: &mut DiagnosticReport) { + dedupe(&mut report.evidence); report.summary = summarize(&report.evidence); report.status = status_from_evidence(&report.evidence); } +fn dedupe(evidence: &mut Vec) { + let mut deduped: Vec = Vec::with_capacity(evidence.len()); + for item in evidence.drain(..) { + if !deduped.contains(&item) { + deduped.push(item); + } + } + *evidence = deduped; +} + pub(super) fn summarize(evidence: &[Evidence]) -> String { if evidence.is_empty() { return "No startup diagnostics were found".to_string(); @@ -78,3 +89,44 @@ pub(super) fn summarize(evidence: &[Evidence]) -> String { .collect::>() .join(", ") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finalize_collapses_repeated_findings() { + let mut report = DiagnosticReport::new("test"); + for _ in 0..3 { + push( + &mut report, + Severity::Error, + "logs.unexpected argument", + Category::Startup, + "alice.log", + "A node command-line argument appears to be invalid", + "alice.log", + Some("error: unexpected argument '--bad' found".to_string()), + ); + } + push( + &mut report, + Severity::Warning, + "zombie_json.missing", + Category::Startup, + "zombie.json", + "zombie.json file was not found", + "zombie.json", + None, + ); + + finalize(&mut report); + + assert_eq!(report.evidence.len(), 2); + assert!(report + .evidence + .iter() + .any(|e| e.id == "logs.unexpected argument")); + assert_eq!(report.summary, "Warning: 1, Error: 1"); + } +} diff --git a/crates/mcp/src/diagnostics/mod.rs b/crates/mcp/src/diagnostics/mod.rs index 981b557d8..b8b0dde70 100644 --- a/crates/mcp/src/diagnostics/mod.rs +++ b/crates/mcp/src/diagnostics/mod.rs @@ -91,6 +91,19 @@ pub fn next_steps_for(report: &DiagnosticReport) -> Vec { ]; } + if report + .evidence + .iter() + .any(|item| item.id.ends_with(".no_block_progress")) + { + return vec![ + "A node is up but not producing or importing blocks. Check parachain registration, \ + collator-to-relay connectivity (peer count), and that the registered para id matches \ + the chain spec." + .to_string(), + ]; + } + vec!["No immediate failure detected".to_string()] } @@ -101,9 +114,30 @@ mod tests { use super::*; use crate::{ diagnostics::test_helpers::{fixture_path, temp_zombie_json, unique_temp_path}, - report::Status, + report::{Evidence, Status}, }; + #[test] + fn next_steps_flags_a_node_stuck_with_no_block_progress() { + let mut report = DiagnosticReport::new("test"); + report.push_evidence(Evidence { + id: "node.people-collator.no_block_progress".to_string(), + severity: Severity::Warning, + category: Category::Liveness, + subject: "people-collator".to_string(), + message: "Node is up but its best block is still 0".to_string(), + source: "http://127.0.0.1:9615/metrics".to_string(), + excerpt: None, + }); + + let steps = next_steps_for(&report); + + assert_eq!(steps.len(), 1); + assert_ne!(steps[0], "No immediate failure detected"); + assert!(steps[0].contains("not producing or importing blocks")); + assert!(steps[0].contains("para id")); + } + #[tokio::test] async fn scans_logs_next_to_missing_zombie_json() { let base_dir = unique_temp_path("zombie-mcp-missing-json-log-scan", "dir"); diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 8b0a8ecac..ca4167f1f 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -3,5 +3,7 @@ pub mod input; pub mod log_patterns; pub mod recent_runs; pub mod report; +#[cfg(feature = "mcp")] pub mod server; +#[cfg(feature = "mcp")] pub mod tools; diff --git a/crates/mcp/src/main.rs b/crates/mcp/src/main.rs index 83d0c6388..0234f3fe0 100644 --- a/crates/mcp/src/main.rs +++ b/crates/mcp/src/main.rs @@ -1,7 +1,6 @@ use clap::{Parser, Subcommand}; -use rmcp::{transport::stdio, ServiceExt}; -use zombie_mcp::server::ZombienetMcpServer; +mod cli; mod install; #[derive(Parser, Debug)] @@ -15,11 +14,18 @@ struct Cli { enum Command { /// Register zombie-mcp as an MCP server for a client Install(install::InstallArgs), + /// Run read-only diagnostics without an MCP/LLM client and print a JSON report. + Diagnose(cli::DiagnoseArgs), + /// Validate a Zombienet config file without an MCP/LLM client. + Validate(cli::ValidateArgs), } #[tokio::main] async fn main() -> anyhow::Result<()> { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,zombienet_orchestrator=warn")); tracing_subscriber::fmt() + .with_env_filter(filter) .with_writer(std::io::stderr) .with_ansi(false) .init(); @@ -27,10 +33,33 @@ async fn main() -> anyhow::Result<()> { if let Some(command) = Cli::parse().command { match command { Command::Install(args) => return install::run(args), + Command::Diagnose(args) => return cli::run(args).await, + Command::Validate(args) => return cli::validate(args), } } + run_default().await +} + +/// With the `mcp` feature, the default (no subcommand) launches the MCP stdio server. +#[cfg(feature = "mcp")] +async fn run_default() -> anyhow::Result<()> { + use rmcp::{transport::stdio, ServiceExt}; + use zombie_mcp::server::ZombienetMcpServer; + let service = ZombienetMcpServer::default().serve(stdio()).await?; service.waiting().await?; Ok(()) } + +/// Without the `mcp` feature there is no server, so point the user at the CLI. +#[cfg(not(feature = "mcp"))] +async fn run_default() -> anyhow::Result<()> { + use clap::CommandFactory; + + Cli::command().print_help()?; + eprintln!(); + eprintln!("This build has no MCP server (compiled with --no-default-features)."); + eprintln!("Run `zombie-mcp diagnose --auto` for read-only diagnostics."); + Ok(()) +} diff --git a/crates/mcp/tests/diagnose_cli.rs b/crates/mcp/tests/diagnose_cli.rs new file mode 100644 index 000000000..339062699 --- /dev/null +++ b/crates/mcp/tests/diagnose_cli.rs @@ -0,0 +1,98 @@ +use std::process::Command; + +use serde_json::Value; + +/// `diagnose --zombie-json ` returns a report (not a panic) with the +/// `zombie_json.missing` warning and exits 0 without `--fail-on-error`. +#[test] +fn diagnose_missing_zombie_json_reports_and_exits_zero() { + let missing = std::env::temp_dir().join(format!( + "zombie-mcp-diagnose-cli-missing-{}/zombie.json", + std::process::id(), + )); + + let output = Command::new(env!("CARGO_BIN_EXE_zombie-mcp")) + .args(["diagnose", "--zombie-json", &missing.display().to_string()]) + .output() + .expect("zombie-mcp binary runs"); + + assert!( + output.status.success(), + "expected exit 0, got {:?}; stderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr), + ); + + let stdout = String::from_utf8(output.stdout).expect("stdout is utf-8"); + let report: Value = serde_json::from_str(&stdout) + .unwrap_or_else(|error| panic!("stdout should be a JSON report ({error}): {stdout}")); + + let has_missing = report["evidence"] + .as_array() + .expect("evidence is an array") + .iter() + .any(|item| item["id"] == "zombie_json.missing"); + assert!(has_missing); +} + +/// Passing neither `--zombie-json` nor `--auto` is a usage error (non-zero exit). +#[test] +fn diagnose_without_path_or_auto_errors() { + let output = Command::new(env!("CARGO_BIN_EXE_zombie-mcp")) + .arg("diagnose") + .output() + .expect("zombie-mcp binary runs"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("pass --zombie-json or --auto")); +} + +/// The CI gate: a run whose `zombie.json` has no attachable nodes yields a +/// `failed` report. `--fail-on-error` must turn that into exit 1, while the same +/// run without the flag still exits 0 (the report is always printed either way). +#[test] +fn fail_on_error_flips_exit_code_for_failed_run() { + use std::fs; + + let dir = std::env::temp_dir().join(format!( + "zombie-mcp-diagnose-cli-failed-{}", + std::process::id(), + )); + fs::create_dir_all(&dir).expect("temp run dir can be created"); + let zombie_json = dir.join("zombie.json"); + // No attachable nodes -> network.attach_failed (Error) -> status "failed". + fs::write(&zombie_json, r#"{"relay": {"nodes": []}}"#).expect("zombie.json can be written"); + let path = zombie_json.display().to_string(); + + // Without the flag: report is printed and the status really is `failed`, but + // the process still exits 0 — so the flag has a non-trivial effect to verify. + let baseline = Command::new(env!("CARGO_BIN_EXE_zombie-mcp")) + .args(["diagnose", "--zombie-json", &path]) + .output() + .expect("zombie-mcp binary runs"); + let stdout = String::from_utf8(baseline.stdout).expect("stdout is utf-8"); + let report: Value = serde_json::from_str(&stdout) + .unwrap_or_else(|error| panic!("stdout should be a JSON report ({error}): {stdout}")); + assert_eq!(report["status"], "failed", "expected failed status: {stdout}"); + assert!( + baseline.status.success(), + "expected exit 0 without --fail-on-error, got {:?}", + baseline.status, + ); + + // With the flag: the same failed report exits 1. + let gated = Command::new(env!("CARGO_BIN_EXE_zombie-mcp")) + .args(["diagnose", "--zombie-json", &path, "--fail-on-error"]) + .output() + .expect("zombie-mcp binary runs"); + + fs::remove_dir_all(&dir).ok(); + + assert_eq!( + gated.status.code(), + Some(1), + "expected exit 1 with --fail-on-error on a failed run; stderr: {}", + String::from_utf8_lossy(&gated.stderr), + ); +} diff --git a/crates/mcp/tests/live_diagnostics.rs b/crates/mcp/tests/live_diagnostics.rs index eab3f47bb..773a05f63 100644 --- a/crates/mcp/tests/live_diagnostics.rs +++ b/crates/mcp/tests/live_diagnostics.rs @@ -1,14 +1,8 @@ -//! Live integration test for the "zombie.json exists but the parachain is -//! stalled" failure mode. +//! Live E2E tests: spawn real networks and check the diagnostics verdict for a +//! stalled parachain, a healthy network, and a paused node. //! -//! It spawns a real network whose parachain is registered under the wrong para -//! id: the chain spec is built for para 1004, but it is registered as 2000. Every -//! process therefore starts and `zombie.json` is written, yet the collator is -//! never backed and never produces blocks. The test then asserts that the MCP -//! diagnostics surface the stall instead of reporting the run as healthy. -//! -//! Ignored by default: it requires `polkadot` and `polkadot-parachain` on `PATH` -//! and takes a couple of minutes. Run it explicitly with: +//! `#[ignore]` (manual only, not CI): they need `polkadot` + `polkadot-parachain` +//! on `PATH` and take minutes. Run with: //! //! ```sh //! cargo test -p zombie-mcp --test live_diagnostics -- --ignored --nocapture @@ -21,12 +15,12 @@ use zombie_mcp::{ input::{BlockProductionInput, DiagnoseRunInput}, report::{Severity, Status}, }; -use zombienet_sdk::{NetworkConfigBuilder, NetworkConfigExt}; +use zombienet_sdk::{LocalFileSystem, Network, NetworkConfigBuilder, NetworkConfigExt}; const COLLATOR: &str = "people-collator"; -/// The chain spec is built for para 1004; registering it under a different id -/// keeps every process alive while preventing block production. -const REGISTERED_PARA_ID: u32 = 2000; +const BEST_BLOCK_METRIC: &str = "block_height{status=\"best\"}"; +/// How long to wait for a chain to start producing blocks. +const BLOCK_WAIT_SECS: u64 = 120; fn spec_path() -> PathBuf { PathBuf::from(concat!( @@ -35,33 +29,32 @@ fn spec_path() -> PathBuf { )) } -fn binary_on_path(name: &str) -> bool { - std::process::Command::new(name) - .arg("--version") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) +struct LiveNetwork { + network: Network, + zombie_json: PathBuf, + base_dir: PathBuf, } -#[tokio::test(flavor = "multi_thread")] -#[ignore = "spawns a real network; requires polkadot + polkadot-parachain on PATH"] -async fn diagnoses_stalled_parachain_with_zombie_json_present() -> anyhow::Result<()> { - if !binary_on_path("polkadot") || !binary_on_path("polkadot-parachain") { - eprintln!("skipping: polkadot/polkadot-parachain not found on PATH"); - return Ok(()); - } - let spec = spec_path(); - if !spec.is_file() { - eprintln!("skipping: chain spec not found at {}", spec.display()); - return Ok(()); +impl LiveNetwork { + /// Tear the network down and remove its base dir. Call before asserting so a + /// panic cannot leak node processes. + async fn cleanup(self) { + let _ = self.network.destroy().await; + let _ = std::fs::remove_dir_all(&self.base_dir); } +} +/// Spawn the people-westend network registering the parachain under `para_id`. +/// A `para_id` matching `SPEC_PARA_ID` is healthy; any other id leaves the +/// collator unbacked (spawned but stalled). +async fn spawn_people_network(suffix: &str, para_id: u32) -> anyhow::Result { let base_dir = std::env::temp_dir().join(format!( - "zombie-mcp-live-stalled-{}-{:?}", + "zombie-mcp-live-{suffix}-{}-{:?}", std::process::id(), std::thread::current().id(), )); let base_dir_str = base_dir.to_str().expect("temp base dir is valid UTF-8"); + let spec = spec_path(); let config = NetworkConfigBuilder::new() .with_relaychain(|r| { @@ -73,7 +66,7 @@ async fn diagnoses_stalled_parachain_with_zombie_json_present() -> anyhow::Resul .with_validator(|n| n.with_name("validator-1")) }) .with_parachain(|p| { - p.with_id(REGISTERED_PARA_ID) + p.with_id(para_id) .with_chain_spec_path(spec.to_str().expect("spec path is valid UTF-8")) .with_default_command("polkadot-parachain") .with_default_image("docker.io/parity/polkadot-parachain:latest") @@ -87,13 +80,59 @@ async fn diagnoses_stalled_parachain_with_zombie_json_present() -> anyhow::Resul .map_err(|errs| anyhow::anyhow!("config errors: {errs:?}"))?; let network = config.spawn_native().await?; + let zombie_json = base_dir.join("zombie.json"); + Ok(LiveNetwork { + network, + zombie_json, + base_dir, + }) +} + +/// Spawn a relay-only network (two validators, no parachain). It reliably +/// produces blocks on `westend-local`, so it is the basis for the healthy +/// control and the node-down case. +async fn spawn_relay_only(suffix: &str) -> anyhow::Result { + let base_dir = std::env::temp_dir().join(format!( + "zombie-mcp-live-{suffix}-{}-{:?}", + std::process::id(), + std::thread::current().id(), + )); + let base_dir_str = base_dir.to_str().expect("temp base dir is valid UTF-8"); + + let config = NetworkConfigBuilder::new() + .with_relaychain(|r| { + r.with_chain("westend-local") + .with_default_command("polkadot") + .with_default_image("docker.io/parity/polkadot:latest") + .with_validator(|n| n.with_name("validator-0")) + .with_validator(|n| n.with_name("validator-1")) + }) + .with_global_settings(|g| { + g.with_base_dir(base_dir_str) + .with_tear_down_on_failure(false) + }) + .build() + .map_err(|errs| anyhow::anyhow!("config errors: {errs:?}"))?; + + let network = config.spawn_native().await?; + let zombie_json = base_dir.join("zombie.json"); + Ok(LiveNetwork { + network, + zombie_json, + base_dir, + }) +} - let zombie_json_path = base_dir.join("zombie.json"); - let zombie_json_written = zombie_json_path.is_file(); +#[tokio::test(flavor = "multi_thread")] +#[ignore = "spawns a real network; requires polkadot + polkadot-parachain on PATH"] +async fn diagnoses_stalled_parachain_with_zombie_json_present() -> anyhow::Result<()> { + // Register under the wrong id: every process starts and zombie.json is + // written, but the collator is never backed and never produces blocks. + let live = spawn_people_network("stalled", 2000).await?; + let zombie_json_written = live.zombie_json.is_file(); - // diagnose_run must flag the stalled collator instead of reporting "ok". let report = diagnose_run(DiagnoseRunInput { - zombie_json_path: zombie_json_path.clone(), + zombie_json_path: live.zombie_json.clone(), }) .await; let collator_stalled = report.evidence.iter().any(|e| { @@ -103,7 +142,7 @@ async fn diagnoses_stalled_parachain_with_zombie_json_present() -> anyhow::Resul // check_block_production must not count the genesis block as production. let block_report = check_block_production(BlockProductionInput { - zombie_json_path: zombie_json_path.clone(), + zombie_json_path: live.zombie_json.clone(), node_name: COLLATOR.to_string(), blocks: 1, timeout_secs: 15, @@ -114,15 +153,9 @@ async fn diagnoses_stalled_parachain_with_zombie_json_present() -> anyhow::Resul }); let block_status = block_report.status; - // Always tear the network down before asserting, so a failure cannot leak - // node processes. - let _ = network.destroy().await; - let _ = std::fs::remove_dir_all(&base_dir); + live.cleanup().await; - assert!( - zombie_json_written, - "zombie.json should be written after spawn" - ); + assert!(zombie_json_written, "zombie.json should be written after spawn"); assert!( collator_stalled, "diagnose_run should flag no_block_progress for the collator: {:?}", @@ -148,3 +181,103 @@ async fn diagnoses_stalled_parachain_with_zombie_json_present() -> anyhow::Resul Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "spawns a real network; requires polkadot + polkadot-parachain on PATH"] +async fn healthy_network_diagnoses_clean() -> anyhow::Result<()> { + // Relay-only: a network that genuinely produces blocks, so the control is + // about "healthy diagnoses clean", not about parachain backing. + let live = spawn_relay_only("healthy").await?; + + // Wait until the relay is actually producing before diagnosing, otherwise a + // validator would momentarily look stalled at genesis. + let produced = live + .network + .get_node("validator-0")? + .wait_metric_with_timeout(BEST_BLOCK_METRIC, |height| height >= 1.0, BLOCK_WAIT_SECS) + .await; + + let report = diagnose_run(DiagnoseRunInput { + zombie_json_path: live.zombie_json.clone(), + }) + .await; + let any_no_progress = report + .evidence + .iter() + .any(|e| e.id.ends_with(".no_block_progress")); + let any_error = report + .evidence + .iter() + .any(|e| e.severity == Severity::Error); + let status = report.status; + + live.cleanup().await; + + produced.map_err(|e| anyhow::anyhow!("parachain did not produce blocks in time: {e}"))?; + assert!( + !any_no_progress, + "a healthy network must not be flagged with no_block_progress: {:?}", + report.evidence + ); + assert!( + !any_error, + "a healthy network must not produce error evidence: {:?}", + report.evidence + ); + assert_eq!( + status, + Status::Ok, + "a healthy network should diagnose as ok: {:?}", + report.evidence + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "spawns a real network; requires polkadot + polkadot-parachain on PATH"] +async fn paused_node_is_reported_as_unresponsive() -> anyhow::Result<()> { + let downed = "validator-1"; + let live = spawn_relay_only("node-down").await?; + + // Bring the network to a healthy state first, so the paused node is the only + // anomaly the diagnostics should surface. + let _ = live + .network + .get_node("validator-0")? + .wait_metric_with_timeout(BEST_BLOCK_METRIC, |height| height >= 1.0, BLOCK_WAIT_SECS) + .await; + + // Pause (SIGSTOP) the validator so its RPC stops answering. + live.network.get_node(downed)?.pause().await?; + + let report = diagnose_run(DiagnoseRunInput { + zombie_json_path: live.zombie_json.clone(), + }) + .await; + let downed_rpc_error = report.evidence.iter().any(|e| { + e.id.starts_with(&format!("node.{downed}.rpc_")) && e.severity == Severity::Error + }); + let status = report.status; + let next_steps = report.next_steps.clone(); + + live.cleanup().await; + + assert!( + downed_rpc_error, + "diagnose_run should flag the paused node's RPC as down: {:?}", + report.evidence + ); + assert_eq!( + status, + Status::Failed, + "a network with a downed node should diagnose as failed: {:?}", + report.evidence + ); + assert!( + next_steps.iter().any(|s| s.contains("RPC port")), + "next steps should point at the RPC endpoint: {next_steps:?}" + ); + + Ok(()) +} diff --git a/crates/mcp/tests/mcp_stdio.rs b/crates/mcp/tests/mcp_stdio.rs index e2d1eb9fa..1bf965556 100644 --- a/crates/mcp/tests/mcp_stdio.rs +++ b/crates/mcp/tests/mcp_stdio.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "mcp")] + use std::{borrow::Cow, fs, path::PathBuf, time::Duration}; use rmcp::{ diff --git a/crates/mcp/tests/validate_cli.rs b/crates/mcp/tests/validate_cli.rs new file mode 100644 index 000000000..789cb3e33 --- /dev/null +++ b/crates/mcp/tests/validate_cli.rs @@ -0,0 +1,52 @@ +use std::process::Command; + +use serde_json::Value; + +fn invalid_config() -> String { + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/invalid.toml").to_string() +} + +/// `validate --config ` returns a `failed` report carrying the +/// `config.invalid` evidence, and exits 0 without `--fail-on-error`. +#[test] +fn validate_reports_invalid_config() { + let output = Command::new(env!("CARGO_BIN_EXE_zombie-mcp")) + .args(["validate", "--config", &invalid_config()]) + .output() + .expect("zombie-mcp binary runs"); + + assert!( + output.status.success(), + "expected exit 0 without --fail-on-error, got {:?}; stderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr), + ); + + let stdout = String::from_utf8(output.stdout).expect("stdout is utf-8"); + let report: Value = serde_json::from_str(&stdout) + .unwrap_or_else(|error| panic!("stdout should be a JSON report ({error}): {stdout}")); + + assert_eq!(report["status"], "failed", "expected failed status: {stdout}"); + let has_config_invalid = report["evidence"] + .as_array() + .expect("evidence is an array") + .iter() + .any(|item| item["id"] == "config.invalid"); + assert!(has_config_invalid, "expected config.invalid evidence: {stdout}"); +} + +/// `--fail-on-error` turns an invalid config into a non-zero exit (CI gate). +#[test] +fn validate_fail_on_error_exits_nonzero() { + let output = Command::new(env!("CARGO_BIN_EXE_zombie-mcp")) + .args(["validate", "--config", &invalid_config(), "--fail-on-error"]) + .output() + .expect("zombie-mcp binary runs"); + + assert_eq!( + output.status.code(), + Some(1), + "expected exit 1 with --fail-on-error on an invalid config; stderr: {}", + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/docs/src/mcp.md b/docs/src/mcp.md index 73e7843bc..c7df6bb4b 100644 --- a/docs/src/mcp.md +++ b/docs/src/mcp.md @@ -2,6 +2,34 @@ `zombie-mcp` lets Codex or Claude Code debug a zombienet run that you already started. It is read-only: it uses `zombie.json` as the run handle and inspects logs, liveness, metrics, and block production, but it does not start or stop the network. +The same diagnostics also run **without an LLM client** as a plain CLI (see [CLI mode](#cli-mode)). The two layers share one core: + +- **CLI / core (deterministic).** `zombie-mcp diagnose` runs the baked detection — config validation, log-pattern matching, liveness, metrics, block production — and prints a JSON report. It is reproducible and CI-friendly; no model is involved. +- **LLM via MCP (additive).** The MCP frontend only adds what a deterministic tool cannot: natural-language entry, correlating evidence with repository source, and reasoning about errors that fall outside the baked log patterns. It orchestrates the same core functions; it does not change their logic. + +## CLI mode + +For humans and CI, run the diagnostics directly — no client to install, no model required: + +```sh +# Auto-discover the most recent run and diagnose it. +cargo run -p zombie-mcp -- diagnose --auto + +# Diagnose a specific run by its zombie.json path. +cargo run -p zombie-mcp -- diagnose --zombie-json /path/to/zombie.json + +# Gate CI: exit 1 when the report status is `failed`. +cargo run -p zombie-mcp -- diagnose --auto --fail-on-error +``` + +The JSON `DiagnosticReport` is written to stdout; tracing logs stay on stderr, so you can pipe the report straight into `jq` (which also pretty-prints it) or a CI artifact. `--auto` picks the newest run found by the same discovery the LLM used (`find_recent_runs`), then diagnoses it. + +The MCP server is an opt-in `mcp` feature (on by default). To build just the core + CLI without the MCP server stack: + +```sh +cargo build -p zombie-mcp --no-default-features +``` + ## Install Run one of these once from the repository root, depending on your client: From 3996ff6bd0e953846946a714ccdb3b31e13414d1 Mon Sep 17 00:00:00 2001 From: DenzelPenzel Date: Mon, 22 Jun 2026 17:35:52 +0100 Subject: [PATCH 3/4] style: rustfmt (nightly) --- crates/mcp/tests/diagnose_cli.rs | 5 ++++- crates/mcp/tests/live_diagnostics.rs | 12 ++++++++---- crates/mcp/tests/validate_cli.rs | 10 ++++++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/crates/mcp/tests/diagnose_cli.rs b/crates/mcp/tests/diagnose_cli.rs index 339062699..61c20d427 100644 --- a/crates/mcp/tests/diagnose_cli.rs +++ b/crates/mcp/tests/diagnose_cli.rs @@ -74,7 +74,10 @@ fn fail_on_error_flips_exit_code_for_failed_run() { let stdout = String::from_utf8(baseline.stdout).expect("stdout is utf-8"); let report: Value = serde_json::from_str(&stdout) .unwrap_or_else(|error| panic!("stdout should be a JSON report ({error}): {stdout}")); - assert_eq!(report["status"], "failed", "expected failed status: {stdout}"); + assert_eq!( + report["status"], "failed", + "expected failed status: {stdout}" + ); assert!( baseline.status.success(), "expected exit 0 without --fail-on-error, got {:?}", diff --git a/crates/mcp/tests/live_diagnostics.rs b/crates/mcp/tests/live_diagnostics.rs index 773a05f63..122610db6 100644 --- a/crates/mcp/tests/live_diagnostics.rs +++ b/crates/mcp/tests/live_diagnostics.rs @@ -155,7 +155,10 @@ async fn diagnoses_stalled_parachain_with_zombie_json_present() -> anyhow::Resul live.cleanup().await; - assert!(zombie_json_written, "zombie.json should be written after spawn"); + assert!( + zombie_json_written, + "zombie.json should be written after spawn" + ); assert!( collator_stalled, "diagnose_run should flag no_block_progress for the collator: {:?}", @@ -255,9 +258,10 @@ async fn paused_node_is_reported_as_unresponsive() -> anyhow::Result<()> { zombie_json_path: live.zombie_json.clone(), }) .await; - let downed_rpc_error = report.evidence.iter().any(|e| { - e.id.starts_with(&format!("node.{downed}.rpc_")) && e.severity == Severity::Error - }); + let downed_rpc_error = report + .evidence + .iter() + .any(|e| e.id.starts_with(&format!("node.{downed}.rpc_")) && e.severity == Severity::Error); let status = report.status; let next_steps = report.next_steps.clone(); diff --git a/crates/mcp/tests/validate_cli.rs b/crates/mcp/tests/validate_cli.rs index 789cb3e33..561ee6ce1 100644 --- a/crates/mcp/tests/validate_cli.rs +++ b/crates/mcp/tests/validate_cli.rs @@ -26,13 +26,19 @@ fn validate_reports_invalid_config() { let report: Value = serde_json::from_str(&stdout) .unwrap_or_else(|error| panic!("stdout should be a JSON report ({error}): {stdout}")); - assert_eq!(report["status"], "failed", "expected failed status: {stdout}"); + assert_eq!( + report["status"], "failed", + "expected failed status: {stdout}" + ); let has_config_invalid = report["evidence"] .as_array() .expect("evidence is an array") .iter() .any(|item| item["id"] == "config.invalid"); - assert!(has_config_invalid, "expected config.invalid evidence: {stdout}"); + assert!( + has_config_invalid, + "expected config.invalid evidence: {stdout}" + ); } /// `--fail-on-error` turns an invalid config into a non-zero exit (CI gate). From 0a82e112d42d0be0564e496d1c3d53d8a0aec5e8 Mon Sep 17 00:00:00 2001 From: DenzelPenzel Date: Mon, 22 Jun 2026 19:20:29 +0100 Subject: [PATCH 4/4] Update Cargo.lock --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 0eb0aff15..b55b04756 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11064,7 +11064,7 @@ dependencies = [ [[package]] name = "zombie-mcp" -version = "0.4.13" +version = "0.4.14" dependencies = [ "anyhow", "clap",