diff --git a/Cargo.toml b/Cargo.toml index bee6d43..f211c3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "analyzer-cli" -version = "0.2.0" +version = "0.2.2" 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 = "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 963e8ed..c154ae9 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,96 @@ 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 +``` + +#### `--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`): + +```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/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..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() @@ -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/main.rs b/src/main.rs index 5f770d6..7e5b513 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,16 @@ 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, + + /// 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, @@ -51,7 +59,7 @@ struct Cli { format: Format, #[command(subcommand)] - command: Command, + command: Option, } #[derive(Subcommand)] @@ -334,6 +342,22 @@ 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, cli.mcp_format).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 +373,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..384bf6c --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,866 @@ +//! 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::{Path, PathBuf}; + +use anyhow::Result; +use clap::ValueEnum; +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::{ + 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) +// =========================================================================== + +#[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: Uuid, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct CreateScanParams { + /// Object UUID to scan against. + object_id: Uuid, + /// 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>, +} + +/// 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 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. 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. 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//. + output_path: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ComplianceParams { + /// 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. 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, + /// Page number (default: 1). + page: Option, + /// Results per page (default: 25). + per_page: Option, + /// Search / filter string. + search: Option, +} + +// =========================================================================== +// MCP server +// =========================================================================== + +#[derive(Clone)] +pub struct AnalyzerMcp { + client: AnalyzerClient, + mcp_format: McpFormat, + tool_router: ToolRouter, +} + +// --------------------------------------------------------------------------- +// Tools +// --------------------------------------------------------------------------- + +#[tool_router] +impl AnalyzerMcp { + fn new(client: AnalyzerClient, mcp_format: McpFormat) -> Self { + Self { + client, + mcp_format, + tool_router: Self::tool_router(), + } + } + + // -- 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." + )] + 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 = "[Write] 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 = "[Critical] Delete an object by its UUID. This permanently removes the object and all associated scans." + )] + async fn delete_object( + &self, + Parameters(p): Parameters, + ) -> Result { + match self.client.delete_object(p.object_id).await { + Ok(()) => ok_text(format!("Deleted object {}", p.object_id)), + Err(e) => ok_err(e), + } + } + + // -- Scan tools ----------------------------------------------------------- + + #[tool( + 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, + Parameters(p): Parameters, + ) -> Result { + let object_id = 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 = "[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, + ) -> Result { + 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 = "[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, + ) -> Result { + 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), + } + } + + #[tool( + 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 { + Ok(types) => ok_json(&types), + Err(e) => ok_err(e), + } + } + + #[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, + ) -> Result { + 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 = "[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 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 = + 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: analysis_type.default_sort_by().to_string(), + sort_ord: "asc".to_string(), + search: p.search, + }; + + 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), + } + } + + #[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 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 = "[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 = 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, "sbom.json"), + }; + match self.client.download_sbom(id).await { + Ok(bytes) => { + 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 the AI 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 = "[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 = 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, "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 = "[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 = 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(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 = "[Write] Cancel a running scan. Accepts scan_id or object_id.")] + async fn cancel_scan( + &self, + Parameters(p): Parameters, + ) -> Result { + 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 = "[Critical] Delete a scan permanently. Accepts scan_id or object_id.")] + async fn delete_scan( + &self, + Parameters(p): Parameters, + ) -> Result { + 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), + } + } + + // -- 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." + )] + 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 { + 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\ + ## 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. `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 (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\ + 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\ + ## 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). \ + `get_compliance` returns structured pass/fail results. \ + `download_compliance_report` downloads the PDF.\n\ + \n\ + ## 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(), + ); + info + } +} + +// =========================================================================== +// Entry point +// =========================================================================== + +/// Start the MCP server over stdio. +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, mcp_format); + + let service = server + .serve((tokio::io::stdin(), tokio::io::stdout())) + .await?; + service.waiting().await?; + + Ok(()) +} + +// =========================================================================== +// Helpers +// =========================================================================== + +// =========================================================================== +// 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 { + ComplianceType::from_name(s).ok_or_else(|| { + McpError::invalid_params( + format!("Unknown compliance type: '{s}'. Supported: cra"), + None, + ) + }) +} + +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, + 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 { + 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: Uuid, filename: &str) -> PathBuf { + let base = dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("analyzer") + .join("downloads") + .join(scan_id.to_string()); + base.join(filename) +} + +/// Create parent directories and write bytes to a file. +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?; + } + tokio::fs::write(path, bytes).await +} 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(), }