From 9dff7b2ac6dfa1e30135bd8120989ac6d368fcf2 Mon Sep 17 00:00:00 2001 From: BootIntel Agent Date: Tue, 29 Sep 2026 21:15:01 +0000 Subject: [PATCH] Serve the offline analysis to an assistant, read-only, over stdio `bootintel mcp` speaks the Model Context Protocol over stdin and stdout, so an assistant can do the reasoning half of reading a boot log while this binary does the reading half. Given an inventory, a boot-chain verdict and a kernel posture, what to look at first is exactly the question a model is good at; recognising them in a capture is what this already does. It runs locally, and that is the whole point rather than an implementation detail. The model is the operator's own, under whatever agreement they already work under, and no capture leaves the machine. A hosted version would have to receive the log, which is the thing an NDA-bound consultancy cannot do and the reason the offline tooling exists at all. Every tool reads. None opens a serial port, writes to a device, or reaches the network, and a test asserts the tool list by name rather than trusting a policy, so adding a write is a deliberate edit somebody reviews. If that ever changes, the allowlist belongs in this process where the bytes are written and not in a prompt: a model can be argued into anything, a refusal in code cannot. Captures arrive as text rather than as a path, so the caller decides what is disclosed instead of the server reading whatever it likes off the disk. The engine's honesty survives the trip through a summariser, which is the part most at risk of being lost. An empty finding list says the capture was not recognised rather than that the device is clean, absence is reported as unknown rather than hardened, and each verdict carries the value it was read from. Protocol details worth stating because they are the ones that break quietly: notifications get no reply, an unrecognised protocol version falls back instead of failing the handshake, a malformed line does not end the session, and a tool failure is a result carrying isError rather than a transport error, so the model sees why it failed rather than the client seeing a broken connection. Verified against a real handshake, not only unit tests: initialize, initialized, tools/list and a tools/call round trip driven through the built binary. 402 tests with --features tui, 381 without, clippy clean under -D warnings in both, rustfmt clean. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 21 ++ README.md | 38 +++ crates/cli/src/cmd/mcp.rs | 490 ++++++++++++++++++++++++++++++++++++++ crates/cli/src/cmd/mod.rs | 1 + crates/cli/src/main.rs | 5 + 5 files changed, 555 insertions(+) create mode 100644 crates/cli/src/cmd/mcp.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6e7a2..2c00169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ All notable changes to bootintel-cli are documented here. Format follows [Keep a ## [Unreleased] +### Added +- **`bootintel mcp`**: serve the offline analysis to an MCP client over stdio, so + an assistant can do the reasoning half of reading a boot log while this binary + does the reading half. Four tools: `scan_log`, `boot_chain_verdict`, + `list_detectors`, `sample_log`. + + It runs locally on purpose. The model is the operator's own, under whatever + agreement they already work under, and no capture leaves the machine. A hosted + version would have to receive the log, which is the thing an NDA-bound + consultancy cannot do. + + **Every tool is read-only.** None opens a serial port, writes to a device, or + reaches the network, and a test asserts the tool list rather than trusting + that, so adding a write is a deliberate edit rather than something that + arrives unnoticed. Captures are passed as text rather than a path, so the + caller decides what is disclosed. + + The honesty the engine is careful about survives the trip: an empty finding + list says that the capture was not recognised rather than that the device is + clean, and the verdict names the value each entry was read from. + ## [0.12.0] — 2026-09-29 — the dashboard can take the prompt too ### Added diff --git a/README.md b/README.md index 316d757..8296da1 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,43 @@ Use `bootintel term` when you want a clean terminal and `bootintel analyze` when | Gate a build artifact | `bootintel scan boot.log --format sarif --gate-critical` | Emits CI-friendly output and exits non-zero for critical findings. | | Request richer analysis | `bootintel scan --api --preview boot.log` | Explicitly sends the log to BootIntel's API using the anonymous preview quota. | +## Using it from an assistant + +`bootintel mcp` speaks the Model Context Protocol over stdio, which lets an +assistant do the reasoning half of reading a boot log while this binary does the +reading half. It runs on your machine: the model is yours, under whatever +agreement you already work under, and no capture leaves. + +Claude Code: + +```sh +claude mcp add bootintel -- bootintel mcp +``` + +Claude Desktop, in `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "bootintel": { "command": "bootintel", "args": ["mcp"] } + } +} +``` + +Then ask for what you actually want to know: + +> Here is a boot log from a router I am assessing. What should I look at first, +> and what can you not tell from this capture? + +**Every tool is read-only.** None of them opens a serial port, writes to a +device, or reaches the network. An agent that can type at a U-Boot prompt on a +client's only sample of a device is a different product with a different risk; +`analyze --interrupt-autoboot` exists for a human who wants that, with the +safety rails that come with it. + +The tools pass the capture as text rather than a path, so you decide what is +disclosed rather than the server reading whatever it likes off your disk. + ## Privacy and terminal safety - Local detection runs on your machine and never requires an account. @@ -165,6 +202,7 @@ cargo build --release | `bootintel verdict ` | Assess what a capture establishes about the boot: the U-Boot session if it contains one, and the kernel hardening posture if the boot got that far. For the session half it reads a `printenv` dump taken at the prompt Reads a `printenv` dump taken at the prompt and reports what the boot chain permits: whether autoboot is interruptible, whether images are verified, whether a netboot path is pre-configured, whether `bootargs` can be rewritten, and whether `saveenv` makes any of it stick. Every entry names the variable it was read from. `--json` mirrors the server's `uboot_shell` / `uboot_env` / `boot_chain_verdict` keys; `--gate-exposed` exits 1 on any exposed verdict. Runs entirely offline: a U-Boot environment holds a client's internal addressing, so nothing is uploaded. Reports what the kernel announced about mandatory access control, memory initialisation and KASLR, including the distinction between `selinux=0` on a command line (switched off) and `selinux=0` under `Unknown command line parameters:` (not compiled in at all). Exits 3 only when the capture yields neither, because "could not assess" must not look like "nothing wrong". | | `bootintel share ` | Print a bootintel.com share URL with the log embedded via lz-string compression. Nothing is uploaded — the log lives in the URL itself. | | `bootintel ports` | List serial ports on this machine with USB VID/PID + product info when known. | +| `bootintel mcp` | Serve the offline analysis to an MCP client over stdio, so your own assistant can read boot logs. Four read-only tools: `scan_log`, `boot_chain_verdict`, `list_detectors`, `sample_log`. Nothing is uploaded and no tool opens a serial port or writes to a device. `--list-tools` prints the definitions without serving. See [Using it from an assistant](#using-it-from-an-assistant). | | `bootintel version` | Version, detector count, build metadata. | | `bootintel term ` | Interactive picocom-shaped UART terminal. `--baud` / `--data-bits` / `--parity` / `--stop-bits` / `--flow-control` for serial config. `--log-file ` captures raw bytes in parallel. Ctrl-A q to quit, Ctrl-A ? for help, Ctrl-A Ctrl-A to send literal 0x01. | | `bootintel analyze ` | Term + streaming client-side detector analysis. Findings surface as `[bootintel] ● Bootloader: U-Boot 2020.10` inline lines interleaved with the raw serial stream. All `term` flags plus `--no-live-display` to suppress inline output. Extra Ctrl-A hotkeys: `l` toggle live display, `c` clear + re-scan, `s` save findings JSON, `u` copy share URL to clipboard, `f` full server-side analysis when armed with `--api` / `--api --preview`. `--tui` renders a split-screen ratatui dashboard instead of the inline picocom-shaped output; PgUp/PgDn/Home/End scroll the serial pane (needs the `tui` build feature). | diff --git a/crates/cli/src/cmd/mcp.rs b/crates/cli/src/cmd/mcp.rs new file mode 100644 index 0000000..c30d648 --- /dev/null +++ b/crates/cli/src/cmd/mcp.rs @@ -0,0 +1,490 @@ +//! `bootintel mcp` — expose the offline analysis to an MCP client over stdio. +//! +//! # Why this exists, and why it is local +//! +//! The reasoning half of reading a boot log is exactly what a model is good at: +//! given a component inventory, a boot-chain verdict and a kernel posture, what +//! should be looked at first. The reading half is what this binary already does. +//! +//! Running that as an MCP server on the operator's own machine keeps the one +//! property the rest of this tool is built around: nothing leaves. The model +//! that does the reasoning is the operator's own, under whatever agreement they +//! already work under, and BootIntel supplies tools rather than a destination +//! for a client's capture. A hosted version would have to receive the log, +//! which is the thing an NDA-bound consultancy cannot do. +//! +//! # Read-only, deliberately +//! +//! Every tool here reads. None of them opens a serial port, writes to a device, +//! or reaches the network. An agent that can type at a U-Boot prompt on a +//! client's only sample of a device is a different product with a different +//! risk, and `--interrupt-autoboot` already exists for a human who wants that, +//! with the safety rails that come with it. If that ever moves here, the +//! allowlist belongs in this process where the bytes are written, not in a +//! prompt: a model can be argued into anything, a refusal in code cannot. +//! +//! # Transport +//! +//! JSON-RPC 2.0 over stdin/stdout, one message per line, which is what MCP's +//! stdio transport is. No dependency is needed for that beyond serde_json, +//! which is already here. + +use anyhow::Result; +use clap::Args as ClapArgs; +use serde_json::{json, Value}; +use std::io::{BufRead, Write}; + +/// Protocol revisions this server knows how to speak. The client's requested +/// version is echoed when it is one of these, because a client that asked for a +/// version and got a different one has to guess what changed. +const SUPPORTED_PROTOCOLS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"]; +const DEFAULT_PROTOCOL: &str = "2024-11-05"; + +#[derive(ClapArgs, Debug)] +pub struct Args { + /// Print the tool definitions as JSON and exit, without serving. Useful for + /// checking what an MCP client will see without wiring one up. + #[arg(long)] + pub list_tools: bool, +} + +pub fn run(args: Args) -> Result<()> { + if args.list_tools { + let out = std::io::stdout(); + let mut w = out.lock(); + serde_json::to_writer_pretty(&mut w, &json!({ "tools": tool_definitions() }))?; + writeln!(w)?; + return Ok(()); + } + serve(std::io::stdin().lock(), std::io::stdout().lock()) +} + +/// The message loop. Split from `run` so the tests drive it with ordinary +/// readers and writers instead of a subprocess. +pub fn serve(reader: R, mut writer: W) -> Result<()> { + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let response = match serde_json::from_str::(&line) { + Ok(request) => handle(&request), + // A parse error still needs an id field, and there is none to read, + // so this is the one case where null is correct per JSON-RPC. + Err(e) => Some(error_response( + Value::Null, + -32700, + &format!("parse error: {e}"), + )), + }; + if let Some(response) = response { + serde_json::to_writer(&mut writer, &response)?; + writeln!(writer)?; + writer.flush()?; + } + } + Ok(()) +} + +/// `None` means the message was a notification, which by JSON-RPC takes no +/// reply. Answering one is a protocol error a client may or may not tolerate. +fn handle(request: &Value) -> Option { + let id = request.get("id").cloned(); + let method = request.get("method").and_then(Value::as_str).unwrap_or(""); + let params = request.get("params").cloned().unwrap_or(json!({})); + + match method { + "initialize" => { + let asked = params + .get("protocolVersion") + .and_then(Value::as_str) + .unwrap_or(DEFAULT_PROTOCOL); + let version = if SUPPORTED_PROTOCOLS.contains(&asked) { + asked + } else { + DEFAULT_PROTOCOL + }; + Some(result_response( + id?, + json!({ + "protocolVersion": version, + "capabilities": { "tools": { "listChanged": false } }, + "serverInfo": { + "name": "bootintel", + "version": env!("CARGO_PKG_VERSION"), + }, + "instructions": + "Reads serial boot logs offline. Nothing is uploaded and no tool here \ + writes to a device. Give scan_log or boot_chain_verdict the CONTENTS of \ + a capture; they do not read files themselves, so the caller decides what \ + is disclosed.", + }), + )) + } + // Notifications: no id, no reply. + m if m.starts_with("notifications/") => None, + "tools/list" => Some(result_response(id?, json!({ "tools": tool_definitions() }))), + "tools/call" => { + let id = id?; + let name = params.get("name").and_then(Value::as_str).unwrap_or(""); + let arguments = params.get("arguments").cloned().unwrap_or(json!({})); + match call_tool(name, &arguments) { + Ok(text) => Some(result_response( + id, + json!({ "content": [{ "type": "text", "text": text }], "isError": false }), + )), + // A tool failure is a RESULT with isError, not a protocol error: + // the call was well formed and the model should see why it + // failed rather than the client treating it as a transport + // fault. + Err(message) => Some(result_response( + id, + json!({ "content": [{ "type": "text", "text": message }], "isError": true }), + )), + } + } + "ping" => Some(result_response(id?, json!({}))), + _ => Some(error_response( + id.unwrap_or(Value::Null), + -32601, + &format!("unknown method: {method}"), + )), + } +} + +fn result_response(id: Value, result: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +fn error_response(id: Value, code: i64, message: &str) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) +} + +/// The capture is passed as text rather than a path on purpose: the caller +/// decides what is disclosed, and a tool that reads arbitrary files is a +/// different trust question from one that reads what it was handed. +fn log_argument(arguments: &Value) -> Result { + match arguments.get("log").and_then(Value::as_str) { + Some(log) if !log.trim().is_empty() => Ok(log.to_string()), + Some(_) => Err("the `log` argument is empty; pass the contents of a boot capture".into()), + None => { + Err("missing the `log` argument, which takes the contents of a boot capture".into()) + } + } +} + +fn call_tool(name: &str, arguments: &Value) -> Result { + match name { + "scan_log" => { + let log = log_argument(arguments)?; + let findings = bootintel_detectors::analyze(&log); + let rows: Vec = findings + .iter() + .map(|f| { + json!({ + "label": f.label, + "value": f.value, + "detail": f.detail, + "evidence": f.source, + "line_number": f.line_number, + "critical": bootintel_detectors::CRITICAL_LABELS.contains(&f.label.as_str()), + }) + }) + .collect(); + Ok(serde_json::to_string_pretty(&json!({ + "findings": rows, + "finding_count": rows.len(), + // Said explicitly because an empty list is ambiguous: a capture + // that matched nothing is not a device with nothing wrong. + "note": if rows.is_empty() { + "No detector matched. That means this capture was not recognised, not that \ + the device is clean: the capture may have started after the boot banner, or \ + the baud rate may have been wrong." + } else { + "Findings are what the detectors recognised in this capture. Absence of a \ + finding is not evidence of absence on the device." + }, + })) + .map_err(|e| e.to_string())?) + } + "boot_chain_verdict" => { + let log = log_argument(arguments)?; + let a = bootintel_detectors::boot_chain::assess(&log); + let hardening = bootintel_detectors::os_hardening::parse(&log); + let verdicts: Vec = a + .verdicts + .iter() + .map(|v| { + json!({ + "title": v.title, + "state": v.state, + "severity": v.severity, + "detail": v.detail, + "read_from": v.evidence, + "remediation": v.remediation, + }) + }) + .collect(); + Ok(serde_json::to_string_pretty(&json!({ + "session_reached": a.session.reached, + "environment_variables": a.session.env.len(), + "verdicts": verdicts, + "image_check": a.integrity.image_check, + "image_check_result": a.integrity.image_check_result, + "hab_fuse": a.integrity.hab_fuse, + "kaslr": hardening.kaslr, + "kaslr_reason": hardening.kaslr_reason, + "lsm": hardening.lsm, + "mandatory_access_control": hardening.mac_modules, + "note": + "Every verdict names the value it was read from. A protection this capture \ + does not mention is unknown, not absent: U-Boot prints only what is set, so \ + absence is never reported as hardened.", + })) + .map_err(|e| e.to_string())?) + } + "list_detectors" => Ok(serde_json::to_string_pretty(&json!({ + "detectors": bootintel_detectors::detector_labels(), + "critical": bootintel_detectors::CRITICAL_LABELS, + })) + .map_err(|e| e.to_string())?), + "sample_log" => Ok(bootintel_detectors::SAMPLE.to_string()), + other => Err(format!( + "unknown tool: {other}. Call tools/list for what this server offers." + )), + } +} + +fn log_schema(purpose: &str) -> Value { + json!({ + "type": "object", + "properties": { + "log": { "type": "string", "description": purpose } + }, + "required": ["log"], + }) +} + +fn tool_definitions() -> Vec { + vec![ + json!({ + "name": "scan_log", + "description": + "Identify the bootloader, kernel, SoC, init system and exposure signs in a serial \ + boot log. Runs entirely locally; nothing is uploaded. Absence of a finding means \ + the capture did not show it, not that the device lacks it.", + "inputSchema": log_schema("The contents of a serial boot capture."), + }), + json!({ + "name": "boot_chain_verdict", + "description": + "Assess what the boot chain permits, from a capture containing a U-Boot session \ + (a `printenv` dump taken at the prompt) and/or a kernel boot. Reports whether \ + autoboot is interruptible, whether images are verified, whether a netboot path \ + is configured, and the kernel's hardening posture. Every entry names the value \ + it was read from.", + "inputSchema": log_schema( + "The contents of a capture. A `printenv` dump taken at the U-Boot prompt gives \ + the fullest answer; a plain boot log still yields the kernel posture."), + }), + json!({ + "name": "list_detectors", + "description": "List every detector this build recognises, and which count as critical exposures.", + "inputSchema": { "type": "object", "properties": {} }, + }), + json!({ + "name": "sample_log", + "description": + "Return a short example boot log, for trying the other tools without a device to hand.", + "inputSchema": { "type": "object", "properties": {} }, + }), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::BufReader; + + /// Drive the server with a script of messages and collect the replies. + fn exchange(messages: &[&str]) -> Vec { + let input = messages.join("\n") + "\n"; + let mut output: Vec = Vec::new(); + serve(BufReader::new(input.as_bytes()), &mut output).expect("server loop"); + String::from_utf8(output) + .expect("utf8") + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str(l).expect("each reply is one JSON object per line")) + .collect() + } + + fn call(tool: &str, arguments: Value) -> Value { + let msg = json!({ + "jsonrpc": "2.0", "id": 9, "method": "tools/call", + "params": { "name": tool, "arguments": arguments }, + }); + exchange(&[&msg.to_string()]).remove(0) + } + + fn call_text(tool: &str, arguments: Value) -> String { + call(tool, arguments)["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default() + .to_string() + } + + #[test] + fn initialize_echoes_a_version_the_client_asked_for() { + for asked in SUPPORTED_PROTOCOLS { + let msg = json!({"jsonrpc":"2.0","id":1,"method":"initialize", + "params":{"protocolVersion": asked}}); + let reply = exchange(&[&msg.to_string()]).remove(0); + assert_eq!(reply["result"]["protocolVersion"], **asked); + } + } + + #[test] + fn an_unknown_protocol_version_falls_back_rather_than_failing() { + let msg = json!({"jsonrpc":"2.0","id":1,"method":"initialize", + "params":{"protocolVersion":"1999-01-01"}}); + let reply = exchange(&[&msg.to_string()]).remove(0); + assert_eq!(reply["result"]["protocolVersion"], DEFAULT_PROTOCOL); + } + + /// JSON-RPC notifications take no reply. Sending one anyway is a protocol + /// error that some clients tolerate and others do not, which makes it the + /// kind of bug that works on the machine it was written on. + #[test] + fn a_notification_gets_no_reply() { + let replies = exchange(&[ + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{}}"#, + ]); + assert!(replies.is_empty(), "replied to a notification: {replies:?}"); + } + + #[test] + fn the_tool_set_is_read_only() { + // The guard that matters. Every tool here reads; none opens a serial + // port or writes to a device. An agent able to type at a U-Boot prompt + // on a client's only sample is a different product with a different + // risk, so adding one has to be a deliberate edit to this list rather + // than something that arrives unnoticed. + let names: Vec = tool_definitions() + .iter() + .map(|t| t["name"].as_str().unwrap_or_default().to_string()) + .collect(); + assert_eq!( + names, + vec![ + "scan_log", + "boot_chain_verdict", + "list_detectors", + "sample_log" + ], + ); + let blob = serde_json::to_string(&tool_definitions()).unwrap(); + for forbidden in ["write", "send", "port", "interrupt", "flash", "erase"] { + assert!( + !blob + .to_lowercase() + .contains(&format!("\"name\":\"{forbidden}")), + "a tool named for a write operation appeared: {forbidden}" + ); + } + } + + #[test] + fn every_tool_declares_a_schema() { + for tool in tool_definitions() { + assert_eq!(tool["inputSchema"]["type"], "object", "{tool}"); + assert!( + tool["description"].as_str().unwrap_or_default().len() > 40, + "{tool}" + ); + } + } + + /// A tool failure is a result carrying isError, not a transport error. The + /// call was well formed; the model needs to see why it failed rather than + /// the client treating it as a broken connection. + #[test] + fn a_missing_argument_is_a_tool_error_not_a_protocol_error() { + let reply = call("scan_log", json!({})); + assert!( + reply.get("error").is_none(), + "returned a protocol error: {reply}" + ); + assert_eq!(reply["result"]["isError"], true); + assert!(reply["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default() + .contains("log")); + } + + #[test] + fn an_unknown_method_is_a_protocol_error() { + let reply = exchange(&[r#"{"jsonrpc":"2.0","id":4,"method":"does/not/exist"}"#]).remove(0); + assert_eq!(reply["error"]["code"], -32601); + } + + #[test] + fn malformed_input_does_not_kill_the_session() { + let replies = exchange(&["{not json", r#"{"jsonrpc":"2.0","id":5,"method":"ping"}"#]); + assert_eq!(replies[0]["error"]["code"], -32700); + // The session survives: a client that sends one bad line should not + // have to reconnect. + assert_eq!(replies[1]["id"], 5); + } + + #[test] + fn scan_log_reports_what_it_recognised() { + let text = call_text("scan_log", json!({ "log": bootintel_detectors::SAMPLE })); + let body: Value = serde_json::from_str(&text).unwrap(); + assert!(body["finding_count"].as_u64().unwrap_or(0) > 0, "{text}"); + } + + /// The honesty property has to survive the trip through a model. An empty + /// finding list read without this note is a clean bill of health, which is + /// the opposite of what it means. + #[test] + fn an_empty_result_says_what_empty_means() { + let text = call_text("scan_log", json!({ "log": "nothing recognisable here\n" })); + assert!(text.contains("not that the device is clean"), "{text}"); + } + + #[test] + fn the_verdict_carries_the_value_it_was_read_from() { + let text = call_text( + "boot_chain_verdict", + json!({ + "log": "hab fuse not enabled\n=> printenv\nbootdelay=3\nverify=no\n\ + Environment size: 40/65532 bytes\n", + }), + ); + let body: Value = serde_json::from_str(&text).unwrap(); + let anchor = body["verdicts"] + .as_array() + .unwrap() + .iter() + .find(|v| v["title"] == "Secure boot anchor") + .expect("the anchor verdict"); + assert_eq!(anchor["read_from"], "hab fuse not enabled"); + assert!(body["note"] + .as_str() + .unwrap_or_default() + .contains("unknown, not absent")); + } + + #[test] + fn a_log_with_nothing_in_it_yields_no_verdicts() { + let text = call_text( + "boot_chain_verdict", + json!({ "log": "Booting from flash...\n" }), + ); + let body: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(body["session_reached"], false); + assert!(body["verdicts"].as_array().unwrap().is_empty()); + } +} diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rs index 9fedf0b..14c71ca 100644 --- a/crates/cli/src/cmd/mod.rs +++ b/crates/cli/src/cmd/mod.rs @@ -15,6 +15,7 @@ pub mod history; pub mod init; pub mod login; pub mod manpage; +pub mod mcp; pub mod ports; pub mod replay; pub mod scan; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 79c5392..6fcae8d 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -99,6 +99,10 @@ enum Cmd { Share(cmd::share::Args), /// List serial ports available on this machine. Ports(cmd::ports::Args), + /// Serve the offline analysis to an MCP client over stdio, so your own + /// assistant can read boot logs. Read-only: no tool opens a port or writes + /// to a device, and nothing is uploaded. + Mcp(cmd::mcp::Args), /// Scan every log file in a directory and print a per-file + /// rollup report. Text / JSON / CSV output. Non-recursive by /// default; --recursive walks into subdirs. @@ -195,6 +199,7 @@ fn main() -> Result<()> { Cmd::Analyze(args) => cmd::analyze::run(args), Cmd::Share(args) => cmd::share::run(args), Cmd::Ports(args) => cmd::ports::run(args), + Cmd::Mcp(args) => cmd::mcp::run(args), Cmd::Batch(args) => cmd::batch::run(args), Cmd::Diff(args) => cmd::diff::run(args), Cmd::Watch(args) => cmd::watch::run(args),