Skip to main content

crates_docs/tools/
health.rs

1//! Health check tool
2//!
3//! Provides functionality to check the health status of the server
4//! and external services (docs.rs, crates.io). Used for diagnosing
5//! connection issues and monitoring system availability.
6
7#![allow(missing_docs)]
8
9use crate::tools::Tool;
10use async_trait::async_trait;
11use rust_mcp_sdk::macros;
12use serde::{Deserialize, Serialize};
13use std::time::{Duration, Instant};
14
15/// The set of valid `check_type` values accepted by the `health_check` tool.
16/// Kept in sync with the schema description and the `perform_checks` match.
17const VALID_CHECK_TYPES: &[&str] = &["all", "external", "internal", "docs_rs", "crates_io"];
18
19/// Parameters for the `health_check` tool
20///
21/// Defines the input parameters for performing health checks,
22/// including the type of check to perform and verbosity level.
23#[macros::mcp_tool(
24    name = "health_check",
25    title = "Health Check",
26    description = "Check the health status of the server and external services (docs.rs, crates.io). Used for diagnosing connection issues and monitoring system availability.",
27    destructive_hint = false,
28    idempotent_hint = true,
29    open_world_hint = false,
30    read_only_hint = true,
31    icons = [
32        (src = "https://img.icons8.com/color/96/000000/heart-health.png", mime_type = "image/png", sizes = ["96x96"], theme = "light"),
33        (src = "https://img.icons8.com/color/96/000000/heart-health.png", mime_type = "image/png", sizes = ["96x96"], theme = "dark")
34    ]
35)]
36#[derive(Debug, Clone, Deserialize, Serialize, macros::JsonSchema)]
37pub struct HealthCheckTool {
38    /// Check type
39    #[json_schema(
40        title = "Check Type",
41        description = "Type of health check to perform: all (all checks), external (external services: docs.rs, crates.io), internal (internal state), docs_rs (docs.rs only), crates_io (crates.io only)",
42        default = "all"
43    )]
44    pub check_type: Option<String>,
45
46    /// Verbose output
47    #[json_schema(
48        title = "Verbose Output",
49        description = "Whether to show detailed output including response time for each check",
50        default = false
51    )]
52    pub verbose: Option<bool>,
53}
54
55/// Overall health check result containing all check results
56#[derive(Debug, Clone, Serialize)]
57struct HealthStatus {
58    /// Overall status: "healthy", "unhealthy", or "degraded"
59    status: String,
60    /// Timestamp of the health check in RFC3339 format
61    timestamp: String,
62    /// Individual check results
63    checks: Vec<HealthCheck>,
64    /// Server uptime duration
65    uptime: Duration,
66}
67
68/// Result of a single health check
69#[derive(Debug, Clone, Serialize)]
70struct HealthCheck {
71    /// Name of the service checked
72    name: String,
73    /// Status: "healthy", "unhealthy", or "unknown"
74    status: String,
75    /// Duration of the check in milliseconds
76    duration_ms: u64,
77    /// Optional success message
78    message: Option<String>,
79    /// Optional error message if check failed
80    error: Option<String>,
81}
82
83/// Implementation of the health check tool
84///
85/// Handles the execution of health checks for the server and external services,
86/// including docs.rs and crates.io availability checks.
87pub struct HealthCheckToolImpl {
88    /// Server start time for uptime calculation
89    start_time: Instant,
90}
91
92impl HealthCheckToolImpl {
93    /// Creates a new health check tool instance
94    ///
95    /// Initializes the tool with the current time as the server start time
96    /// for uptime calculation purposes.
97    #[must_use]
98    pub fn new() -> Self {
99        Self {
100            start_time: Instant::now(),
101        }
102    }
103
104    #[allow(clippy::cast_possible_truncation)]
105    async fn check_http_service(
106        name: &'static str,
107        url: &str,
108        healthy_msg: &'static str,
109    ) -> HealthCheck {
110        let start = Instant::now();
111        // Use global HTTP client singleton for connection pool reuse
112        let client = match crate::utils::get_or_init_global_http_client() {
113            Ok(client) => client,
114            Err(e) => {
115                return HealthCheck {
116                    name: name.to_string(),
117                    status: "unhealthy".to_string(),
118                    duration_ms: start.elapsed().as_millis() as u64,
119                    message: None,
120                    error: Some(format!("Failed to initialize HTTP client: {e}")),
121                };
122            }
123        };
124
125        match client
126            .get(url)
127            .header("User-Agent", crate::user_agent())
128            .timeout(Duration::from_secs(5))
129            .send()
130            .await
131        {
132            Ok(response) => {
133                let duration = start.elapsed();
134                if response.status().is_success() {
135                    HealthCheck {
136                        name: name.to_string(),
137                        status: "healthy".to_string(),
138                        duration_ms: duration.as_millis() as u64,
139                        message: Some(healthy_msg.to_string()),
140                        error: None,
141                    }
142                } else {
143                    HealthCheck {
144                        name: name.to_string(),
145                        status: "unhealthy".to_string(),
146                        duration_ms: duration.as_millis() as u64,
147                        message: None,
148                        error: Some(format!("HTTP status code: {}", response.status())),
149                    }
150                }
151            }
152            Err(e) => {
153                let duration = start.elapsed();
154                HealthCheck {
155                    name: name.to_string(),
156                    status: "unhealthy".to_string(),
157                    duration_ms: duration.as_millis() as u64,
158                    message: None,
159                    error: Some(format!("Request failed: {e}")),
160                }
161            }
162        }
163    }
164
165    #[inline]
166    async fn check_docs_rs(&self) -> HealthCheck {
167        Self::check_http_service("docs.rs", "https://docs.rs/", "Service is healthy").await
168    }
169
170    #[inline]
171    async fn check_crates_io(&self) -> HealthCheck {
172        Self::check_http_service(
173            "crates.io",
174            "https://crates.io/api/v1/crates?q=serde&per_page=1",
175            "API is healthy",
176        )
177        .await
178    }
179
180    /// Check memory usage.
181    ///
182    /// On Linux this reports the process resident set size (RSS) read from
183    /// `/proc/self/statm` so the "internal" health check carries real, useful
184    /// information instead of a hard-coded "normal" verdict. On other platforms
185    /// it reports that the metric is unavailable rather than fabricating one.
186    fn check_memory() -> HealthCheck {
187        let message = Self::memory_message();
188        HealthCheck {
189            name: "memory".to_string(),
190            status: "healthy".to_string(),
191            duration_ms: 0,
192            message: Some(message),
193            error: None,
194        }
195    }
196
197    #[cfg(target_os = "linux")]
198    fn memory_message() -> String {
199        match Self::read_process_rss_bytes() {
200            Some(bytes) => {
201                // Integer math keeps this precise and avoids lossy float casts.
202                let mib = bytes / (1024 * 1024);
203                let frac = (bytes % (1024 * 1024)) * 10 / (1024 * 1024);
204                format!("Resident set size: {mib}.{frac} MiB")
205            }
206            None => "Memory metrics unavailable (could not read /proc/self/statm)".to_string(),
207        }
208    }
209
210    #[cfg(not(target_os = "linux"))]
211    fn memory_message() -> String {
212        "Memory metrics are not implemented on this platform".to_string()
213    }
214
215    /// Read the current process resident set size in bytes from `/proc`.
216    #[cfg(target_os = "linux")]
217    fn read_process_rss_bytes() -> Option<u64> {
218        let statm = std::fs::read_to_string("/proc/self/statm").ok()?;
219        // Field 2 (index 1) is the resident set size measured in memory pages.
220        let resident_pages: u64 = statm.split_whitespace().nth(1)?.parse().ok()?;
221        // SAFETY: `sysconf` is a pure libc query with no memory-safety impact.
222        let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
223        let page_size = u64::try_from(page_size).unwrap_or(4096);
224        Some(resident_pages.saturating_mul(page_size))
225    }
226
227    async fn perform_checks(&self, check_type: &str, verbose: bool) -> HealthStatus {
228        let checks = match check_type {
229            "all" => {
230                let (docs_rs, crates_io) =
231                    tokio::join!(self.check_docs_rs(), self.check_crates_io());
232                vec![docs_rs, crates_io, Self::check_memory()]
233            }
234            "external" => {
235                let (docs_rs, crates_io) =
236                    tokio::join!(self.check_docs_rs(), self.check_crates_io());
237                vec![docs_rs, crates_io]
238            }
239            "internal" => vec![Self::check_memory()],
240            "docs_rs" => vec![self.check_docs_rs().await],
241            "crates_io" => vec![self.check_crates_io().await],
242            _ => vec![HealthCheck {
243                name: "unknown_check".to_string(),
244                status: "unknown".to_string(),
245                duration_ms: 0,
246                message: None,
247                error: Some(format!("Unknown check type: {check_type}")),
248            }],
249        };
250
251        // Determine overall status
252        let overall_status = if checks.iter().all(|c| c.status == "healthy") {
253            "healthy".to_string()
254        } else if checks.iter().any(|c| c.status == "unhealthy") {
255            "unhealthy".to_string()
256        } else {
257            "degraded".to_string()
258        };
259
260        HealthStatus {
261            status: overall_status,
262            timestamp: chrono::Utc::now().to_rfc3339(),
263            checks: if verbose {
264                checks
265            } else {
266                // In non-verbose mode, only return checks with issues
267                checks
268                    .into_iter()
269                    .filter(|c| c.status != "healthy")
270                    .collect()
271            },
272            uptime: self.start_time.elapsed(),
273        }
274    }
275
276    /// Render a [`HealthStatus`] into a report string.
277    ///
278    /// In verbose mode this returns pretty-printed JSON; otherwise a concise
279    /// human-readable summary. This is shared by the MCP tool execution path
280    /// and the CLI `health` command so their output stays consistent.
281    fn render_report(health_status: &HealthStatus, verbose: bool) -> String {
282        if verbose {
283            serde_json::to_string_pretty(health_status)
284                .unwrap_or_else(|e| format!("JSON serialization failed: {e}"))
285        } else {
286            let mut summary = format!(
287                "Status: {}\nUptime: {:.2?}\nTimestamp: {}",
288                health_status.status, health_status.uptime, health_status.timestamp
289            );
290
291            if !health_status.checks.is_empty() {
292                use std::fmt::Write;
293                summary.push_str("\n\nCheck Results:");
294                for check in &health_status.checks {
295                    let _ = write!(
296                        summary,
297                        "\n- {}: {} ({:.2}ms)",
298                        check.name, check.status, check.duration_ms
299                    );
300                    if let Some(ref msg) = check.message {
301                        let _ = write!(summary, " - {msg}");
302                    }
303                    if let Some(ref err) = check.error {
304                        let _ = write!(summary, " [Error: {err}]");
305                    }
306                }
307            }
308
309            summary
310        }
311    }
312
313    /// Run a health check for CLI usage.
314    ///
315    /// Returns the rendered report and whether the overall status is healthy
316    /// (`true` only when every individual check is healthy). Callers can use the
317    /// boolean to set a process exit code so container/orchestrator health
318    /// probes behave correctly.
319    pub async fn run_check_report(&self, check_type: &str, verbose: bool) -> (String, bool) {
320        let health_status = self.perform_checks(check_type, verbose).await;
321        let is_healthy = health_status.status == "healthy";
322        (Self::render_report(&health_status, verbose), is_healthy)
323    }
324}
325
326#[async_trait]
327impl Tool for HealthCheckToolImpl {
328    fn definition(&self) -> rust_mcp_sdk::schema::Tool {
329        HealthCheckTool::tool()
330    }
331
332    async fn execute(
333        &self,
334        arguments: serde_json::Value,
335    ) -> std::result::Result<
336        rust_mcp_sdk::schema::CallToolResult,
337        rust_mcp_sdk::schema::CallToolError,
338    > {
339        let params: HealthCheckTool = serde_json::from_value(arguments).map_err(|e| {
340            rust_mcp_sdk::schema::CallToolError::invalid_arguments(
341                "health_check",
342                Some(format!("Parameter parsing failed: {e}")),
343            )
344        })?;
345
346        let check_type = params.check_type.unwrap_or_else(|| "all".to_string());
347        // Validate up front (fail-fast) like the other tools, rather than
348        // emitting a misleading "degraded" report with an "unknown_check" for a
349        // simple typo such as "al" instead of "all".
350        if !VALID_CHECK_TYPES.contains(&check_type.as_str()) {
351            return Err(rust_mcp_sdk::schema::CallToolError::invalid_arguments(
352                "health_check",
353                Some(format!(
354                    "Invalid check_type '{check_type}'. Expected one of: {}",
355                    VALID_CHECK_TYPES.join(", ")
356                )),
357            ));
358        }
359        let verbose = params.verbose.unwrap_or(false);
360
361        let health_status = self.perform_checks(&check_type, verbose).await;
362
363        let content = Self::render_report(&health_status, verbose);
364
365        Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
366            content.into(),
367        ]))
368    }
369}
370
371impl Default for HealthCheckToolImpl {
372    fn default() -> Self {
373        Self::new()
374    }
375}