From f64fed72f675f9a6cb7868ef5c82a154e6904f93 Mon Sep 17 00:00:00 2001 From: Alberto Gorni Date: Mon, 23 Feb 2026 15:52:08 +0100 Subject: [PATCH 1/5] feat: add MCP server mode (--mcp) for AI assistant integration Add Model Context Protocol (MCP) server that exposes all Analyzer CLI operations as structured tools over stdio JSON-RPC. This enables AI assistants like Claude to manage objects, create scans, browse paginated analysis results, check compliance, download reports/SBOMs, and more. - New src/mcp.rs with 20 MCP tools (objects, scans, results, config) - Add --mcp flag to main CLI, route to MCP server before subcommands - Add rmcp and schemars dependencies - Update README with MCP Server mode docs and Claude Code config - Bump version to 0.2.1 Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 4 +- README.md | 50 ++++ src/main.rs | 28 +- src/mcp.rs | 756 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 834 insertions(+), 4 deletions(-) create mode 100644 src/mcp.rs diff --git a/Cargo.toml b/Cargo.toml index bee6d43..ad8b419 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "analyzer-cli" -version = "0.2.0" +version = "0.2.1" edition = "2024" description = "CLI for Exein Analyzer - firmware and container security scanning" license = "Apache-2.0" @@ -36,6 +36,8 @@ url = { version = "2", features = ["serde"] } humantime = "2" bytes = "1" clap_complete = "4" +rmcp = { version = "0.12", features = ["server", "macros", "transport-io"] } +schemars = "1" [dev-dependencies] assert_cmd = "2" diff --git a/README.md b/README.md index 963e8ed..16c5d5f 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,56 @@ analyzer scan overview --object --format json | jq '.analyses' analyzer scan results --object --analysis cve --format json | jq '.findings' ``` +### MCP Server mode + +The CLI can run as an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server, allowing AI assistants like Claude to use Analyzer tools directly. + +```bash +# Start the MCP server (uses default profile) +analyzer --mcp + +# Use a specific profile +analyzer --mcp --profile staging + +# Override API key and URL via environment +ANALYZER_API_KEY=your-key ANALYZER_URL=https://my-instance.example.com/api/ analyzer --mcp +``` + +**Prerequisites:** You must have a valid configuration before starting the MCP server. Run `analyzer login` at least once, or ensure a profile is configured in `~/.config/analyzer/config.toml`. + +The MCP server exposes all CLI operations as structured tools: managing objects, creating scans, retrieving scores, browsing analysis results, checking compliance, downloading SBOMs and reports. It communicates over stdio using JSON-RPC, so stdout is reserved for the protocol — all logs go to stderr. + +#### Configure in Claude Code + +Add to your `~/.claude.json` (or project-level `.mcp.json`): + +```json +{ + "mcpServers": { + "analyzer": { + "command": "/path/to/analyzer", + "args": ["--mcp"] + } + } +} +``` + +To use a specific profile or API key: + +```json +{ + "mcpServers": { + "analyzer": { + "command": "/path/to/analyzer", + "args": ["--mcp", "--profile", "staging"], + "env": { + "ANALYZER_API_KEY": "your-key" + } + } + } +} +``` + ### Shell completions ```bash diff --git a/src/main.rs b/src/main.rs index 5f770d6..1da3ac5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod client; mod commands; mod config; +mod mcp; mod output; use std::path::PathBuf; @@ -31,9 +32,12 @@ use crate::output::Format; about, long_about = None, propagate_version = true, - arg_required_else_help = true, )] struct Cli { + /// Run as an MCP (Model Context Protocol) server over stdio. + #[arg(long)] + mcp: bool, + /// API key (overrides config file and ANALYZER_API_KEY env var). #[arg(long, global = true, env = "ANALYZER_API_KEY", hide_env_values = true)] api_key: Option, @@ -51,7 +55,7 @@ struct Cli { format: Format, #[command(subcommand)] - command: Command, + command: Option, } #[derive(Subcommand)] @@ -334,6 +338,24 @@ enum ScanCommand { async fn main() -> ExitCode { let cli = Cli::parse(); + if cli.mcp { + if let Err(e) = mcp::serve(cli.api_key, cli.url, cli.profile).await { + eprintln!("MCP server error: {e:#}"); + return ExitCode::FAILURE; + } + return ExitCode::SUCCESS; + } + + match cli.command { + Some(_) => {} + None => { + ::command() + .print_help() + .ok(); + return ExitCode::SUCCESS; + } + } + if let Err(e) = run(cli).await { output::error(&format!("{e:#}")); ExitCode::FAILURE @@ -349,7 +371,7 @@ async fn run(cli: Cli) -> Result<()> { let profile = cli.profile; let format = cli.format; - match cli.command { + match cli.command.expect("command is checked in main") { // -- Auth (no API key required) ----------------------------------- Command::Login { url: login_url, diff --git a/src/mcp.rs b/src/mcp.rs new file mode 100644 index 0000000..fa7ca46 --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,756 @@ +//! MCP (Model Context Protocol) server mode. +//! +//! When the CLI is invoked with `--mcp`, this module runs an MCP server over +//! stdio, exposing Analyzer operations as structured tools for AI assistants. + +use std::path::PathBuf; + +use anyhow::Result; +use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{ + CallToolResult, Content, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo, +}; +use rmcp::{ErrorData as McpError, ServerHandler, ServiceExt, tool, tool_handler, tool_router}; +use schemars::JsonSchema; +use serde::Deserialize; +use uuid::Uuid; + +use crate::client::AnalyzerClient; +use crate::client::models::{ComplianceType, CreateObject, ResultsQuery, ScanTypeRequest}; +use crate::config::ConfigFile; + +// =========================================================================== +// Parameter structs (serde + schemars for MCP tool schemas) +// =========================================================================== + +#[derive(Debug, Deserialize, JsonSchema)] +struct CreateObjectParams { + /// Name for the new object. + name: String, + /// Optional description. + description: Option, + /// Optional tags. + tags: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ObjectIdParam { + /// Object UUID. + object_id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct CreateScanParams { + /// Object UUID to scan against. + object_id: String, + /// Path to the firmware or container image file. + file_path: String, + /// Image type: "linux", "docker", or "idf". + scan_type: String, + /// Analysis types to run (e.g. ["cve", "software-bom"]). + /// If omitted, all available analyses for the scan type are run. + analyses: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ScanIdParam { + /// Scan UUID. + scan_id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct DownloadParams { + /// Scan UUID. + scan_id: String, + /// Output file path. If omitted, saves to ~/.cache/analyzer/downloads//. + output_path: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ComplianceDownloadParams { + /// Scan UUID. + scan_id: String, + /// Compliance standard: "cra" (Cyber Resilience Act). + compliance_type: String, + /// Output file path. If omitted, saves to ~/.cache/analyzer/downloads//. + output_path: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ComplianceParams { + /// Scan UUID. + scan_id: String, + /// Compliance standard: "cra" (Cyber Resilience Act). + compliance_type: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct AnalysisResultsParams { + /// Scan UUID. + scan_id: String, + /// Analysis type: cve, password-hash, malware, hardening, capabilities, crypto, + /// software-bom, kernel, info, symbols, tasks, stack-overflow. + analysis_type: String, + /// Page number (default: 1). + page: Option, + /// Results per page (default: 25). + per_page: Option, + /// Search / filter string. + search: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ConfigureProfileParams { + /// API key to save. + api_key: String, + /// Server URL (default: https://analyzer.exein.io/api/). + url: Option, + /// Profile name (default: "default"). + profile: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ConfigGetParams { + /// Config key to read: "url", "api-key", or "default-profile". + key: String, + /// Profile to read from. + profile: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ConfigSetParams { + /// Config key to set: "url", "api-key", or "default-profile". + key: String, + /// Value to set. + value: String, + /// Profile to modify. + profile: Option, +} + +// =========================================================================== +// MCP server +// =========================================================================== + +#[derive(Clone)] +pub struct AnalyzerMcp { + client: AnalyzerClient, + tool_router: ToolRouter, +} + +// --------------------------------------------------------------------------- +// Tools +// --------------------------------------------------------------------------- + +#[tool_router] +impl AnalyzerMcp { + fn new(client: AnalyzerClient) -> Self { + Self { + client, + tool_router: Self::tool_router(), + } + } + + // -- Object tools --------------------------------------------------------- + + #[tool(description = "List all objects (devices/products) in your Analyzer account. Returns JSON array with id, name, description, tags, score (current and previous Exein Rating), and last scan info.")] + async fn list_objects(&self) -> Result { + match self.client.list_objects().await { + Ok(page) => ok_json(&page.data), + Err(e) => ok_err(e), + } + } + + #[tool(description = "Create a new object (device / product).")] + async fn create_object( + &self, + Parameters(p): Parameters, + ) -> Result { + let req = CreateObject { + name: p.name, + description: p.description, + tags: p.tags.unwrap_or_default(), + }; + match self.client.create_object(&req).await { + Ok(obj) => ok_json(&obj), + Err(e) => ok_err(e), + } + } + + #[tool(description = "Delete an object by its UUID.")] + async fn delete_object( + &self, + Parameters(p): Parameters, + ) -> Result { + let id = parse_uuid(&p.object_id)?; + match self.client.delete_object(id).await { + Ok(()) => ok_text(format!("Deleted object {id}")), + Err(e) => ok_err(e), + } + } + + // -- Scan tools ----------------------------------------------------------- + + #[tool( + description = "Create a new firmware/container scan. Uploads the image file and starts analysis. Returns the scan UUID. Image types: 'linux' (firmware), 'docker' (containers), 'idf' (ESP-IDF). If analyses are omitted, all defaults for the scan type are run. After creation, poll get_scan_status until completion (typically 1-10 min)." + )] + async fn create_scan( + &self, + Parameters(p): Parameters, + ) -> Result { + let object_id = parse_uuid(&p.object_id)?; + let file_path = PathBuf::from(&p.file_path); + + if !file_path.exists() { + return ok_text(format!("Error: file not found: {}", p.file_path)); + } + + // Resolve analyses: if empty, use all defaults for this scan type. + let analyses = match p.analyses { + Some(a) if !a.is_empty() => a, + _ => { + let types = self.client.get_scan_types().await.map_err(|e| { + McpError::internal_error(format!("Failed to fetch scan types: {e:#}"), None) + })?; + match types.iter().find(|t| t.image_type == p.scan_type) { + Some(t) => t.analyses.iter().map(|a| a.analysis_type.clone()).collect(), + None => { + return ok_text(format!( + "Error: unknown scan type '{}'. Use get_scan_types to see available types.", + p.scan_type + )); + } + } + } + }; + + let req = ScanTypeRequest { + scan_type: p.scan_type, + analyses, + }; + + match self.client.create_scan(object_id, &file_path, &req).await { + Ok(resp) => ok_json(&serde_json::json!({ "scan_id": resp.id })), + Err(e) => ok_err(e), + } + } + + #[tool(description = "Get the current status of a scan and its individual analyses. Each analysis has a status: 'pending' (queued), 'in-progress' (running), 'success' (done), 'error' (failed), 'canceled'. The overall scan status reflects the aggregate. Poll this until all analyses reach a terminal state (success/error/canceled).")] + async fn get_scan_status( + &self, + Parameters(p): Parameters, + ) -> Result { + let id = parse_uuid(&p.scan_id)?; + match self.client.get_scan_status(id).await { + Ok(status) => ok_json(&status), + Err(e) => ok_err(e), + } + } + + #[tool(description = "Get the Exein Rating (security score) for a completed scan. Score is 0-100 where LOWER IS BETTER: 0 = no issues (best), 100 = worst. Returns overall score plus per-analysis breakdown (cve, hardening, kernel, malware, password-hash, capabilities). A score of 0 means clean/no issues found. Use this to identify which areas need improvement — higher scores indicate worse security posture.")] + async fn get_scan_score( + &self, + Parameters(p): Parameters, + ) -> Result { + let id = parse_uuid(&p.scan_id)?; + match self.client.get_scan_score(id).await { + Ok(score) => ok_json(&score), + Err(e) => ok_err(e), + } + } + + #[tool( + description = "List available scan types and their analysis options. Returns image types (linux, docker, idf) with available analyses. Each analysis shows whether it runs by default." + )] + async fn get_scan_types(&self) -> Result { + match self.client.get_scan_types().await { + Ok(types) => ok_json(&types), + Err(e) => ok_err(e), + } + } + + #[tool(description = "Get a scan overview — summary of all analyses with finding counts by severity. Shows CVE counts (critical/high/medium/low), malware detections, password issues, hardening issues, capabilities risk levels, crypto assets, SBOM component count, kernel configs. Use this for a quick assessment before drilling into specific analysis results.")] + async fn get_scan_overview( + &self, + Parameters(p): Parameters, + ) -> Result { + let id = parse_uuid(&p.scan_id)?; + match self.client.get_scan_overview(id).await { + Ok(overview) => ok_json(&overview), + Err(e) => ok_err(e), + } + } + + #[tool(description = "Browse paginated analysis results for a specific analysis type. Returns detailed findings: CVE entries with CVSS scores, malware detections, hardening flags per binary, capabilities with risk levels, crypto assets, SBOM components, kernel security features, etc. Supports pagination (page, per_page) and search filtering. Analysis types: cve, password-hash, malware, hardening, capabilities, crypto, software-bom, kernel, info, symbols, tasks, stack-overflow.")] + async fn get_analysis_results( + &self, + Parameters(p): Parameters, + ) -> Result { + let scan_id = parse_uuid(&p.scan_id)?; + + // Resolve the analysis type to its UUID + let scan = self.client.get_scan(scan_id).await.map_err(|e| { + McpError::internal_error(format!("Failed to fetch scan: {e:#}"), None) + })?; + + let analysis_id = scan + .analysis + .iter() + .find(|entry| entry.entry_type.analyses.iter().any(|a| a == &p.analysis_type)) + .map(|entry| entry.id); + + let analysis_id = match analysis_id { + Some(id) => id, + None => { + let available: Vec<_> = scan + .analysis + .iter() + .flat_map(|e| e.entry_type.analyses.iter()) + .collect(); + return ok_text(format!( + "Error: analysis type '{}' not found in scan. Available: {}", + p.analysis_type, + available.iter().map(|s| s.as_str()).collect::>().join(", ") + )); + } + }; + + let query = ResultsQuery { + page: p.page.unwrap_or(1), + per_page: p.per_page.unwrap_or(25), + sort_by: default_sort_by(&p.analysis_type).to_string(), + sort_ord: "asc".to_string(), + search: p.search, + }; + + match self.client.get_analysis_results(scan_id, analysis_id, &query).await { + Ok(results) => ok_json(&results), + Err(e) => ok_err(e), + } + } + + #[tool(description = "Get compliance check results for a scan. Returns structured compliance data with sections, requirements, and pass/fail/unknown status for each check. Supported compliance types: 'cra' (EU Cyber Resilience Act). The result includes total/passed/failed/unknown/not-applicable counts.")] + async fn get_compliance( + &self, + Parameters(p): Parameters, + ) -> Result { + let scan_id = parse_uuid(&p.scan_id)?; + let ct = parse_compliance_type(&p.compliance_type)?; + match self.client.get_compliance(scan_id, ct).await { + Ok(report) => ok_json(&report), + Err(e) => ok_err(e), + } + } + + #[tool(description = "Download the SBOM (Software Bill of Materials) in CycloneDX JSON format. Saves to disk and returns the full JSON inline. The SBOM lists all software components found in the image: name, version, type, purl (Package URL), and licenses. Use this to understand the software supply chain, identify outdated packages, or cross-reference with CVE results.")] + async fn download_sbom( + &self, + Parameters(p): Parameters, + ) -> Result { + let id = parse_uuid(&p.scan_id)?; + let path = match &p.output_path { + Some(p) => PathBuf::from(p), + None => downloads_path(&p.scan_id, "sbom.json"), + }; + match self.client.download_sbom(id).await { + Ok(bytes) => { + // Save to disk for the user + let save_msg = match save_to_path(&path, &bytes).await { + Ok(()) => format!("[Saved to {}]", path.display()), + Err(e) => format!("[Could not save to disk: {e}]"), + }; + // Return the JSON content inline so Claude can read it + let content = String::from_utf8_lossy(&bytes); + ok_text(format!("{save_msg}\n\n{content}")) + } + Err(e) => ok_err(e), + } + } + + #[tool(description = "Download the PDF security report for a completed scan. The report includes: Exein Rating, firmware details (OS, arch, kernel), executive summary with critical findings, CVE list by product and severity, binary hardening analysis, kernel security modules status, and remediation recommendations. Saves to disk (binary PDF) — returns the file path only.")] + async fn download_report( + &self, + Parameters(p): Parameters, + ) -> Result { + let id = parse_uuid(&p.scan_id)?; + let path = match &p.output_path { + Some(p) => PathBuf::from(p), + None => downloads_path(&p.scan_id, "report.pdf"), + }; + match self.client.download_report(id).await { + Ok(bytes) => match save_to_path(&path, &bytes).await { + Ok(()) => ok_text(format!("Report saved to {}", path.display())), + Err(e) => ok_text(format!("Error writing file: {e}")), + }, + Err(e) => ok_err(e), + } + } + + #[tool(description = "Download a compliance report PDF. Supported types: 'cra' (EU Cyber Resilience Act). Assesses firmware compliance with regulatory requirements. Saves to disk (binary PDF) — returns the file path only.")] + async fn download_compliance_report( + &self, + Parameters(p): Parameters, + ) -> Result { + let id = parse_uuid(&p.scan_id)?; + let ct = parse_compliance_type(&p.compliance_type)?; + let default_name = format!("{}_report.pdf", p.compliance_type); + let path = match &p.output_path { + Some(p) => PathBuf::from(p), + None => downloads_path(&p.scan_id, &default_name), + }; + match self.client.download_compliance_report(id, ct).await { + Ok(bytes) => match save_to_path(&path, &bytes).await { + Ok(()) => ok_text(format!("{} report saved to {}", ct.display_name(), path.display())), + Err(e) => ok_text(format!("Error writing file: {e}")), + }, + Err(e) => ok_err(e), + } + } + + #[tool(description = "Cancel a running scan.")] + async fn cancel_scan( + &self, + Parameters(p): Parameters, + ) -> Result { + let id = parse_uuid(&p.scan_id)?; + match self.client.cancel_scan(id).await { + Ok(()) => ok_text(format!("Cancelled scan {id}")), + Err(e) => ok_err(e), + } + } + + #[tool(description = "Delete a scan.")] + async fn delete_scan( + &self, + Parameters(p): Parameters, + ) -> Result { + let id = parse_uuid(&p.scan_id)?; + match self.client.delete_scan(id).await { + Ok(()) => ok_text(format!("Deleted scan {id}")), + Err(e) => ok_err(e), + } + } + + // -- Config tools --------------------------------------------------------- + + #[tool( + description = "Configure an Analyzer profile with an API key and optional URL. Validates the key against the server before saving." + )] + async fn configure_profile( + &self, + Parameters(p): Parameters, + ) -> Result { + let profile_name = p.profile.as_deref().unwrap_or("default"); + let url = p + .url + .unwrap_or_else(|| "https://analyzer.exein.io/api/".to_string()); + + // Validate the key + let parsed_url: url::Url = url.parse().map_err(|_| { + McpError::invalid_params(format!("Invalid URL: {url}"), None) + })?; + let client = AnalyzerClient::new(parsed_url, &p.api_key).map_err(|e| { + McpError::internal_error(format!("Failed to create client: {e:#}"), None) + })?; + + let validation = match client.health().await { + Ok(_) => "Key validated successfully.", + Err(_) => "Could not validate key (server may be unreachable). Saving anyway.", + }; + + // Save + let mut config = ConfigFile::load().unwrap_or_default(); + let profile = config.profile_mut(profile_name); + profile.api_key = Some(p.api_key); + profile.url = Some(url.clone()); + config.save().map_err(|e| { + McpError::internal_error(format!("Failed to save config: {e:#}"), None) + })?; + + ok_text(format!( + "{validation}\nProfile '{profile_name}' saved (URL: {url})." + )) + } + + #[tool(description = "Get a configuration value. Valid keys: url, api-key, default-profile.")] + async fn config_get( + &self, + Parameters(p): Parameters, + ) -> Result { + let config = ConfigFile::load().unwrap_or_default(); + let profile_name = p.profile.as_deref().unwrap_or(&config.default_profile); + let prof = config.profile(Some(profile_name)); + + let value = match p.key.as_str() { + "url" => prof.url.as_deref().unwrap_or("(not set)").to_string(), + "api-key" | "api_key" => { + if prof.api_key.is_some() { + "(set)".to_string() + } else { + "(not set)".to_string() + } + } + "default-profile" | "default_profile" => config.default_profile.clone(), + other => { + return ok_text(format!( + "Unknown config key: {other}. Valid keys: url, api-key, default-profile" + )); + } + }; + + ok_text(format!("{} = {}", p.key, value)) + } + + #[tool(description = "Set a configuration value. Valid keys: url, api-key, default-profile.")] + async fn config_set( + &self, + Parameters(p): Parameters, + ) -> Result { + let mut config = ConfigFile::load().unwrap_or_default(); + let profile_name = p.profile.as_deref().unwrap_or("default"); + let prof = config.profile_mut(profile_name); + + match p.key.as_str() { + "url" => { + let _: url::Url = p.value.parse().map_err(|_| { + McpError::invalid_params(format!("Invalid URL: {}", p.value), None) + })?; + prof.url = Some(p.value.clone()); + } + "api-key" | "api_key" => { + prof.api_key = Some(p.value.clone()); + } + "default-profile" | "default_profile" => { + config.default_profile = p.value.clone(); + } + other => { + return ok_text(format!( + "Unknown config key: {other}. Valid keys: url, api-key, default-profile" + )); + } + } + + config.save().map_err(|e| { + McpError::internal_error(format!("Failed to save config: {e:#}"), None) + })?; + + ok_text(format!( + "Set {} = {} (profile: {profile_name})", + p.key, p.value + )) + } + + #[tool(description = "Show the currently resolved configuration: active profile name, Analyzer API URL, and masked API key. Useful to verify which account and server you are connected to.")] + async fn whoami(&self) -> Result { + let config = ConfigFile::load().unwrap_or_default(); + let profile_name = std::env::var("ANALYZER_PROFILE") + .unwrap_or_else(|_| config.default_profile.clone()); + let prof = config.profile(Some(&profile_name)); + + let url = std::env::var("ANALYZER_URL") + .ok() + .or_else(|| prof.url.clone()) + .unwrap_or_else(|| "https://analyzer.exein.io/api/".to_string()); + + let key = std::env::var("ANALYZER_API_KEY") + .ok() + .or_else(|| prof.api_key.clone()); + + let masked_key = match &key { + Some(k) if k.len() > 8 => format!("{}...{}", &k[..4], &k[k.len() - 4..]), + Some(k) => format!("{}...", &k[..k.len().min(4)]), + None => "(not set)".to_string(), + }; + + ok_text(format!( + "Profile: {profile_name}\nURL: {url}\nAPI Key: {masked_key}" + )) + } +} + +// --------------------------------------------------------------------------- +// ServerHandler +// --------------------------------------------------------------------------- + +#[tool_handler] +impl ServerHandler for AnalyzerMcp { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::V_2024_11_05, + capabilities: ServerCapabilities::builder().enable_tools().build(), + server_info: Implementation::from_build_env(), + instructions: Some( + "Exein Analyzer MCP server — scan firmware and container images for \ + vulnerabilities, generate SBOMs, and check compliance.\n\ + \n\ + ## Quick Start\n\ + 1. Call `list_objects` to see existing objects (devices/products).\n\ + 2. Call `get_scan_types` to discover available image types and analyses.\n\ + 3. Create an object with `create_object` if needed.\n\ + 4. Upload and scan with `create_scan` (provide object_id, file path, scan type).\n\ + 5. Poll `get_scan_status` until all analyses reach 'success' (or 'error').\n\ + 6. Use `get_scan_overview` for a quick summary of all findings.\n\ + 7. Drill down with `get_analysis_results` for specific analysis types.\n\ + 8. Retrieve scores and downloads: `get_scan_score`, `download_sbom`, \ + `download_report`, `download_compliance_report`.\n\ + 9. Check compliance with `get_compliance` (e.g. CRA).\n\ + \n\ + ## Image Types\n\ + - **linux**: Linux firmware images (e.g. OpenWrt, Yocto, Buildroot). Supports all analyses.\n\ + - **docker**: Docker/OCI container images.\n\ + - **idf**: ESP-IDF firmware images (Espressif IoT Development Framework). \ + Supports a subset of analyses (info, cve, software-bom). \ + Hardening and kernel-security checks are not applicable to bare-metal RTOS targets.\n\ + \n\ + ## Analysis Types\n\ + - **info**: Extracts firmware metadata — OS, architecture, kernel version.\n\ + - **cve**: CVE vulnerability scan powered by Kepler (Exein open-source tool using NIST NVD). \ + Finds known vulnerabilities in software components. Results are grouped by product with \ + severity breakdown: Critical, High, Medium, Low.\n\ + - **software-bom**: Generates the Software Bill of Materials (SBOM) in CycloneDX JSON format. \ + Lists all software components, versions, and licenses found in the image.\n\ + - **malware**: Scans the filesystem for known malicious files (malware, trojans, etc.).\n\ + - **crypto**: Cryptographic analysis — identifies certificates, public/private keys.\n\ + - **hardening**: Binary hardening checks — verifies compiler security flags for each executable: \ + Stack Canary, NX (non-executable stack), PIE (position-independent), RELRO (relocation read-only), \ + Fortify Source. Reports weak binaries count.\n\ + - **password-hash**: Detects hard-coded weak passwords in the firmware filesystem.\n\ + - **kernel**: Checks kernel security modules: SECCOMP, SELINUX, APPARMOR, KASLR, \ + STACKPROTECTOR, FORTIFYSOURCE, etc. Reports enabled/not-enabled status.\n\ + - **capabilities**: Analyzes executable capabilities and syscalls, assigning risk levels.\n\ + - **symbols** (IDF only): Lists symbols from ESP-IDF firmware.\n\ + - **tasks** (IDF only): Lists RTOS tasks.\n\ + - **stack-overflow** (IDF only): Stack overflow detection method.\n\ + \n\ + ## Exein Rating (Security Score)\n\ + - Score is 0-100, where **lower is better** (0 = best, 100 = worst).\n\ + - 0: Perfect — no issues found in this category.\n\ + - 1-30: Good security posture.\n\ + - 31-59: Mediocre — address higher-risk vulnerabilities.\n\ + - 60-100: Poor — critical security issues require immediate attention.\n\ + - The overall score is a weighted aggregate of individual analysis scores.\n\ + - Per-analysis scores: malware=0 means clean (no malware), cve=100 means \ + severe vulnerability exposure, hardening=50 means partial compiler protections, etc.\n\ + - IMPORTANT: Do NOT interpret score 0 as 'bad'. Score 0 means the best possible result \ + (no issues detected). Score 100 is the worst.\n\ + \n\ + ## Browsing Results\n\ + - Use `get_scan_overview` first for a high-level summary of all analyses.\n\ + - Then use `get_analysis_results` with a specific analysis_type to browse detailed findings.\n\ + - Results are paginated (default 25 per page). Use page/per_page params to navigate.\n\ + - Use the search param to filter results (e.g. search='openssl' for CVEs).\n\ + \n\ + ## Compliance\n\ + - `get_compliance` returns structured compliance check results (pass/fail per requirement).\n\ + - `download_compliance_report` downloads the full PDF compliance report.\n\ + - Supported standard: 'cra' (EU Cyber Resilience Act).\n\ + \n\ + ## Scan Status\n\ + - Each analysis within a scan has its own status: pending → in-progress → success | error | canceled.\n\ + - The overall scan status reflects the aggregate of all analyses.\n\ + - Scans typically take 1-10 minutes depending on image size and analyses requested.\n\ + \n\ + ## Downloaded Files\n\ + - PDF reports and SBOMs are saved to `~/.cache/analyzer/downloads//` by default.\n\ + - The SBOM (download_sbom) is also returned inline as JSON so you can analyze it directly.\n\ + - PDF reports (download_report, download_compliance_report) are binary files saved to disk — \ + the tool returns only the file path. Use a filesystem MCP server to access them if needed.\n\ + \n\ + ## SBOM Format\n\ + - Format: CycloneDX JSON (ECMA-424, 1st edition June 2024 / CycloneDX 1.6).\n\ + - Also compatible with: SPDX 3.0.1 (on request via download_sbom parameters).\n\ + - Key fields: `components[]` array with `name`, `version`, `type`, `purl` (Package URL), `licenses`.\n\ + - Use the SBOM to understand the full software supply chain of the scanned image." + .into(), + ), + } + } +} + +// =========================================================================== +// Entry point +// =========================================================================== + +/// Start the MCP server over stdio. +pub async fn serve( + api_key: Option, + url: Option, + profile: Option, +) -> Result<()> { + let cfg = crate::config::resolve(api_key.as_deref(), url.as_deref(), profile.as_deref())?; + let client = AnalyzerClient::new(cfg.url, &cfg.api_key)?; + let server = AnalyzerMcp::new(client); + + let service = server + .serve((tokio::io::stdin(), tokio::io::stdout())) + .await?; + service.waiting().await?; + + Ok(()) +} + +// =========================================================================== +// Helpers +// =========================================================================== + +fn parse_uuid(s: &str) -> Result { + s.parse::() + .map_err(|_| McpError::invalid_params(format!("Invalid UUID: {s}"), None)) +} + +fn parse_compliance_type(s: &str) -> Result { + match s.to_lowercase().as_str() { + "cra" => Ok(ComplianceType::Cra), + other => Err(McpError::invalid_params( + format!("Unknown compliance type: '{other}'. Supported: cra"), + None, + )), + } +} + +/// Default sort-by field for a given analysis type API name. +fn default_sort_by(analysis_type: &str) -> &'static str { + match analysis_type { + "cve" | "password-hash" | "hardening" | "capabilities" => "severity", + "malware" => "filename", + "crypto" => "type", + "software-bom" | "info" | "symbols" | "stack-overflow" => "name", + "kernel" => "features", + "tasks" => "function", + _ => "name", + } +} + +fn ok_json(value: &T) -> Result { + let json = serde_json::to_string_pretty(value) + .map_err(|e| McpError::internal_error(format!("JSON serialization error: {e}"), None))?; + Ok(CallToolResult::success(vec![Content::text(json)])) +} + +fn ok_text(msg: String) -> Result { + Ok(CallToolResult::success(vec![Content::text(msg)])) +} + +fn ok_err(e: anyhow::Error) -> Result { + Ok(CallToolResult::error(vec![Content::text(format!( + "Error: {e:#}" + ))])) +} + +/// Build a path like `~/.cache/analyzer/downloads//`. +fn downloads_path(scan_id: &str, filename: &str) -> PathBuf { + let base = dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("analyzer") + .join("downloads") + .join(scan_id); + base.join(filename) +} + +/// Create parent directories and write bytes to a file. +async fn save_to_path(path: &PathBuf, bytes: &[u8]) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, bytes).await +} From 68580785316b019d23665c4bf68aba3ef0d83559 Mon Sep 17 00:00:00 2001 From: giovanni alberto falcione Date: Fri, 6 Mar 2026 09:56:42 +0100 Subject: [PATCH 2/5] refactor: deduplicate MCP server, add object_id support, remove config tools - Extract shared AnalysisType::from_api_name() and ComplianceType::from_name() so MCP reuses enum parsing instead of duplicating match arms - Make resolve_analysis_id public, reuse from MCP instead of reimplementing - All scan tools now accept object_id as alternative to scan_id, matching the CLI's --object flag and aligning with ELI's natural interaction model - Remove configure_profile/config_get/config_set (config mutation is out of scope for AI agents); keep whoami for diagnostics - Add [Read]/[Write]/[Critical] classification to tool descriptions, preparing for ELI's Permission Gate - Fix &PathBuf -> &Path (Clippy), trim instructions block Net -145 lines from mcp.rs. --- src/client/models.rs | 27 +++ src/commands/scan.rs | 2 +- src/mcp.rs | 422 +++++++++++++------------------------------ 3 files changed, 153 insertions(+), 298 deletions(-) diff --git a/src/client/models.rs b/src/client/models.rs index 6ab9531..1b3306d 100644 --- a/src/client/models.rs +++ b/src/client/models.rs @@ -605,6 +605,14 @@ impl ComplianceType { Self::Cra => "Cyber Resilience Act", } } + + /// Parse from a user-provided string (e.g. "cra"). + pub fn from_name(name: &str) -> Option { + match name.to_lowercase().as_str() { + "cra" => Some(Self::Cra), + _ => None, + } + } } // === Analysis Type enum (for CLI) === @@ -644,6 +652,25 @@ impl AnalysisType { } } + /// Parse from the API name string (e.g. "cve", "software-bom"). + pub fn from_api_name(name: &str) -> Option { + match name { + "cve" => Some(Self::Cve), + "password-hash" => Some(Self::PasswordHash), + "malware" => Some(Self::Malware), + "hardening" => Some(Self::Hardening), + "capabilities" => Some(Self::Capabilities), + "crypto" => Some(Self::Crypto), + "software-bom" => Some(Self::SoftwareBom), + "kernel" => Some(Self::Kernel), + "info" => Some(Self::Info), + "symbols" => Some(Self::Symbols), + "tasks" => Some(Self::Tasks), + "stack-overflow" => Some(Self::StackOverflow), + _ => None, + } + } + /// Default sort-by field for this analysis type. pub fn default_sort_by(&self) -> &'static str { match self { diff --git a/src/commands/scan.rs b/src/commands/scan.rs index d5a71a9..d41f275 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -474,7 +474,7 @@ pub async fn run_overview(client: &AnalyzerClient, scan_id: Uuid, format: Format // =========================================================================== /// Resolve an analysis type name to its UUID by fetching the scan metadata. -async fn resolve_analysis_id( +pub async fn resolve_analysis_id( client: &AnalyzerClient, scan_id: Uuid, analysis_type: &AnalysisType, diff --git a/src/mcp.rs b/src/mcp.rs index fa7ca46..257f16c 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -3,7 +3,7 @@ //! When the CLI is invoked with `--mcp`, this module runs an MCP server over //! stdio, exposing Analyzer operations as structured tools for AI assistants. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::Result; use rmcp::handler::server::router::tool::ToolRouter; @@ -17,7 +17,7 @@ use serde::Deserialize; use uuid::Uuid; use crate::client::AnalyzerClient; -use crate::client::models::{ComplianceType, CreateObject, ResultsQuery, ScanTypeRequest}; +use crate::client::models::{AnalysisType, ComplianceType, CreateObject, ResultsQuery, ScanTypeRequest}; use crate::config::ConfigFile; // =========================================================================== @@ -53,24 +53,32 @@ struct CreateScanParams { analyses: Option>, } +/// Identifies a scan — either by scan UUID directly, or by object UUID +/// (which resolves to the object's most recent scan). #[derive(Debug, Deserialize, JsonSchema)] -struct ScanIdParam { - /// Scan UUID. - scan_id: String, +struct ScanOrObjectParam { + /// Scan UUID. Provide either scan_id or object_id. + scan_id: Option, + /// Object UUID — resolves to the object's most recent scan. + object_id: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct DownloadParams { - /// Scan UUID. - scan_id: String, + /// Scan UUID. Provide either scan_id or object_id. + scan_id: Option, + /// Object UUID — resolves to the object's most recent scan. + object_id: Option, /// Output file path. If omitted, saves to ~/.cache/analyzer/downloads//. output_path: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct ComplianceDownloadParams { - /// Scan UUID. - scan_id: String, + /// Scan UUID. Provide either scan_id or object_id. + scan_id: Option, + /// Object UUID — resolves to the object's most recent scan. + object_id: Option, /// Compliance standard: "cra" (Cyber Resilience Act). compliance_type: String, /// Output file path. If omitted, saves to ~/.cache/analyzer/downloads//. @@ -79,16 +87,20 @@ struct ComplianceDownloadParams { #[derive(Debug, Deserialize, JsonSchema)] struct ComplianceParams { - /// Scan UUID. - scan_id: String, + /// Scan UUID. Provide either scan_id or object_id. + scan_id: Option, + /// Object UUID — resolves to the object's most recent scan. + object_id: Option, /// Compliance standard: "cra" (Cyber Resilience Act). compliance_type: String, } #[derive(Debug, Deserialize, JsonSchema)] struct AnalysisResultsParams { - /// Scan UUID. - scan_id: String, + /// Scan UUID. Provide either scan_id or object_id. + scan_id: Option, + /// Object UUID — resolves to the object's most recent scan. + object_id: Option, /// Analysis type: cve, password-hash, malware, hardening, capabilities, crypto, /// software-bom, kernel, info, symbols, tasks, stack-overflow. analysis_type: String, @@ -100,34 +112,6 @@ struct AnalysisResultsParams { search: Option, } -#[derive(Debug, Deserialize, JsonSchema)] -struct ConfigureProfileParams { - /// API key to save. - api_key: String, - /// Server URL (default: https://analyzer.exein.io/api/). - url: Option, - /// Profile name (default: "default"). - profile: Option, -} - -#[derive(Debug, Deserialize, JsonSchema)] -struct ConfigGetParams { - /// Config key to read: "url", "api-key", or "default-profile". - key: String, - /// Profile to read from. - profile: Option, -} - -#[derive(Debug, Deserialize, JsonSchema)] -struct ConfigSetParams { - /// Config key to set: "url", "api-key", or "default-profile". - key: String, - /// Value to set. - value: String, - /// Profile to modify. - profile: Option, -} - // =========================================================================== // MCP server // =========================================================================== @@ -153,7 +137,7 @@ impl AnalyzerMcp { // -- Object tools --------------------------------------------------------- - #[tool(description = "List all objects (devices/products) in your Analyzer account. Returns JSON array with id, name, description, tags, score (current and previous Exein Rating), and last scan info.")] + #[tool(description = "[Read] List all objects (devices/products) in your Analyzer account. Returns JSON array with id, name, description, tags, score (current and previous Exein Rating), and last scan info.")] async fn list_objects(&self) -> Result { match self.client.list_objects().await { Ok(page) => ok_json(&page.data), @@ -161,7 +145,7 @@ impl AnalyzerMcp { } } - #[tool(description = "Create a new object (device / product).")] + #[tool(description = "[Write] Create a new object (device / product).")] async fn create_object( &self, Parameters(p): Parameters, @@ -177,7 +161,7 @@ impl AnalyzerMcp { } } - #[tool(description = "Delete an object by its UUID.")] + #[tool(description = "[Critical] Delete an object by its UUID. This permanently removes the object and all associated scans.")] async fn delete_object( &self, Parameters(p): Parameters, @@ -192,7 +176,7 @@ impl AnalyzerMcp { // -- Scan tools ----------------------------------------------------------- #[tool( - description = "Create a new firmware/container scan. Uploads the image file and starts analysis. Returns the scan UUID. Image types: 'linux' (firmware), 'docker' (containers), 'idf' (ESP-IDF). If analyses are omitted, all defaults for the scan type are run. After creation, poll get_scan_status until completion (typically 1-10 min)." + description = "[Write] Create a new firmware/container scan. Uploads the image file and starts analysis. Returns the scan UUID. Image types: 'linux' (firmware), 'docker' (containers), 'idf' (ESP-IDF). If analyses are omitted, all defaults for the scan type are run. After creation, poll get_scan_status until completion (typically 1-10 min)." )] async fn create_scan( &self, @@ -235,24 +219,24 @@ impl AnalyzerMcp { } } - #[tool(description = "Get the current status of a scan and its individual analyses. Each analysis has a status: 'pending' (queued), 'in-progress' (running), 'success' (done), 'error' (failed), 'canceled'. The overall scan status reflects the aggregate. Poll this until all analyses reach a terminal state (success/error/canceled).")] + #[tool(description = "[Read] Get the current status of a scan and its individual analyses. Each analysis has a status: 'pending' (queued), 'in-progress' (running), 'success' (done), 'error' (failed), 'canceled'. The overall scan status reflects the aggregate. Poll this until all analyses reach a terminal state (success/error/canceled). Accepts scan_id or object_id (resolves to most recent scan).")] async fn get_scan_status( &self, - Parameters(p): Parameters, + Parameters(p): Parameters, ) -> Result { - let id = parse_uuid(&p.scan_id)?; + let id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; match self.client.get_scan_status(id).await { Ok(status) => ok_json(&status), Err(e) => ok_err(e), } } - #[tool(description = "Get the Exein Rating (security score) for a completed scan. Score is 0-100 where LOWER IS BETTER: 0 = no issues (best), 100 = worst. Returns overall score plus per-analysis breakdown (cve, hardening, kernel, malware, password-hash, capabilities). A score of 0 means clean/no issues found. Use this to identify which areas need improvement — higher scores indicate worse security posture.")] + #[tool(description = "[Read] Get the Exein Rating (security score) for a completed scan. Score is 0-100 where LOWER IS BETTER: 0 = no issues (best), 100 = worst. Returns overall score plus per-analysis breakdown (cve, hardening, kernel, malware, password-hash, capabilities). A score of 0 means clean/no issues found. Accepts scan_id or object_id.")] async fn get_scan_score( &self, - Parameters(p): Parameters, + Parameters(p): Parameters, ) -> Result { - let id = parse_uuid(&p.scan_id)?; + let id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; match self.client.get_scan_score(id).await { Ok(score) => ok_json(&score), Err(e) => ok_err(e), @@ -260,7 +244,7 @@ impl AnalyzerMcp { } #[tool( - description = "List available scan types and their analysis options. Returns image types (linux, docker, idf) with available analyses. Each analysis shows whether it runs by default." + description = "[Read] List available scan types and their analysis options. Returns image types (linux, docker, idf) with available analyses. Each analysis shows whether it runs by default." )] async fn get_scan_types(&self) -> Result { match self.client.get_scan_types().await { @@ -269,56 +253,43 @@ impl AnalyzerMcp { } } - #[tool(description = "Get a scan overview — summary of all analyses with finding counts by severity. Shows CVE counts (critical/high/medium/low), malware detections, password issues, hardening issues, capabilities risk levels, crypto assets, SBOM component count, kernel configs. Use this for a quick assessment before drilling into specific analysis results.")] + #[tool(description = "[Read] Get a scan overview — summary of all analyses with finding counts by severity. Shows CVE counts (critical/high/medium/low), malware detections, password issues, hardening issues, capabilities risk levels, crypto assets, SBOM component count, kernel configs. Use this for a quick assessment before drilling into specific analysis results. Accepts scan_id or object_id.")] async fn get_scan_overview( &self, - Parameters(p): Parameters, + Parameters(p): Parameters, ) -> Result { - let id = parse_uuid(&p.scan_id)?; + let id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; match self.client.get_scan_overview(id).await { Ok(overview) => ok_json(&overview), Err(e) => ok_err(e), } } - #[tool(description = "Browse paginated analysis results for a specific analysis type. Returns detailed findings: CVE entries with CVSS scores, malware detections, hardening flags per binary, capabilities with risk levels, crypto assets, SBOM components, kernel security features, etc. Supports pagination (page, per_page) and search filtering. Analysis types: cve, password-hash, malware, hardening, capabilities, crypto, software-bom, kernel, info, symbols, tasks, stack-overflow.")] + #[tool(description = "[Read] Browse paginated analysis results for a specific analysis type. Returns detailed findings: CVE entries with CVSS scores, malware detections, hardening flags per binary, capabilities with risk levels, crypto assets, SBOM components, kernel security features, etc. Supports pagination (page, per_page) and search filtering. Analysis types: cve, password-hash, malware, hardening, capabilities, crypto, software-bom, kernel, info, symbols, tasks, stack-overflow. Accepts scan_id or object_id.")] async fn get_analysis_results( &self, Parameters(p): Parameters, ) -> Result { - let scan_id = parse_uuid(&p.scan_id)?; - - // Resolve the analysis type to its UUID - let scan = self.client.get_scan(scan_id).await.map_err(|e| { - McpError::internal_error(format!("Failed to fetch scan: {e:#}"), None) + let scan_id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; + + let analysis_type = AnalysisType::from_api_name(&p.analysis_type).ok_or_else(|| { + McpError::invalid_params( + format!( + "Unknown analysis type: '{}'. Valid types: cve, password-hash, malware, hardening, capabilities, crypto, software-bom, kernel, info, symbols, tasks, stack-overflow", + p.analysis_type + ), + None, + ) })?; - let analysis_id = scan - .analysis - .iter() - .find(|entry| entry.entry_type.analyses.iter().any(|a| a == &p.analysis_type)) - .map(|entry| entry.id); - - let analysis_id = match analysis_id { - Some(id) => id, - None => { - let available: Vec<_> = scan - .analysis - .iter() - .flat_map(|e| e.entry_type.analyses.iter()) - .collect(); - return ok_text(format!( - "Error: analysis type '{}' not found in scan. Available: {}", - p.analysis_type, - available.iter().map(|s| s.as_str()).collect::>().join(", ") - )); - } - }; + let analysis_id = crate::commands::scan::resolve_analysis_id(&self.client, scan_id, &analysis_type) + .await + .map_err(|e| McpError::invalid_params(format!("{e:#}"), None))?; let query = ResultsQuery { page: p.page.unwrap_or(1), per_page: p.per_page.unwrap_or(25), - sort_by: default_sort_by(&p.analysis_type).to_string(), + sort_by: analysis_type.default_sort_by().to_string(), sort_ord: "asc".to_string(), search: p.search, }; @@ -329,12 +300,12 @@ impl AnalyzerMcp { } } - #[tool(description = "Get compliance check results for a scan. Returns structured compliance data with sections, requirements, and pass/fail/unknown status for each check. Supported compliance types: 'cra' (EU Cyber Resilience Act). The result includes total/passed/failed/unknown/not-applicable counts.")] + #[tool(description = "[Read] Get compliance check results for a scan. Returns structured compliance data with sections, requirements, and pass/fail/unknown status for each check. Supported compliance types: 'cra' (EU Cyber Resilience Act). The result includes total/passed/failed/unknown/not-applicable counts. Accepts scan_id or object_id.")] async fn get_compliance( &self, Parameters(p): Parameters, ) -> Result { - let scan_id = parse_uuid(&p.scan_id)?; + let scan_id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; let ct = parse_compliance_type(&p.compliance_type)?; match self.client.get_compliance(scan_id, ct).await { Ok(report) => ok_json(&report), @@ -342,24 +313,23 @@ impl AnalyzerMcp { } } - #[tool(description = "Download the SBOM (Software Bill of Materials) in CycloneDX JSON format. Saves to disk and returns the full JSON inline. The SBOM lists all software components found in the image: name, version, type, purl (Package URL), and licenses. Use this to understand the software supply chain, identify outdated packages, or cross-reference with CVE results.")] + #[tool(description = "[Read] Download the SBOM (Software Bill of Materials) in CycloneDX JSON format. Saves to disk and returns the full JSON inline. The SBOM lists all software components found in the image: name, version, type, purl (Package URL), and licenses. Use this to understand the software supply chain, identify outdated packages, or cross-reference with CVE results. Accepts scan_id or object_id.")] async fn download_sbom( &self, Parameters(p): Parameters, ) -> Result { - let id = parse_uuid(&p.scan_id)?; + let id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; let path = match &p.output_path { Some(p) => PathBuf::from(p), - None => downloads_path(&p.scan_id, "sbom.json"), + None => downloads_path(&id.to_string(), "sbom.json"), }; match self.client.download_sbom(id).await { Ok(bytes) => { - // Save to disk for the user let save_msg = match save_to_path(&path, &bytes).await { Ok(()) => format!("[Saved to {}]", path.display()), Err(e) => format!("[Could not save to disk: {e}]"), }; - // Return the JSON content inline so Claude can read it + // Return the JSON content inline so the AI can read it let content = String::from_utf8_lossy(&bytes); ok_text(format!("{save_msg}\n\n{content}")) } @@ -367,15 +337,15 @@ impl AnalyzerMcp { } } - #[tool(description = "Download the PDF security report for a completed scan. The report includes: Exein Rating, firmware details (OS, arch, kernel), executive summary with critical findings, CVE list by product and severity, binary hardening analysis, kernel security modules status, and remediation recommendations. Saves to disk (binary PDF) — returns the file path only.")] + #[tool(description = "[Read] Download the PDF security report for a completed scan. The report includes: Exein Rating, firmware details (OS, arch, kernel), executive summary with critical findings, CVE list by product and severity, binary hardening analysis, kernel security modules status, and remediation recommendations. Saves to disk (binary PDF) — returns the file path only. Accepts scan_id or object_id.")] async fn download_report( &self, Parameters(p): Parameters, ) -> Result { - let id = parse_uuid(&p.scan_id)?; + let id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; let path = match &p.output_path { Some(p) => PathBuf::from(p), - None => downloads_path(&p.scan_id, "report.pdf"), + None => downloads_path(&id.to_string(), "report.pdf"), }; match self.client.download_report(id).await { Ok(bytes) => match save_to_path(&path, &bytes).await { @@ -386,17 +356,17 @@ impl AnalyzerMcp { } } - #[tool(description = "Download a compliance report PDF. Supported types: 'cra' (EU Cyber Resilience Act). Assesses firmware compliance with regulatory requirements. Saves to disk (binary PDF) — returns the file path only.")] + #[tool(description = "[Read] Download a compliance report PDF. Supported types: 'cra' (EU Cyber Resilience Act). Assesses firmware compliance with regulatory requirements. Saves to disk (binary PDF) — returns the file path only. Accepts scan_id or object_id.")] async fn download_compliance_report( &self, Parameters(p): Parameters, ) -> Result { - let id = parse_uuid(&p.scan_id)?; + let id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; let ct = parse_compliance_type(&p.compliance_type)?; let default_name = format!("{}_report.pdf", p.compliance_type); let path = match &p.output_path { Some(p) => PathBuf::from(p), - None => downloads_path(&p.scan_id, &default_name), + None => downloads_path(&id.to_string(), &default_name), }; match self.client.download_compliance_report(id, ct).await { Ok(bytes) => match save_to_path(&path, &bytes).await { @@ -407,140 +377,33 @@ impl AnalyzerMcp { } } - #[tool(description = "Cancel a running scan.")] + #[tool(description = "[Write] Cancel a running scan. Accepts scan_id or object_id.")] async fn cancel_scan( &self, - Parameters(p): Parameters, + Parameters(p): Parameters, ) -> Result { - let id = parse_uuid(&p.scan_id)?; + let id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; match self.client.cancel_scan(id).await { Ok(()) => ok_text(format!("Cancelled scan {id}")), Err(e) => ok_err(e), } } - #[tool(description = "Delete a scan.")] + #[tool(description = "[Critical] Delete a scan permanently. Accepts scan_id or object_id.")] async fn delete_scan( &self, - Parameters(p): Parameters, + Parameters(p): Parameters, ) -> Result { - let id = parse_uuid(&p.scan_id)?; + let id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; match self.client.delete_scan(id).await { Ok(()) => ok_text(format!("Deleted scan {id}")), Err(e) => ok_err(e), } } - // -- Config tools --------------------------------------------------------- - - #[tool( - description = "Configure an Analyzer profile with an API key and optional URL. Validates the key against the server before saving." - )] - async fn configure_profile( - &self, - Parameters(p): Parameters, - ) -> Result { - let profile_name = p.profile.as_deref().unwrap_or("default"); - let url = p - .url - .unwrap_or_else(|| "https://analyzer.exein.io/api/".to_string()); - - // Validate the key - let parsed_url: url::Url = url.parse().map_err(|_| { - McpError::invalid_params(format!("Invalid URL: {url}"), None) - })?; - let client = AnalyzerClient::new(parsed_url, &p.api_key).map_err(|e| { - McpError::internal_error(format!("Failed to create client: {e:#}"), None) - })?; - - let validation = match client.health().await { - Ok(_) => "Key validated successfully.", - Err(_) => "Could not validate key (server may be unreachable). Saving anyway.", - }; - - // Save - let mut config = ConfigFile::load().unwrap_or_default(); - let profile = config.profile_mut(profile_name); - profile.api_key = Some(p.api_key); - profile.url = Some(url.clone()); - config.save().map_err(|e| { - McpError::internal_error(format!("Failed to save config: {e:#}"), None) - })?; - - ok_text(format!( - "{validation}\nProfile '{profile_name}' saved (URL: {url})." - )) - } + // -- Info tools ----------------------------------------------------------- - #[tool(description = "Get a configuration value. Valid keys: url, api-key, default-profile.")] - async fn config_get( - &self, - Parameters(p): Parameters, - ) -> Result { - let config = ConfigFile::load().unwrap_or_default(); - let profile_name = p.profile.as_deref().unwrap_or(&config.default_profile); - let prof = config.profile(Some(profile_name)); - - let value = match p.key.as_str() { - "url" => prof.url.as_deref().unwrap_or("(not set)").to_string(), - "api-key" | "api_key" => { - if prof.api_key.is_some() { - "(set)".to_string() - } else { - "(not set)".to_string() - } - } - "default-profile" | "default_profile" => config.default_profile.clone(), - other => { - return ok_text(format!( - "Unknown config key: {other}. Valid keys: url, api-key, default-profile" - )); - } - }; - - ok_text(format!("{} = {}", p.key, value)) - } - - #[tool(description = "Set a configuration value. Valid keys: url, api-key, default-profile.")] - async fn config_set( - &self, - Parameters(p): Parameters, - ) -> Result { - let mut config = ConfigFile::load().unwrap_or_default(); - let profile_name = p.profile.as_deref().unwrap_or("default"); - let prof = config.profile_mut(profile_name); - - match p.key.as_str() { - "url" => { - let _: url::Url = p.value.parse().map_err(|_| { - McpError::invalid_params(format!("Invalid URL: {}", p.value), None) - })?; - prof.url = Some(p.value.clone()); - } - "api-key" | "api_key" => { - prof.api_key = Some(p.value.clone()); - } - "default-profile" | "default_profile" => { - config.default_profile = p.value.clone(); - } - other => { - return ok_text(format!( - "Unknown config key: {other}. Valid keys: url, api-key, default-profile" - )); - } - } - - config.save().map_err(|e| { - McpError::internal_error(format!("Failed to save config: {e:#}"), None) - })?; - - ok_text(format!( - "Set {} = {} (profile: {profile_name})", - p.key, p.value - )) - } - - #[tool(description = "Show the currently resolved configuration: active profile name, Analyzer API URL, and masked API key. Useful to verify which account and server you are connected to.")] + #[tool(description = "[Read] Show the currently resolved configuration: active profile name, Analyzer API URL, and masked API key. Useful to verify which account and server you are connected to.")] async fn whoami(&self) -> Result { let config = ConfigFile::load().unwrap_or_default(); let profile_name = std::env::var("ANALYZER_PROFILE") @@ -583,84 +446,50 @@ impl ServerHandler for AnalyzerMcp { "Exein Analyzer MCP server — scan firmware and container images for \ vulnerabilities, generate SBOMs, and check compliance.\n\ \n\ + ## Tool Access Classification\n\ + Each tool is tagged [Read], [Write], or [Critical]:\n\ + - **[Read]**: Safe, no side effects — call freely.\n\ + - **[Write]**: Creates or modifies state — confirm with the user before calling.\n\ + - **[Critical]**: Destructive/irreversible — always confirm with the user.\n\ + \n\ + ## Identifying Scans\n\ + Most tools accept either `scan_id` (scan UUID) or `object_id` (object UUID). \ + When `object_id` is provided, the object's most recent scan is used automatically. \ + This lets you go from object to results without looking up scan IDs.\n\ + \n\ ## Quick Start\n\ - 1. Call `list_objects` to see existing objects (devices/products).\n\ - 2. Call `get_scan_types` to discover available image types and analyses.\n\ - 3. Create an object with `create_object` if needed.\n\ - 4. Upload and scan with `create_scan` (provide object_id, file path, scan type).\n\ - 5. Poll `get_scan_status` until all analyses reach 'success' (or 'error').\n\ - 6. Use `get_scan_overview` for a quick summary of all findings.\n\ - 7. Drill down with `get_analysis_results` for specific analysis types.\n\ - 8. Retrieve scores and downloads: `get_scan_score`, `download_sbom`, \ - `download_report`, `download_compliance_report`.\n\ - 9. Check compliance with `get_compliance` (e.g. CRA).\n\ + 1. `list_objects` — see existing objects (devices/products).\n\ + 2. `get_scan_types` — discover available image types and analyses.\n\ + 3. `create_object` — create an object if needed.\n\ + 4. `create_scan` — upload and scan (provide object_id, file path, scan type).\n\ + 5. `get_scan_status` — poll until all analyses reach 'success'.\n\ + 6. `get_scan_overview` — quick summary of all findings.\n\ + 7. `get_analysis_results` — drill into specific analysis types.\n\ + 8. `get_scan_score`, `download_sbom`, `download_report` — scores and artifacts.\n\ + 9. `get_compliance` — check regulatory compliance (e.g. CRA).\n\ \n\ ## Image Types\n\ - - **linux**: Linux firmware images (e.g. OpenWrt, Yocto, Buildroot). Supports all analyses.\n\ - - **docker**: Docker/OCI container images.\n\ - - **idf**: ESP-IDF firmware images (Espressif IoT Development Framework). \ - Supports a subset of analyses (info, cve, software-bom). \ - Hardening and kernel-security checks are not applicable to bare-metal RTOS targets.\n\ + - **linux**: Linux firmware (OpenWrt, Yocto, Buildroot). All analyses.\n\ + - **docker**: Docker/OCI containers.\n\ + - **idf**: ESP-IDF firmware. Subset of analyses (info, cve, software-bom).\n\ \n\ ## Analysis Types\n\ - - **info**: Extracts firmware metadata — OS, architecture, kernel version.\n\ - - **cve**: CVE vulnerability scan powered by Kepler (Exein open-source tool using NIST NVD). \ - Finds known vulnerabilities in software components. Results are grouped by product with \ - severity breakdown: Critical, High, Medium, Low.\n\ - - **software-bom**: Generates the Software Bill of Materials (SBOM) in CycloneDX JSON format. \ - Lists all software components, versions, and licenses found in the image.\n\ - - **malware**: Scans the filesystem for known malicious files (malware, trojans, etc.).\n\ - - **crypto**: Cryptographic analysis — identifies certificates, public/private keys.\n\ - - **hardening**: Binary hardening checks — verifies compiler security flags for each executable: \ - Stack Canary, NX (non-executable stack), PIE (position-independent), RELRO (relocation read-only), \ - Fortify Source. Reports weak binaries count.\n\ - - **password-hash**: Detects hard-coded weak passwords in the firmware filesystem.\n\ - - **kernel**: Checks kernel security modules: SECCOMP, SELINUX, APPARMOR, KASLR, \ - STACKPROTECTOR, FORTIFYSOURCE, etc. Reports enabled/not-enabled status.\n\ - - **capabilities**: Analyzes executable capabilities and syscalls, assigning risk levels.\n\ - - **symbols** (IDF only): Lists symbols from ESP-IDF firmware.\n\ - - **tasks** (IDF only): Lists RTOS tasks.\n\ - - **stack-overflow** (IDF only): Stack overflow detection method.\n\ + cve, software-bom, malware, crypto, hardening, password-hash, kernel, \ + capabilities, info, symbols (IDF), tasks (IDF), stack-overflow (IDF).\n\ \n\ ## Exein Rating (Security Score)\n\ - - Score is 0-100, where **lower is better** (0 = best, 100 = worst).\n\ - - 0: Perfect — no issues found in this category.\n\ - - 1-30: Good security posture.\n\ - - 31-59: Mediocre — address higher-risk vulnerabilities.\n\ - - 60-100: Poor — critical security issues require immediate attention.\n\ - - The overall score is a weighted aggregate of individual analysis scores.\n\ - - Per-analysis scores: malware=0 means clean (no malware), cve=100 means \ - severe vulnerability exposure, hardening=50 means partial compiler protections, etc.\n\ - - IMPORTANT: Do NOT interpret score 0 as 'bad'. Score 0 means the best possible result \ - (no issues detected). Score 100 is the worst.\n\ - \n\ - ## Browsing Results\n\ - - Use `get_scan_overview` first for a high-level summary of all analyses.\n\ - - Then use `get_analysis_results` with a specific analysis_type to browse detailed findings.\n\ - - Results are paginated (default 25 per page). Use page/per_page params to navigate.\n\ - - Use the search param to filter results (e.g. search='openssl' for CVEs).\n\ + 0-100 where **lower is better** (0 = no issues, 100 = worst). \ + Score 0 means best possible result. The overall score is a weighted aggregate \ + of per-analysis scores.\n\ \n\ ## Compliance\n\ - - `get_compliance` returns structured compliance check results (pass/fail per requirement).\n\ - - `download_compliance_report` downloads the full PDF compliance report.\n\ - - Supported standard: 'cra' (EU Cyber Resilience Act).\n\ + Supported standard: 'cra' (EU Cyber Resilience Act). \ + `get_compliance` returns structured pass/fail results. \ + `download_compliance_report` downloads the PDF.\n\ \n\ - ## Scan Status\n\ - - Each analysis within a scan has its own status: pending → in-progress → success | error | canceled.\n\ - - The overall scan status reflects the aggregate of all analyses.\n\ - - Scans typically take 1-10 minutes depending on image size and analyses requested.\n\ - \n\ - ## Downloaded Files\n\ - - PDF reports and SBOMs are saved to `~/.cache/analyzer/downloads//` by default.\n\ - - The SBOM (download_sbom) is also returned inline as JSON so you can analyze it directly.\n\ - - PDF reports (download_report, download_compliance_report) are binary files saved to disk — \ - the tool returns only the file path. Use a filesystem MCP server to access them if needed.\n\ - \n\ - ## SBOM Format\n\ - - Format: CycloneDX JSON (ECMA-424, 1st edition June 2024 / CycloneDX 1.6).\n\ - - Also compatible with: SPDX 3.0.1 (on request via download_sbom parameters).\n\ - - Key fields: `components[]` array with `name`, `version`, `type`, `purl` (Package URL), `licenses`.\n\ - - Use the SBOM to understand the full software supply chain of the scanned image." + ## Downloads\n\ + PDFs and SBOMs save to `~/.cache/analyzer/downloads//` by default. \ + The SBOM is also returned inline as JSON. PDF reports return only the file path." .into(), ), } @@ -699,26 +528,25 @@ fn parse_uuid(s: &str) -> Result { } fn parse_compliance_type(s: &str) -> Result { - match s.to_lowercase().as_str() { - "cra" => Ok(ComplianceType::Cra), - other => Err(McpError::invalid_params( - format!("Unknown compliance type: '{other}'. Supported: cra"), + ComplianceType::from_name(s).ok_or_else(|| { + McpError::invalid_params( + format!("Unknown compliance type: '{s}'. Supported: cra"), None, - )), - } + ) + }) } -/// Default sort-by field for a given analysis type API name. -fn default_sort_by(analysis_type: &str) -> &'static str { - match analysis_type { - "cve" | "password-hash" | "hardening" | "capabilities" => "severity", - "malware" => "filename", - "crypto" => "type", - "software-bom" | "info" | "symbols" | "stack-overflow" => "name", - "kernel" => "features", - "tasks" => "function", - _ => "name", - } +/// Resolve a scan ID from optional scan_id / object_id string params. +async fn resolve_scan( + client: &AnalyzerClient, + scan_id: Option<&str>, + object_id: Option<&str>, +) -> Result { + let scan_uuid = scan_id.map(parse_uuid).transpose()?; + let object_uuid = object_id.map(parse_uuid).transpose()?; + crate::commands::scan::resolve_scan_id(client, scan_uuid, object_uuid) + .await + .map_err(|e| McpError::invalid_params(format!("{e:#}"), None)) } fn ok_json(value: &T) -> Result { @@ -748,7 +576,7 @@ fn downloads_path(scan_id: &str, filename: &str) -> PathBuf { } /// Create parent directories and write bytes to a file. -async fn save_to_path(path: &PathBuf, bytes: &[u8]) -> std::io::Result<()> { +async fn save_to_path(path: &Path, bytes: &[u8]) -> std::io::Result<()> { if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; } From 0ca72f0633b681aaa528d761e652bee6283e38fd Mon Sep 17 00:00:00 2001 From: giovanni alberto falcione Date: Fri, 6 Mar 2026 10:30:32 +0100 Subject: [PATCH 3/5] style: cargo fmt --- src/main.rs | 4 +-- src/mcp.rs | 77 ++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1da3ac5..ec31293 100644 --- a/src/main.rs +++ b/src/main.rs @@ -349,9 +349,7 @@ async fn main() -> ExitCode { match cli.command { Some(_) => {} None => { - ::command() - .print_help() - .ok(); + ::command().print_help().ok(); return ExitCode::SUCCESS; } } diff --git a/src/mcp.rs b/src/mcp.rs index 257f16c..dee7e82 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -17,7 +17,9 @@ use serde::Deserialize; use uuid::Uuid; use crate::client::AnalyzerClient; -use crate::client::models::{AnalysisType, ComplianceType, CreateObject, ResultsQuery, ScanTypeRequest}; +use crate::client::models::{ + AnalysisType, ComplianceType, CreateObject, ResultsQuery, ScanTypeRequest, +}; use crate::config::ConfigFile; // =========================================================================== @@ -137,7 +139,9 @@ impl AnalyzerMcp { // -- Object tools --------------------------------------------------------- - #[tool(description = "[Read] List all objects (devices/products) in your Analyzer account. Returns JSON array with id, name, description, tags, score (current and previous Exein Rating), and last scan info.")] + #[tool( + description = "[Read] List all objects (devices/products) in your Analyzer account. Returns JSON array with id, name, description, tags, score (current and previous Exein Rating), and last scan info." + )] async fn list_objects(&self) -> Result { match self.client.list_objects().await { Ok(page) => ok_json(&page.data), @@ -161,7 +165,9 @@ impl AnalyzerMcp { } } - #[tool(description = "[Critical] Delete an object by its UUID. This permanently removes the object and all associated scans.")] + #[tool( + description = "[Critical] Delete an object by its UUID. This permanently removes the object and all associated scans." + )] async fn delete_object( &self, Parameters(p): Parameters, @@ -219,7 +225,9 @@ impl AnalyzerMcp { } } - #[tool(description = "[Read] Get the current status of a scan and its individual analyses. Each analysis has a status: 'pending' (queued), 'in-progress' (running), 'success' (done), 'error' (failed), 'canceled'. The overall scan status reflects the aggregate. Poll this until all analyses reach a terminal state (success/error/canceled). Accepts scan_id or object_id (resolves to most recent scan).")] + #[tool( + description = "[Read] Get the current status of a scan and its individual analyses. Each analysis has a status: 'pending' (queued), 'in-progress' (running), 'success' (done), 'error' (failed), 'canceled'. The overall scan status reflects the aggregate. Poll this until all analyses reach a terminal state (success/error/canceled). Accepts scan_id or object_id (resolves to most recent scan)." + )] async fn get_scan_status( &self, Parameters(p): Parameters, @@ -231,7 +239,9 @@ impl AnalyzerMcp { } } - #[tool(description = "[Read] Get the Exein Rating (security score) for a completed scan. Score is 0-100 where LOWER IS BETTER: 0 = no issues (best), 100 = worst. Returns overall score plus per-analysis breakdown (cve, hardening, kernel, malware, password-hash, capabilities). A score of 0 means clean/no issues found. Accepts scan_id or object_id.")] + #[tool( + description = "[Read] Get the Exein Rating (security score) for a completed scan. Score is 0-100 where LOWER IS BETTER: 0 = no issues (best), 100 = worst. Returns overall score plus per-analysis breakdown (cve, hardening, kernel, malware, password-hash, capabilities). A score of 0 means clean/no issues found. Accepts scan_id or object_id." + )] async fn get_scan_score( &self, Parameters(p): Parameters, @@ -253,7 +263,9 @@ impl AnalyzerMcp { } } - #[tool(description = "[Read] Get a scan overview — summary of all analyses with finding counts by severity. Shows CVE counts (critical/high/medium/low), malware detections, password issues, hardening issues, capabilities risk levels, crypto assets, SBOM component count, kernel configs. Use this for a quick assessment before drilling into specific analysis results. Accepts scan_id or object_id.")] + #[tool( + description = "[Read] Get a scan overview — summary of all analyses with finding counts by severity. Shows CVE counts (critical/high/medium/low), malware detections, password issues, hardening issues, capabilities risk levels, crypto assets, SBOM component count, kernel configs. Use this for a quick assessment before drilling into specific analysis results. Accepts scan_id or object_id." + )] async fn get_scan_overview( &self, Parameters(p): Parameters, @@ -265,12 +277,15 @@ impl AnalyzerMcp { } } - #[tool(description = "[Read] Browse paginated analysis results for a specific analysis type. Returns detailed findings: CVE entries with CVSS scores, malware detections, hardening flags per binary, capabilities with risk levels, crypto assets, SBOM components, kernel security features, etc. Supports pagination (page, per_page) and search filtering. Analysis types: cve, password-hash, malware, hardening, capabilities, crypto, software-bom, kernel, info, symbols, tasks, stack-overflow. Accepts scan_id or object_id.")] + #[tool( + description = "[Read] Browse paginated analysis results for a specific analysis type. Returns detailed findings: CVE entries with CVSS scores, malware detections, hardening flags per binary, capabilities with risk levels, crypto assets, SBOM components, kernel security features, etc. Supports pagination (page, per_page) and search filtering. Analysis types: cve, password-hash, malware, hardening, capabilities, crypto, software-bom, kernel, info, symbols, tasks, stack-overflow. Accepts scan_id or object_id." + )] async fn get_analysis_results( &self, Parameters(p): Parameters, ) -> Result { - let scan_id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; + let scan_id = + resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; let analysis_type = AnalysisType::from_api_name(&p.analysis_type).ok_or_else(|| { McpError::invalid_params( @@ -282,9 +297,10 @@ impl AnalyzerMcp { ) })?; - let analysis_id = crate::commands::scan::resolve_analysis_id(&self.client, scan_id, &analysis_type) - .await - .map_err(|e| McpError::invalid_params(format!("{e:#}"), None))?; + let analysis_id = + crate::commands::scan::resolve_analysis_id(&self.client, scan_id, &analysis_type) + .await + .map_err(|e| McpError::invalid_params(format!("{e:#}"), None))?; let query = ResultsQuery { page: p.page.unwrap_or(1), @@ -294,18 +310,25 @@ impl AnalyzerMcp { search: p.search, }; - match self.client.get_analysis_results(scan_id, analysis_id, &query).await { + match self + .client + .get_analysis_results(scan_id, analysis_id, &query) + .await + { Ok(results) => ok_json(&results), Err(e) => ok_err(e), } } - #[tool(description = "[Read] Get compliance check results for a scan. Returns structured compliance data with sections, requirements, and pass/fail/unknown status for each check. Supported compliance types: 'cra' (EU Cyber Resilience Act). The result includes total/passed/failed/unknown/not-applicable counts. Accepts scan_id or object_id.")] + #[tool( + description = "[Read] Get compliance check results for a scan. Returns structured compliance data with sections, requirements, and pass/fail/unknown status for each check. Supported compliance types: 'cra' (EU Cyber Resilience Act). The result includes total/passed/failed/unknown/not-applicable counts. Accepts scan_id or object_id." + )] async fn get_compliance( &self, Parameters(p): Parameters, ) -> Result { - let scan_id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; + let scan_id = + resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; let ct = parse_compliance_type(&p.compliance_type)?; match self.client.get_compliance(scan_id, ct).await { Ok(report) => ok_json(&report), @@ -313,7 +336,9 @@ impl AnalyzerMcp { } } - #[tool(description = "[Read] Download the SBOM (Software Bill of Materials) in CycloneDX JSON format. Saves to disk and returns the full JSON inline. The SBOM lists all software components found in the image: name, version, type, purl (Package URL), and licenses. Use this to understand the software supply chain, identify outdated packages, or cross-reference with CVE results. Accepts scan_id or object_id.")] + #[tool( + description = "[Read] Download the SBOM (Software Bill of Materials) in CycloneDX JSON format. Saves to disk and returns the full JSON inline. The SBOM lists all software components found in the image: name, version, type, purl (Package URL), and licenses. Use this to understand the software supply chain, identify outdated packages, or cross-reference with CVE results. Accepts scan_id or object_id." + )] async fn download_sbom( &self, Parameters(p): Parameters, @@ -337,7 +362,9 @@ impl AnalyzerMcp { } } - #[tool(description = "[Read] Download the PDF security report for a completed scan. The report includes: Exein Rating, firmware details (OS, arch, kernel), executive summary with critical findings, CVE list by product and severity, binary hardening analysis, kernel security modules status, and remediation recommendations. Saves to disk (binary PDF) — returns the file path only. Accepts scan_id or object_id.")] + #[tool( + description = "[Read] Download the PDF security report for a completed scan. The report includes: Exein Rating, firmware details (OS, arch, kernel), executive summary with critical findings, CVE list by product and severity, binary hardening analysis, kernel security modules status, and remediation recommendations. Saves to disk (binary PDF) — returns the file path only. Accepts scan_id or object_id." + )] async fn download_report( &self, Parameters(p): Parameters, @@ -356,7 +383,9 @@ impl AnalyzerMcp { } } - #[tool(description = "[Read] Download a compliance report PDF. Supported types: 'cra' (EU Cyber Resilience Act). Assesses firmware compliance with regulatory requirements. Saves to disk (binary PDF) — returns the file path only. Accepts scan_id or object_id.")] + #[tool( + description = "[Read] Download a compliance report PDF. Supported types: 'cra' (EU Cyber Resilience Act). Assesses firmware compliance with regulatory requirements. Saves to disk (binary PDF) — returns the file path only. Accepts scan_id or object_id." + )] async fn download_compliance_report( &self, Parameters(p): Parameters, @@ -370,7 +399,11 @@ impl AnalyzerMcp { }; match self.client.download_compliance_report(id, ct).await { Ok(bytes) => match save_to_path(&path, &bytes).await { - Ok(()) => ok_text(format!("{} report saved to {}", ct.display_name(), path.display())), + Ok(()) => ok_text(format!( + "{} report saved to {}", + ct.display_name(), + path.display() + )), Err(e) => ok_text(format!("Error writing file: {e}")), }, Err(e) => ok_err(e), @@ -403,11 +436,13 @@ impl AnalyzerMcp { // -- Info tools ----------------------------------------------------------- - #[tool(description = "[Read] Show the currently resolved configuration: active profile name, Analyzer API URL, and masked API key. Useful to verify which account and server you are connected to.")] + #[tool( + description = "[Read] Show the currently resolved configuration: active profile name, Analyzer API URL, and masked API key. Useful to verify which account and server you are connected to." + )] async fn whoami(&self) -> Result { let config = ConfigFile::load().unwrap_or_default(); - let profile_name = std::env::var("ANALYZER_PROFILE") - .unwrap_or_else(|_| config.default_profile.clone()); + let profile_name = + std::env::var("ANALYZER_PROFILE").unwrap_or_else(|_| config.default_profile.clone()); let prof = config.profile(Some(&profile_name)); let url = std::env::var("ANALYZER_URL") From b3660f8471607c634ca23e254432f0edf66c586a Mon Sep 17 00:00:00 2001 From: Alberto Gorni Date: Tue, 24 Mar 2026 21:49:09 +0100 Subject: [PATCH 4/5] fix: address PR review comments from krsh - bump rmcp to 1.2.0 and fix non-exhaustive ServerInfo construction - enable schemars uuid1 feature, use Uuid directly in MCP param structs (removes parse_uuid helper) - fix score color logic: lower is better (0=green, 60+=red) - add --mcp-format flag (json|text) for token-efficient analysis results output - update README: MCP tools table, --mcp-format docs Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 6 +- README.md | 40 +++++++ src/commands/scan.rs | 4 +- src/main.rs | 6 +- src/mcp.rs | 272 +++++++++++++++++++++++++++++++++++++------ src/output.rs | 4 +- 6 files changed, 291 insertions(+), 41 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ad8b419..f211c3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "analyzer-cli" -version = "0.2.1" +version = "0.2.2" edition = "2024" description = "CLI for Exein Analyzer - firmware and container security scanning" license = "Apache-2.0" @@ -36,8 +36,8 @@ url = { version = "2", features = ["serde"] } humantime = "2" bytes = "1" clap_complete = "4" -rmcp = { version = "0.12", features = ["server", "macros", "transport-io"] } -schemars = "1" +rmcp = { version = "1.2.0", features = ["server", "macros", "transport-io"] } +schemars = { version = "1", features = ["uuid1"] } [dev-dependencies] assert_cmd = "2" diff --git a/README.md b/README.md index 16c5d5f..c154ae9 100644 --- a/README.md +++ b/README.md @@ -242,10 +242,50 @@ analyzer --mcp --profile staging ANALYZER_API_KEY=your-key ANALYZER_URL=https://my-instance.example.com/api/ analyzer --mcp ``` +#### `--mcp-format` + +Controls how `get_analysis_results` returns findings to the AI assistant. + +| Value | Description | +|-------|-------------| +| `json` | Raw JSON (default) — full fidelity, higher token usage | +| `text` | Compact text — type-specific formatting, fewer tokens | + +```bash +# Default: raw JSON +analyzer --mcp + +# Compact text output (lower token usage) +analyzer --mcp --mcp-format text +``` + +Use `text` if the AI assistant struggles with large result sets (e.g. hundreds of CVEs or SBOM components). The text format renders each analysis type in a purpose-built layout — one finding per line with only the most relevant fields. + **Prerequisites:** You must have a valid configuration before starting the MCP server. Run `analyzer login` at least once, or ensure a profile is configured in `~/.config/analyzer/config.toml`. The MCP server exposes all CLI operations as structured tools: managing objects, creating scans, retrieving scores, browsing analysis results, checking compliance, downloading SBOMs and reports. It communicates over stdio using JSON-RPC, so stdout is reserved for the protocol — all logs go to stderr. +Available tools include: + +| Tool | Description | +|------|-------------| +| `list_objects` | List all objects (devices/products) | +| `create_object` / `delete_object` | Manage objects | +| `create_scan` | Upload and scan a firmware or container image | +| `get_scan_status` | Poll scan progress | +| `get_scan_score` | Get the Exein Rating (0–100, lower is better) | +| `get_scan_types` | List available scan types and analyses | +| `get_scan_overview` | Summary of all analysis findings for a scan | +| `get_analysis_results` | Paginated findings for a specific analysis type | +| `get_compliance` | Compliance check results (e.g. CRA) | +| `get_cve_detail` | Full CVE details by ID: CVSS v3, EPSS, CWE, CNNVD | +| `download_sbom` | Download SBOM in CycloneDX JSON format | +| `download_report` | Download the PDF security report | +| `download_compliance_report` | Download the PDF compliance report | +| `cancel_scan` / `delete_scan` | Manage scans | +| `configure_profile` / `config_get` / `config_set` | Manage configuration | +| `whoami` | Show the currently resolved configuration | + #### Configure in Claude Code Add to your `~/.claude.json` (or project-level `.mcp.json`): diff --git a/src/commands/scan.rs b/src/commands/scan.rs index d41f275..f7527ed 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -195,9 +195,9 @@ pub async fn run_score(client: &AnalyzerClient, scan_id: Uuid, format: Format) - ); for s in &score.scores { let score_str = format!("{:<5}", s.score); - let score_styled = if s.score >= 80 { + let score_styled = if s.score <= 30 { style(score_str).green().to_string() - } else if s.score >= 50 { + } else if s.score <= 59 { style(score_str).yellow().to_string() } else { style(score_str).red().to_string() diff --git a/src/main.rs b/src/main.rs index ec31293..7e5b513 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,10 @@ struct Cli { #[arg(long)] mcp: bool, + /// Output format for MCP analysis results: "json" (default, raw) or "text" (compact, token-efficient). + #[arg(long, value_enum, default_value_t = mcp::McpFormat::Json, requires = "mcp")] + mcp_format: mcp::McpFormat, + /// API key (overrides config file and ANALYZER_API_KEY env var). #[arg(long, global = true, env = "ANALYZER_API_KEY", hide_env_values = true)] api_key: Option, @@ -339,7 +343,7 @@ async fn main() -> ExitCode { let cli = Cli::parse(); if cli.mcp { - if let Err(e) = mcp::serve(cli.api_key, cli.url, cli.profile).await { + if let Err(e) = mcp::serve(cli.api_key, cli.url, cli.profile, cli.mcp_format).await { eprintln!("MCP server error: {e:#}"); return ExitCode::FAILURE; } diff --git a/src/mcp.rs b/src/mcp.rs index dee7e82..7271c15 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; use anyhow::Result; +use clap::ValueEnum; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{ @@ -18,10 +19,21 @@ use uuid::Uuid; use crate::client::AnalyzerClient; use crate::client::models::{ - AnalysisType, ComplianceType, CreateObject, ResultsQuery, ScanTypeRequest, + AnalysisType, CapabilityFinding, ComplianceType, CreateObject, CryptoFinding, HardeningFinding, + IdfSymbolFinding, IdfTaskFinding, KernelFinding, MalwareFinding, PasswordFinding, + ResultsQuery, SbomComponent, ScanTypeRequest, }; use crate::config::ConfigFile; +/// Output format for MCP analysis results. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum McpFormat { + /// Compact human-readable text — fewer tokens. + Text, + /// Raw JSON — full fidelity. + Json, +} + // =========================================================================== // Parameter structs (serde + schemars for MCP tool schemas) // =========================================================================== @@ -39,13 +51,13 @@ struct CreateObjectParams { #[derive(Debug, Deserialize, JsonSchema)] struct ObjectIdParam { /// Object UUID. - object_id: String, + object_id: Uuid, } #[derive(Debug, Deserialize, JsonSchema)] struct CreateScanParams { /// Object UUID to scan against. - object_id: String, + object_id: Uuid, /// Path to the firmware or container image file. file_path: String, /// Image type: "linux", "docker", or "idf". @@ -121,6 +133,7 @@ struct AnalysisResultsParams { #[derive(Clone)] pub struct AnalyzerMcp { client: AnalyzerClient, + mcp_format: McpFormat, tool_router: ToolRouter, } @@ -130,9 +143,10 @@ pub struct AnalyzerMcp { #[tool_router] impl AnalyzerMcp { - fn new(client: AnalyzerClient) -> Self { + fn new(client: AnalyzerClient, mcp_format: McpFormat) -> Self { Self { client, + mcp_format, tool_router: Self::tool_router(), } } @@ -172,9 +186,8 @@ impl AnalyzerMcp { &self, Parameters(p): Parameters, ) -> Result { - let id = parse_uuid(&p.object_id)?; - match self.client.delete_object(id).await { - Ok(()) => ok_text(format!("Deleted object {id}")), + match self.client.delete_object(p.object_id).await { + Ok(()) => ok_text(format!("Deleted object {}", p.object_id)), Err(e) => ok_err(e), } } @@ -188,7 +201,7 @@ impl AnalyzerMcp { &self, Parameters(p): Parameters, ) -> Result { - let object_id = parse_uuid(&p.object_id)?; + let object_id = p.object_id; let file_path = PathBuf::from(&p.file_path); if !file_path.exists() { @@ -310,12 +323,14 @@ impl AnalyzerMcp { search: p.search, }; - match self - .client - .get_analysis_results(scan_id, analysis_id, &query) - .await - { - Ok(results) => ok_json(&results), + match self.client.get_analysis_results(scan_id, analysis_id, &query).await { + Ok(results) => match self.mcp_format { + McpFormat::Json => ok_json(&results), + McpFormat::Text => { + let text = format_analysis_text(&p.analysis_type, &results.findings, results.total_findings, query.page, query.per_page); + ok_text(text) + } + }, Err(e) => ok_err(e), } } @@ -346,7 +361,7 @@ impl AnalyzerMcp { let id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; let path = match &p.output_path { Some(p) => PathBuf::from(p), - None => downloads_path(&id.to_string(), "sbom.json"), + None => downloads_path(id, "sbom.json"), }; match self.client.download_sbom(id).await { Ok(bytes) => { @@ -372,7 +387,7 @@ impl AnalyzerMcp { let id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?; let path = match &p.output_path { Some(p) => PathBuf::from(p), - None => downloads_path(&id.to_string(), "report.pdf"), + None => downloads_path(id, "report.pdf"), }; match self.client.download_report(id).await { Ok(bytes) => match save_to_path(&path, &bytes).await { @@ -395,7 +410,7 @@ impl AnalyzerMcp { let default_name = format!("{}_report.pdf", p.compliance_type); let path = match &p.output_path { Some(p) => PathBuf::from(p), - None => downloads_path(&id.to_string(), &default_name), + None => downloads_path(id, &default_name), }; match self.client.download_compliance_report(id, ct).await { Ok(bytes) => match save_to_path(&path, &bytes).await { @@ -464,6 +479,7 @@ impl AnalyzerMcp { "Profile: {profile_name}\nURL: {url}\nAPI Key: {masked_key}" )) } + } // --------------------------------------------------------------------------- @@ -473,11 +489,11 @@ impl AnalyzerMcp { #[tool_handler] impl ServerHandler for AnalyzerMcp { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: ProtocolVersion::V_2024_11_05, - capabilities: ServerCapabilities::builder().enable_tools().build(), - server_info: Implementation::from_build_env(), - instructions: Some( + let mut info = ServerInfo::default(); + info.protocol_version = ProtocolVersion::V_2024_11_05; + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info.server_info = Implementation::from_build_env(); + info.instructions = Some( "Exein Analyzer MCP server — scan firmware and container images for \ vulnerabilities, generate SBOMs, and check compliance.\n\ \n\ @@ -513,9 +529,27 @@ impl ServerHandler for AnalyzerMcp { capabilities, info, symbols (IDF), tasks (IDF), stack-overflow (IDF).\n\ \n\ ## Exein Rating (Security Score)\n\ - 0-100 where **lower is better** (0 = no issues, 100 = worst). \ - Score 0 means best possible result. The overall score is a weighted aggregate \ - of per-analysis scores.\n\ + - Score is 0-100, where **lower is better** (0 = best, 100 = worst).\n\ + - 0: Perfect — no issues found in this category.\n\ + - 1-30: Good security posture.\n\ + - 31-59: Mediocre — address higher-risk vulnerabilities.\n\ + - 60-100: Poor — critical security issues require immediate attention.\n\ + - The overall score is a weighted aggregate of individual analysis scores.\n\ + - Per-analysis scores: malware=0 means clean (no malware), cve=100 means \ + severe vulnerability exposure, hardening=50 means partial compiler protections, etc.\n\ + - IMPORTANT: Do NOT interpret score 0 as 'bad'. Score 0 means the best possible result \ + (no issues detected). Score 100 is the worst.\n\ + \n\ + ## CVE Details\n\ + - Use `get_cve_detail` with a CVE ID (e.g. \"CVE-2022-48174\") to get full vulnerability \ + details: CVSS v3 score, severity, description, CWE list, EPSS score, CNNVD entry, and data sources.\n\ + - Useful after `get_analysis_results` on a 'cve' analysis to enrich individual findings.\n\ + \n\ + ## Browsing Results\n\ + - Use `get_scan_overview` first for a high-level summary of all analyses.\n\ + - Then use `get_analysis_results` with a specific analysis_type to browse detailed findings.\n\ + - Results are paginated (default 25 per page). Use page/per_page params to navigate.\n\ + - Use the search param to filter results (e.g. search='openssl' for CVEs).\n\ \n\ ## Compliance\n\ Supported standard: 'cra' (EU Cyber Resilience Act). \ @@ -526,8 +560,8 @@ impl ServerHandler for AnalyzerMcp { PDFs and SBOMs save to `~/.cache/analyzer/downloads//` by default. \ The SBOM is also returned inline as JSON. PDF reports return only the file path." .into(), - ), - } + ); + info } } @@ -540,10 +574,11 @@ pub async fn serve( api_key: Option, url: Option, profile: Option, + mcp_format: McpFormat, ) -> Result<()> { let cfg = crate::config::resolve(api_key.as_deref(), url.as_deref(), profile.as_deref())?; let client = AnalyzerClient::new(cfg.url, &cfg.api_key)?; - let server = AnalyzerMcp::new(client); + let server = AnalyzerMcp::new(client, mcp_format); let service = server .serve((tokio::io::stdin(), tokio::io::stdout())) @@ -557,9 +592,175 @@ pub async fn serve( // Helpers // =========================================================================== -fn parse_uuid(s: &str) -> Result { - s.parse::() - .map_err(|_| McpError::invalid_params(format!("Invalid UUID: {s}"), None)) +// =========================================================================== +// Text formatters (token-efficient output for LLMs) +// =========================================================================== + +fn format_analysis_text( + analysis_type: &str, + findings: &[serde_json::Value], + total: u64, + page: u32, + per_page: u32, +) -> String { + let pages = total.div_ceil(per_page as u64); + let mut out = format!( + "Analysis: {analysis_type} | {total} total findings | page {page}/{pages}\n\n" + ); + + if findings.is_empty() { + out.push_str("No findings."); + return out; + } + + match analysis_type { + "cve" => { + for f in findings { + if let Ok(c) = serde_json::from_value::(f.clone()) { + let sev = c.severity.as_deref().unwrap_or("?").to_uppercase(); + let id = c.cveid.as_deref().unwrap_or("?"); + let score = c.cvss.as_ref() + .and_then(|s| s.v3.as_ref()) + .and_then(|v| v.base_score) + .map(|s| format!(" CVSS:{s}")) + .unwrap_or_default(); + let products: Vec<_> = c.products.iter() + .filter_map(|p| p.product.as_deref()) + .collect(); + let prod = if products.is_empty() { String::new() } else { format!(" ({})", products.join(", ")) }; + let summary = c.summary.as_deref().unwrap_or("").chars().take(120).collect::(); + out.push_str(&format!("{sev:<8} {id}{score}{prod}\n {summary}\n")); + } + } + } + "hardening" => { + out.push_str(&format!("{:<50} {:>4} {:>3} {:>3} {:>5} {:>5} {:>4}\n", + "Binary", "Sev", "NX", "PIE", "RELRO", "Stack", "Fort")); + out.push_str(&"-".repeat(80)); + out.push('\n'); + for f in findings { + if let Ok(h) = serde_json::from_value::(f.clone()) { + let name = h.filename.as_deref().unwrap_or("?"); + let name = if name.len() > 50 { &name[name.len()-50..] } else { name }; + let sev = h.severity.as_deref().unwrap_or("?"); + let nx = bool_flag(h.nx); + let pie = h.pie.as_deref().unwrap_or("?"); + let relro = h.relro.as_deref().unwrap_or("?"); + let canary = bool_flag(h.canary); + let fortify = bool_flag(h.fortify); + out.push_str(&format!("{name:<50} {sev:>4} {nx:>3} {pie:>3} {relro:>5} {canary:>5} {fortify:>4}\n")); + } + } + } + "password-hash" => { + for f in findings { + if let Ok(p) = serde_json::from_value::(f.clone()) { + let sev = p.severity.as_deref().unwrap_or("?").to_uppercase(); + let user = p.username.as_deref().unwrap_or("?"); + let pass = p.password.as_deref().unwrap_or("?"); + out.push_str(&format!("{sev:<8} user={user} hash={pass}\n")); + } + } + } + "malware" => { + for f in findings { + if let Ok(m) = serde_json::from_value::(f.clone()) { + let file = m.filename.as_deref().unwrap_or("?"); + let desc = m.description.as_deref().unwrap_or("?"); + let engine = m.detection_engine.as_deref().unwrap_or("?"); + out.push_str(&format!("{file}\n [{engine}] {desc}\n")); + } + } + } + "capabilities" => { + for f in findings { + if let Ok(c) = serde_json::from_value::(f.clone()) { + let file = c.filename.as_deref().unwrap_or("?"); + out.push_str(&format!("{file}\n")); + for b in &c.behaviors { + let risk = b.risk_level.as_deref().unwrap_or("?"); + let desc = b.description.as_deref().unwrap_or("?"); + out.push_str(&format!(" [{risk}] {desc}\n")); + } + } + } + } + "crypto" => { + for f in findings { + if let Ok(c) = serde_json::from_value::(f.clone()) { + let file = c.filename.as_deref().unwrap_or("?"); + let ctype = c.crypto_type.as_deref().unwrap_or("?"); + let sub = c.subtype.as_deref().unwrap_or(""); + let sz = c.pubsz.map(|s| format!(" {s}bit")).unwrap_or_default(); + out.push_str(&format!("{ctype}/{sub}{sz} — {file}\n")); + } + } + } + "software-bom" => { + out.push_str(&format!("{:<35} {:<15} {}\n", "Component", "Version", "License")); + out.push_str(&"-".repeat(70)); + out.push('\n'); + for f in findings { + if let Ok(c) = serde_json::from_value::(f.clone()) { + let name = c.name.as_deref().unwrap_or("?"); + let ver = c.version.as_deref().unwrap_or("?"); + let licenses: Vec = c.licenses.iter() + .filter_map(|l| l.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) + .collect(); + let lic = if licenses.is_empty() { "?".to_string() } else { licenses.join(", ") }; + out.push_str(&format!("{name:<35} {ver:<15} {lic}\n")); + } + } + } + "kernel" => { + for f in findings { + if let Ok(k) = serde_json::from_value::(f.clone()) { + let file = k.file.as_deref().unwrap_or("?"); + out.push_str(&format!("{file}\n")); + for feat in &k.features { + let status = if feat.enabled { "ON " } else { "OFF" }; + out.push_str(&format!(" [{status}] {}\n", feat.name)); + } + } + } + } + "symbols" => { + for f in findings { + if let Ok(s) = serde_json::from_value::(f.clone()) { + let name = s.symbol_name.as_deref().unwrap_or("?"); + let stype = s.symbol_type.as_deref().unwrap_or("?"); + let bind = s.symbol_bind.as_deref().unwrap_or("?"); + out.push_str(&format!("{name} ({stype}/{bind})\n")); + } + } + } + "tasks" => { + for f in findings { + if let Ok(t) = serde_json::from_value::(f.clone()) { + let name = t.task_name.as_deref().unwrap_or("?"); + let func = t.task_fn.as_deref().unwrap_or("?"); + out.push_str(&format!("{name} → {func}\n")); + } + } + } + // info, stack-overflow and unknown types: compact JSON per entry + _ => { + for f in findings { + out.push_str(&f.to_string()); + out.push('\n'); + } + } + } + + out +} + +fn bool_flag(v: Option) -> &'static str { + match v { + Some(true) => "yes", + Some(false) => "NO", + None => "?", + } } fn parse_compliance_type(s: &str) -> Result { @@ -571,6 +772,11 @@ fn parse_compliance_type(s: &str) -> Result { }) } +fn parse_uuid(s: &str) -> Result { + s.parse::() + .map_err(|_| McpError::invalid_params(format!("Invalid UUID: {s}"), None)) +} + /// Resolve a scan ID from optional scan_id / object_id string params. async fn resolve_scan( client: &AnalyzerClient, @@ -601,12 +807,12 @@ fn ok_err(e: anyhow::Error) -> Result { } /// Build a path like `~/.cache/analyzer/downloads//`. -fn downloads_path(scan_id: &str, filename: &str) -> PathBuf { +fn downloads_path(scan_id: Uuid, filename: &str) -> PathBuf { let base = dirs::cache_dir() .unwrap_or_else(|| PathBuf::from("/tmp")) .join("analyzer") .join("downloads") - .join(scan_id); + .join(scan_id.to_string()); base.join(filename) } diff --git a/src/output.rs b/src/output.rs index 83316bc..bd831f9 100644 --- a/src/output.rs +++ b/src/output.rs @@ -38,8 +38,8 @@ pub fn status(label: &str, msg: &str) { /// Format a score with colour coding. pub fn format_score(score: Option) -> String { match score { - Some(s) if s >= 80 => format!("{}", s.to_string().green()), - Some(s) if s >= 50 => format!("{}", s.to_string().yellow()), + Some(s) if s <= 30 => format!("{}", s.to_string().green()), + Some(s) if s <= 59 => format!("{}", s.to_string().yellow()), Some(s) => format!("{}", s.to_string().red()), None => style("--").dim().to_string(), } From 3264b6ff260e0cff9ff55f8167369d13db600b28 Mon Sep 17 00:00:00 2001 From: Alberto Gorni Date: Wed, 25 Mar 2026 09:16:18 +0100 Subject: [PATCH 5/5] style: apply rustfmt Co-Authored-By: Claude Sonnet 4.6 --- src/mcp.rs | 81 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/src/mcp.rs b/src/mcp.rs index 7271c15..384bf6c 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -20,8 +20,8 @@ use uuid::Uuid; use crate::client::AnalyzerClient; use crate::client::models::{ AnalysisType, CapabilityFinding, ComplianceType, CreateObject, CryptoFinding, HardeningFinding, - IdfSymbolFinding, IdfTaskFinding, KernelFinding, MalwareFinding, PasswordFinding, - ResultsQuery, SbomComponent, ScanTypeRequest, + IdfSymbolFinding, IdfTaskFinding, KernelFinding, MalwareFinding, PasswordFinding, ResultsQuery, + SbomComponent, ScanTypeRequest, }; use crate::config::ConfigFile; @@ -323,11 +323,21 @@ impl AnalyzerMcp { search: p.search, }; - match self.client.get_analysis_results(scan_id, analysis_id, &query).await { + match self + .client + .get_analysis_results(scan_id, analysis_id, &query) + .await + { Ok(results) => match self.mcp_format { McpFormat::Json => ok_json(&results), McpFormat::Text => { - let text = format_analysis_text(&p.analysis_type, &results.findings, results.total_findings, query.page, query.per_page); + let text = format_analysis_text( + &p.analysis_type, + &results.findings, + results.total_findings, + query.page, + query.per_page, + ); ok_text(text) } }, @@ -479,7 +489,6 @@ impl AnalyzerMcp { "Profile: {profile_name}\nURL: {url}\nAPI Key: {masked_key}" )) } - } // --------------------------------------------------------------------------- @@ -604,9 +613,8 @@ fn format_analysis_text( per_page: u32, ) -> String { let pages = total.div_ceil(per_page as u64); - let mut out = format!( - "Analysis: {analysis_type} | {total} total findings | page {page}/{pages}\n\n" - ); + let mut out = + format!("Analysis: {analysis_type} | {total} total findings | page {page}/{pages}\n\n"); if findings.is_empty() { out.push_str("No findings."); @@ -616,39 +624,63 @@ fn format_analysis_text( match analysis_type { "cve" => { for f in findings { - if let Ok(c) = serde_json::from_value::(f.clone()) { + if let Ok(c) = + serde_json::from_value::(f.clone()) + { let sev = c.severity.as_deref().unwrap_or("?").to_uppercase(); let id = c.cveid.as_deref().unwrap_or("?"); - let score = c.cvss.as_ref() + let score = c + .cvss + .as_ref() .and_then(|s| s.v3.as_ref()) .and_then(|v| v.base_score) .map(|s| format!(" CVSS:{s}")) .unwrap_or_default(); - let products: Vec<_> = c.products.iter() + let products: Vec<_> = c + .products + .iter() .filter_map(|p| p.product.as_deref()) .collect(); - let prod = if products.is_empty() { String::new() } else { format!(" ({})", products.join(", ")) }; - let summary = c.summary.as_deref().unwrap_or("").chars().take(120).collect::(); + let prod = if products.is_empty() { + String::new() + } else { + format!(" ({})", products.join(", ")) + }; + let summary = c + .summary + .as_deref() + .unwrap_or("") + .chars() + .take(120) + .collect::(); out.push_str(&format!("{sev:<8} {id}{score}{prod}\n {summary}\n")); } } } "hardening" => { - out.push_str(&format!("{:<50} {:>4} {:>3} {:>3} {:>5} {:>5} {:>4}\n", - "Binary", "Sev", "NX", "PIE", "RELRO", "Stack", "Fort")); + out.push_str(&format!( + "{:<50} {:>4} {:>3} {:>3} {:>5} {:>5} {:>4}\n", + "Binary", "Sev", "NX", "PIE", "RELRO", "Stack", "Fort" + )); out.push_str(&"-".repeat(80)); out.push('\n'); for f in findings { if let Ok(h) = serde_json::from_value::(f.clone()) { let name = h.filename.as_deref().unwrap_or("?"); - let name = if name.len() > 50 { &name[name.len()-50..] } else { name }; + let name = if name.len() > 50 { + &name[name.len() - 50..] + } else { + name + }; let sev = h.severity.as_deref().unwrap_or("?"); let nx = bool_flag(h.nx); let pie = h.pie.as_deref().unwrap_or("?"); let relro = h.relro.as_deref().unwrap_or("?"); let canary = bool_flag(h.canary); let fortify = bool_flag(h.fortify); - out.push_str(&format!("{name:<50} {sev:>4} {nx:>3} {pie:>3} {relro:>5} {canary:>5} {fortify:>4}\n")); + out.push_str(&format!( + "{name:<50} {sev:>4} {nx:>3} {pie:>3} {relro:>5} {canary:>5} {fortify:>4}\n" + )); } } } @@ -697,17 +729,26 @@ fn format_analysis_text( } } "software-bom" => { - out.push_str(&format!("{:<35} {:<15} {}\n", "Component", "Version", "License")); + out.push_str(&format!( + "{:<35} {:<15} {}\n", + "Component", "Version", "License" + )); out.push_str(&"-".repeat(70)); out.push('\n'); for f in findings { if let Ok(c) = serde_json::from_value::(f.clone()) { let name = c.name.as_deref().unwrap_or("?"); let ver = c.version.as_deref().unwrap_or("?"); - let licenses: Vec = c.licenses.iter() + let licenses: Vec = c + .licenses + .iter() .filter_map(|l| l.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) .collect(); - let lic = if licenses.is_empty() { "?".to_string() } else { licenses.join(", ") }; + let lic = if licenses.is_empty() { + "?".to_string() + } else { + licenses.join(", ") + }; out.push_str(&format!("{name:<35} {ver:<15} {lic}\n")); } }