Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand Down
90 changes: 90 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,96 @@ analyzer scan overview --object <ID> --format json | jq '.analyses'
analyzer scan results --object <ID> --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
Expand Down
27 changes: 27 additions & 0 deletions src/client/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
match name.to_lowercase().as_str() {
"cra" => Some(Self::Cra),
_ => None,
}
}
}

// === Analysis Type enum (for CLI) ===
Expand Down Expand Up @@ -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<Self> {
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 {
Expand Down
6 changes: 3 additions & 3 deletions src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 27 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
mod client;
mod commands;
mod config;
mod mcp;
mod output;

use std::path::PathBuf;
Expand All @@ -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<String>,
Expand All @@ -51,7 +59,7 @@ struct Cli {
format: Format,

#[command(subcommand)]
command: Command,
command: Option<Command>,
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -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 => {
<Cli as clap::CommandFactory>::command().print_help().ok();
return ExitCode::SUCCESS;
}
}

if let Err(e) = run(cli).await {
output::error(&format!("{e:#}"));
ExitCode::FAILURE
Expand All @@ -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,
Expand Down
Loading
Loading