use std::path::PathBuf;
use rmcp::{
handler::server::wrapper::{Json, Parameters},
model::{
Implementation, ListResourceTemplatesResult, ListResourcesResult, PaginatedRequestParams,
ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents,
ServerCapabilities, ServerInfo,
},
schemars, tool, tool_handler, tool_router,
transport::stdio,
ErrorData, ServerHandler, ServiceExt,
};
use serde::{Deserialize, Serialize};
use sysknife_types::{ApprovalReceipt, RiskLevel, TransactionId};
use sysknife_brain::config::BrainConfig;
use sysknife_brain::planner::LlmPlanner;
use sysknife_brain::state_client::StateClient as _;
use crate::client::{DaemonClient, DescribeInfo};
use crate::error::CliError;
use crate::runner::{resolve_socket_target, verify_postgres, verify_sqlite, Verifier};
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct PlanInput {
pub intent: String,
}
#[derive(Debug, Default, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(default)]
pub struct PlanStepOutput {
pub action_name: String,
pub summary: String,
pub risk_level: String,
pub params: serde_json::Value,
pub command: String,
pub transaction_id: String,
pub warnings: Vec<String>,
pub current_state: serde_json::Value,
pub proposed_change: serde_json::Value,
pub expected_side_effects: Vec<String>,
pub reboot_required: bool,
pub rollback_available: bool,
}
fn merge_preview_into_step(step: &mut PlanStepOutput, preview: &sysknife_types::PreviewEnvelope) {
step.risk_level = match preview.risk_level {
RiskLevel::Low => "low",
RiskLevel::Medium => "medium",
RiskLevel::High => "high",
}
.to_string();
step.warnings = preview.warnings.clone();
step.current_state = preview.current_state.clone();
step.proposed_change = preview.proposed_change.clone();
step.expected_side_effects = preview.expected_side_effects.clone();
step.reboot_required = preview.reboot_required;
step.rollback_available = preview.rollback_available;
}
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PlanOutput {
pub intent: String,
pub summary: String,
pub explanation: String,
pub steps: Vec<PlanStepOutput>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct StepToExecute {
pub transaction_id: String,
pub action_name: String,
pub params: serde_json::Value,
pub approval_receipt: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ExecuteInput {
pub steps: Vec<StepToExecute>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct StepResult {
pub action_name: String,
pub status: String,
pub summary: String,
pub output: Vec<String>,
pub warnings: Vec<String>,
pub needs_reboot: bool,
pub transaction_id: String,
pub rollback_ref: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct ExecuteOutput {
pub steps: Vec<StepResult>,
pub needs_reboot: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct HistoryInput {
pub status: Option<String>,
pub action: Option<String>,
pub since: Option<String>,
pub limit: Option<u32>,
}
#[derive(Debug, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
#[serde(default)]
pub struct HistoryEntry {
pub transaction_id: String,
pub action: String,
pub status: String,
pub summary: String,
pub created_at: Option<String>,
pub risk_level: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct HistoryOutput {
pub entries: Vec<HistoryEntry>,
}
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct DoctorReport {
pub daemon_socket: String,
pub daemon_reachable: bool,
pub brain_provider: String,
pub brain_model: String,
pub distro: String,
pub audit_db_path: String,
pub audit_chain_status: String,
pub warnings: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct AuditVerifyReport {
pub status: String,
pub rows_checked: u64,
pub first_broken_seq: Option<u64>,
pub first_broken_transaction_id: Option<String>,
pub expected: Option<String>,
pub actual: Option<String>,
pub reason: Option<String>,
pub events_checked: u64,
pub approval_events_status: String,
pub binding_status: String,
pub backend: String,
pub chain_status: String,
pub rows_censused: Option<u64>,
pub attributed_rows: Option<u64>,
pub unattributed_rows: Option<u64>,
pub rows_without_principal: Option<u64>,
pub rows_unattested: Option<u64>,
pub rows_naming_no_account: Option<u64>,
pub daemon_socket_caveat: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SysknifeMcpServer;
const SYSKNIFE_DISCOVERY_URI: &str = "sysknife://about";
const SYSKNIFE_DISCOVERY_NAME: &str = "about";
const SYSKNIFE_DISCOVERY_TITLE: &str = "SysKnife MCP server";
const SYSKNIFE_DISCOVERY_DESCRIPTION: &str = "Discovery resource for Codex and other MCP clients.";
const SYSKNIFE_DISCOVERY_BODY: &str = "SysKnife exposes a small tool set for planning and executing Linux system administration tasks.\n\nUse `sysknife_plan` first and present the plan to the user. The user must run `sysknife approve <transaction-id>` in a terminal for every accepted step. Call `sysknife_execute` only with the one-time receipts printed by those commands. MCP cannot issue approval receipts.\n\nAvailable read-only tools: `sysknife_history`, `sysknife_doctor`, and `sysknife_audit_verify`.";
fn sysknife_about_resource() -> Resource {
rmcp::model::Resource::new(SYSKNIFE_DISCOVERY_URI, SYSKNIFE_DISCOVERY_NAME)
.with_title(SYSKNIFE_DISCOVERY_TITLE)
.with_description(SYSKNIFE_DISCOVERY_DESCRIPTION)
.with_mime_type("text/plain")
}
#[tool_router]
impl SysknifeMcpServer {
#[tool(
description = "Plan a Linux system administration intent. Returns typed steps with risk levels, resolved commands, and daemon transaction IDs. IMPORTANT: Present the plan, then STOP. The user must run `sysknife approve <transaction-id>` in a real terminal for each accepted step. Do not execute from chat approval alone."
)]
async fn sysknife_plan(
&self,
Parameters(PlanInput { intent }): Parameters<PlanInput>,
) -> Result<Json<PlanOutput>, ErrorData> {
let value = plan_intent_inner(&intent)
.await
.map_err(|e| ErrorData::internal_error(e, None))?;
let mut output: PlanOutput = serde_json::from_value(value).map_err(|e| {
ErrorData::internal_error(format!("output deserialization error: {e}"), None)
})?;
let client = DaemonClient::new(resolve_socket_target());
enrich_with_commands(&mut output, &client)
.await
.map_err(|e| ErrorData::internal_error(e, None))?;
Ok(Json(output))
}
#[tool(
description = "Execute exact steps produced by sysknife_plan. Every step requires a one-time receipt from an explicit `sysknife approve <transaction-id>` CLI confirmation; MCP cannot approve its own mutations."
)]
async fn sysknife_execute(
&self,
Parameters(ExecuteInput { steps }): Parameters<ExecuteInput>,
) -> Result<Json<ExecuteOutput>, ErrorData> {
execute_steps_inner(steps)
.await
.map(Json)
.map_err(|e| ErrorData::internal_error(e, None))
}
#[tool(
description = "List past SysKnife audit-log entries. Read-only and safe to call without prior sysknife_plan. Filters: status (succeeded/failed/canceled/...), action (canonical action name), since (UTC RFC 3339 timestamp), limit (default 20). Returns a list of HistoryEntry rows."
)]
async fn sysknife_history(
&self,
Parameters(input): Parameters<HistoryInput>,
) -> Result<Json<HistoryOutput>, ErrorData> {
history_inner(input)
.await
.map(|entries| Json(HistoryOutput { entries }))
.map_err(|e| ErrorData::internal_error(e, None))
}
#[tool(
description = "Diagnose SysKnife: pings the daemon, reports the configured brain provider/model, the audit DB path, and a quick audit-chain status (intact/broken/unknown). Read-only and safe to call without prior sysknife_plan."
)]
async fn sysknife_doctor(&self) -> Result<Json<DoctorReport>, ErrorData> {
Ok(Json(doctor_inner().await))
}
#[tool(
description = "Verify the tamper-evident Ed25519-signed hash chain over the audit log. Returns status (intact/broken/cannot_verify), rows_checked, and, on broken, the first offending row. Read-only and safe to call without prior sysknife_plan."
)]
async fn sysknife_audit_verify(&self) -> Result<Json<AuditVerifyReport>, ErrorData> {
Ok(Json(audit_verify_inner().await))
}
}
fn sysknife_implementation() -> Implementation {
let mut implementation = Implementation::from_build_env();
implementation.name = "sysknife".to_string();
implementation.version = env!("CARGO_PKG_VERSION").to_string();
implementation
}
#[tool_handler]
impl ServerHandler for SysknifeMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build(),
)
.with_server_info(sysknife_implementation())
.with_instructions(
"SysKnife provides planning and execution tools for Linux system administration.",
)
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<ListResourcesResult, ErrorData> {
Ok(ListResourcesResult {
resources: vec![sysknife_about_resource()],
next_cursor: None,
meta: None,
})
}
async fn list_resource_templates(
&self,
_request: Option<PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<ListResourceTemplatesResult, ErrorData> {
Ok(ListResourceTemplatesResult {
resource_templates: Vec::new(),
next_cursor: None,
meta: None,
})
}
async fn read_resource(
&self,
ReadResourceRequestParams { uri, .. }: ReadResourceRequestParams,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<ReadResourceResult, ErrorData> {
match uri.as_str() {
SYSKNIFE_DISCOVERY_URI => Ok(ReadResourceResult::new(vec![ResourceContents::text(
SYSKNIFE_DISCOVERY_BODY,
uri,
)])),
_ => Err(ErrorData::resource_not_found(
"resource_not_found",
Some(serde_json::json!({ "uri": uri })),
)),
}
}
}
async fn plan_intent_inner(intent: &str) -> Result<serde_json::Value, String> {
let config = BrainConfig::from_env().map_err(|e| format!("config error: {e}"))?;
let state_client = DaemonClient::new(resolve_socket_target());
let distro = sysknife_core::distro::detect().ok();
let mut planner = LlmPlanner::from_config(config, Box::new(state_client))
.map_err(|e| format!("planner init error: {e}"))?;
if let Some(ref d) = distro {
planner = planner.with_distro(crate::runner::distro_id_to_hint(d));
}
let plan = planner
.plan_intent(intent)
.await
.map_err(|e| format!("planning error: {e}"))?;
check_plan_steps_distro(
plan.steps().iter().map(|s| s.action_name()),
distro.as_ref(),
)?;
serde_json::to_value(&plan).map_err(|e| format!("serialization error: {e}"))
}
fn check_plan_steps_distro<'a>(
action_names: impl Iterator<Item = &'a str>,
distro: Option<&sysknife_core::distro::DistroId>,
) -> Result<(), String> {
for action_name in action_names {
crate::distro_routing::check_action_distro(action_name, distro)?;
}
Ok(())
}
async fn enrich_with_commands(
output: &mut PlanOutput,
client: &DaemonClient,
) -> Result<(), String> {
for step in &mut output.steps {
let DescribeInfo { command, .. } =
client
.describe(&step.action_name, &step.params)
.await
.map_err(|e| format!("describe failed for {}: {e}", step.action_name))?;
step.command = command;
let prepared = client
.preview(&step.action_name, &step.params)
.await
.map_err(|e| format!("preview failed for {}: {e}", step.action_name))?;
step.transaction_id = prepared.transaction_id.into_inner();
merge_preview_into_step(step, &prepared.preview);
}
Ok(())
}
async fn execute_steps_inner(steps: Vec<StepToExecute>) -> Result<ExecuteOutput, String> {
let client = DaemonClient::new(resolve_socket_target());
let mut results: Vec<StepResult> = Vec::new();
let mut plan_needs_reboot = false;
for step in steps {
let mut output_lines: Vec<String> = Vec::new();
let result = client
.execute(
&TransactionId::new(step.transaction_id.clone()),
&step.action_name,
&step.params,
&ApprovalReceipt::new(step.approval_receipt.clone()),
|line| output_lines.push(line.to_owned()),
)
.await
.map_err(|e| format!("execute error for {}: {e}", step.action_name))?;
let needs_reboot = result.needs_reboot;
if needs_reboot {
plan_needs_reboot = true;
}
let status = serde_json::to_value(result.status)
.ok()
.and_then(|v| v.as_str().map(String::from))
.expect("JobState always serializes to a JSON string");
let succeeded = matches!(result.status, sysknife_types::JobState::Succeeded);
results.push(StepResult {
action_name: step.action_name,
status,
summary: result.summary,
output: truncate_output(output_lines),
warnings: result.warnings,
needs_reboot,
transaction_id: result.transaction_id,
rollback_ref: result.rollback_ref,
});
if !succeeded {
break;
}
}
Ok(ExecuteOutput {
steps: results,
needs_reboot: plan_needs_reboot,
})
}
const OUTPUT_LINE_LIMIT: usize = 500;
fn truncate_output(mut lines: Vec<String>) -> Vec<String> {
if lines.len() > OUTPUT_LINE_LIMIT {
let dropped = lines.len() - OUTPUT_LINE_LIMIT;
lines.truncate(OUTPUT_LINE_LIMIT);
lines.push(format!("[truncated: {dropped} more lines omitted]"));
}
lines
}
const HISTORY_DEFAULT_LIMIT: u32 = 20;
async fn history_inner(input: HistoryInput) -> Result<Vec<HistoryEntry>, String> {
let HistoryInput {
status,
action,
since,
limit,
} = input;
let since_hours = match since.as_deref() {
None => None,
Some(s) => match crate::runner::since_to_hours(s) {
Some(h) => Some(h),
None => {
return Err(format!(
"since: {s:?} is not a valid past UTC RFC 3339 timestamp \
(accepted: 2026-01-15T10:30:00Z)"
));
}
},
};
let limit = limit.unwrap_or(HISTORY_DEFAULT_LIMIT);
let client = DaemonClient::new(resolve_socket_target());
let rows = tokio::task::spawn_blocking(move || {
client.query_history(
Some(limit),
status.as_deref(),
action.as_deref(),
since_hours,
)
})
.await
.map_err(|e| format!("join: {e}"))?
.map_err(|e| format!("daemon error: {e}"))?;
Ok(rows.into_iter().map(history_entry_from_row).collect())
}
fn history_entry_from_row(row: sysknife_daemon::transactions::JobHistoryEntry) -> HistoryEntry {
let risk_level = match row.risk_level {
RiskLevel::Low => "low",
RiskLevel::Medium => "medium",
RiskLevel::High => "high",
};
HistoryEntry {
transaction_id: row.transaction_id,
action: row.action_name,
status: format!("{:?}", row.status).to_lowercase(),
summary: row.summary,
created_at: Some(row.created_at),
risk_level: Some(risk_level.to_string()),
}
}
async fn doctor_inner() -> DoctorReport {
let mut warnings: Vec<String> = Vec::new();
let socket = resolve_socket_target();
let socket_label = socket.label();
let distro = match sysknife_core::distro::detect() {
Ok(d) => d.to_string(),
Err(e) => {
let label = format!("unknown ({})", e);
warnings.push(format!("distro detection failed: {e}"));
label
}
};
let client = DaemonClient::new(socket);
let daemon_reachable = match tokio::task::spawn_blocking(move || client.curated_state()).await {
Ok(Ok(_)) => true,
Ok(Err(e)) => {
warnings.push(format!("daemon unreachable: {e}"));
false
}
Err(e) => {
warnings.push(format!("daemon ping join error: {e}"));
false
}
};
let (brain_provider, brain_model) = match BrainConfig::from_env() {
Ok(cfg) => (
cfg.provider_name().to_string(),
cfg.model_name().to_string(),
),
Err(e) => {
warnings.push(format!("brain config unreadable: {e}"));
("unknown".to_string(), "unknown".to_string())
}
};
let lacs_config = sysknife_core::config::LacsConfig::load();
let audit_db_path = match lacs_config.storage.as_ref() {
Some(s) if s.backend.eq_ignore_ascii_case("postgres") => "postgres".to_string(),
_ => sysknife_core::default_database_path().display().to_string(),
};
let audit_chain_status = match audit_chain_quick_check(&lacs_config, &mut warnings).await {
VerifyOutcomeKind::Intact => "intact",
VerifyOutcomeKind::Broken => "broken",
VerifyOutcomeKind::Unknown => "unknown",
}
.to_string();
DoctorReport {
daemon_socket: socket_label,
daemon_reachable,
brain_provider,
brain_model,
distro,
audit_db_path,
audit_chain_status,
warnings,
}
}
enum VerifyOutcomeKind {
Intact,
Broken,
Unknown,
}
async fn audit_chain_quick_check(
lacs_config: &sysknife_core::config::LacsConfig,
warnings: &mut Vec<String>,
) -> VerifyOutcomeKind {
use sysknife_daemon::audit_chain::{AuditKey, BindingOutcome, VerifyOutcome};
let db_path = sysknife_core::default_database_path();
let key_path = std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| {
db_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("audit-key")
});
if !key_path.exists() {
warnings.push(format!("audit key not found at {}", key_path.display()));
return VerifyOutcomeKind::Unknown;
}
let key = match AuditKey::load_or_generate(&key_path) {
Ok(k) => k,
Err(e) => {
warnings.push(format!("audit key load failed: {e}"));
return VerifyOutcomeKind::Unknown;
}
};
let verifier = Verifier::Private(Box::new(key));
let outcome = match lacs_config.storage.as_ref() {
Some(s) if s.backend.eq_ignore_ascii_case("postgres") => {
verify_postgres(s, &verifier).await
}
_ => verify_sqlite(&db_path, &verifier).await,
};
for (label, sub) in [
("audit chain", &outcome.chain),
("approval-event chain", &outcome.events),
] {
if let VerifyOutcome::CannotVerify { reason } = sub {
warnings.push(format!("{label} cannot be verified: {reason}"));
}
}
if let BindingOutcome::MissingEvent {
transaction_seq, ..
} = &outcome.binding
{
warnings.push(format!(
"transaction seq={transaction_seq} commits to an approval event that no longer exists"
));
}
match outcome.exit_code() {
0 => VerifyOutcomeKind::Intact,
1 => VerifyOutcomeKind::Broken,
_ => VerifyOutcomeKind::Unknown,
}
}
async fn audit_verify_inner() -> AuditVerifyReport {
with_socket_caveat(
audit_verify_local_store().await,
crate::runner::remote_daemon_caveat_from_env(),
)
}
async fn audit_verify_local_store() -> AuditVerifyReport {
use sysknife_daemon::audit_chain::AuditKey;
let lacs_config = sysknife_core::config::LacsConfig::load();
let backend_label = match lacs_config.storage.as_ref() {
Some(s) if s.backend.eq_ignore_ascii_case("postgres") => "postgres".to_string(),
_ => sysknife_core::default_database_path().display().to_string(),
};
let db_path = sysknife_core::default_database_path();
let key_path = std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| {
db_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("audit-key")
});
if !key_path.exists() {
return cannot_verify_report(
backend_label,
format!(
"audit key not found at {}; the daemon generates this on first run, \
or set $SYSKNIFE_AUDIT_KEY_PATH",
key_path.display()
),
);
}
let key = match AuditKey::load_or_generate(&key_path) {
Ok(k) => k,
Err(e) => {
return cannot_verify_report(backend_label, format!("audit key load failed: {e}"));
}
};
let verifier = Verifier::Private(Box::new(key));
let outcome = match lacs_config.storage.as_ref() {
Some(s) if s.backend.eq_ignore_ascii_case("postgres") => {
verify_postgres(s, &verifier).await
}
_ => verify_sqlite(&db_path, &verifier).await,
};
outcome_to_report(outcome, backend_label)
}
fn outcome_label(outcome: &sysknife_daemon::audit_chain::VerifyOutcome) -> &'static str {
use sysknife_daemon::audit_chain::VerifyOutcome;
match outcome {
VerifyOutcome::Intact { .. } => "intact",
VerifyOutcome::Broken { .. } => "broken",
VerifyOutcome::CannotVerify { .. } => "cannot_verify",
}
}
fn outcome_to_report(
verification: sysknife_daemon::audit_chain::AuditVerification,
backend: String,
) -> AuditVerifyReport {
use sysknife_daemon::audit_chain::{BindingOutcome, VerifyOutcome};
let attribution = verification.attribution;
let chain_status = outcome_label(&verification.chain).to_string();
let events_checked = match &verification.events {
VerifyOutcome::Intact { rows_checked } | VerifyOutcome::Broken { rows_checked, .. } => {
*rows_checked
}
VerifyOutcome::CannotVerify { .. } => 0,
};
let approval_events_status = outcome_label(&verification.events).to_string();
let binding_status = match &verification.binding {
BindingOutcome::Consistent { .. } => "consistent",
BindingOutcome::MissingEvent { .. } => "missing_event",
}
.to_string();
let overall = verification.exit_code();
let mut report = match verification.chain {
VerifyOutcome::Intact { rows_checked } => AuditVerifyReport {
status: "intact".to_string(),
rows_checked,
first_broken_seq: None,
first_broken_transaction_id: None,
expected: None,
actual: None,
reason: None,
backend,
events_checked,
approval_events_status,
binding_status,
chain_status: chain_status.clone(),
rows_censused: attribution.map(|c| c.rows()),
attributed_rows: attribution.map(|c| c.named()),
unattributed_rows: attribution.map(|c| c.attribution_failed()),
rows_without_principal: attribution.map(|c| c.not_recorded()),
rows_unattested: attribution.map(|c| c.unattested()),
rows_naming_no_account: attribution.map(|c| c.unnamed()),
daemon_socket_caveat: None,
},
VerifyOutcome::Broken {
rows_checked,
first_broken_seq,
first_broken_transaction_id,
expected,
actual,
} => AuditVerifyReport {
status: "broken".to_string(),
rows_checked,
first_broken_seq: Some(first_broken_seq),
first_broken_transaction_id: Some(first_broken_transaction_id),
expected: Some(expected),
actual: Some(actual),
reason: None,
backend,
events_checked,
approval_events_status,
binding_status,
chain_status: chain_status.clone(),
rows_censused: attribution.map(|c| c.rows()),
attributed_rows: attribution.map(|c| c.named()),
unattributed_rows: attribution.map(|c| c.attribution_failed()),
rows_without_principal: attribution.map(|c| c.not_recorded()),
rows_unattested: attribution.map(|c| c.unattested()),
rows_naming_no_account: attribution.map(|c| c.unnamed()),
daemon_socket_caveat: None,
},
VerifyOutcome::CannotVerify { reason } => {
let mut r = cannot_verify_report(backend, reason);
r.events_checked = events_checked;
r.approval_events_status = approval_events_status;
r.binding_status = binding_status;
r.chain_status = chain_status;
r.rows_censused = attribution.map(|c| c.rows());
r.attributed_rows = attribution.map(|c| c.named());
r.unattributed_rows = attribution.map(|c| c.attribution_failed());
r.rows_without_principal = attribution.map(|c| c.not_recorded());
r.rows_unattested = attribution.map(|c| c.unattested());
r.rows_naming_no_account = attribution.map(|c| c.unnamed());
r
}
};
if report.status == "intact" {
report.status = match overall {
0 => "intact",
1 => "broken",
_ => "cannot_verify",
}
.to_string();
}
report
}
fn with_socket_caveat(mut report: AuditVerifyReport, caveat: Option<String>) -> AuditVerifyReport {
report.daemon_socket_caveat = caveat;
report
}
fn cannot_verify_report(backend: String, reason: String) -> AuditVerifyReport {
AuditVerifyReport {
status: "cannot_verify".to_string(),
rows_checked: 0,
first_broken_seq: None,
first_broken_transaction_id: None,
expected: None,
actual: None,
reason: Some(reason),
backend,
events_checked: 0,
approval_events_status: "cannot_verify".to_string(),
binding_status: "consistent".to_string(),
chain_status: "cannot_verify".to_string(),
rows_censused: None,
attributed_rows: None,
unattributed_rows: None,
rows_without_principal: None,
rows_unattested: None,
rows_naming_no_account: None,
daemon_socket_caveat: None,
}
}
pub async fn run_mcp_server() -> Result<(), CliError> {
let service = SysknifeMcpServer
.serve(stdio())
.await
.map_err(|e| CliError::ExecutionFailed(format!("MCP server error: {e}")))?;
service
.waiting()
.await
.map_err(|e| CliError::ExecutionFailed(format!("MCP server wait error: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_report_carries_the_which_machine_caveat() {
let report = with_socket_caveat(
cannot_verify_report("/tmp/store.sqlite".into(), "no key".into()),
Some("NOTE: SYSKNIFE_SOCKET is /tmp/sysknife-web01.sock".into()),
);
let caveat = report
.daemon_socket_caveat
.as_deref()
.expect("the caveat must survive onto the report");
assert!(caveat.contains("/tmp/sysknife-web01.sock"));
let json = serde_json::to_value(&report).expect("report serializes");
assert!(
json.get("daemon_socket_caveat").is_some(),
"and must be visible in the tool's JSON output, got: {json}"
);
}
#[test]
fn a_local_daemon_leaves_the_caveat_empty() {
let report = with_socket_caveat(
cannot_verify_report("/tmp/store.sqlite".into(), "no key".into()),
None,
);
assert!(report.daemon_socket_caveat.is_none());
}
fn verification_with(
chain: sysknife_daemon::audit_chain::VerifyOutcome,
attribution: Option<sysknife_daemon::audit_chain::AttributionCensus>,
) -> sysknife_daemon::audit_chain::AuditVerification {
use sysknife_daemon::audit_chain::{AuditVerification, BindingOutcome, VerifyOutcome};
AuditVerification {
chain,
events: VerifyOutcome::Intact { rows_checked: 0 },
binding: BindingOutcome::Consistent {
bindings_checked: 0,
},
attribution,
}
}
#[test]
fn the_report_carries_every_attribution_count_on_both_verdicts() {
use sysknife_daemon::audit_chain::{AttributionCensus, VerifyOutcome};
let census = AttributionCensus::from_counts_for_tests(6, 1, 2, 3);
for chain in [
VerifyOutcome::Intact { rows_checked: 12 },
VerifyOutcome::Broken {
rows_checked: 4,
first_broken_seq: 5,
first_broken_transaction_id: "tx5".to_string(),
expected: "x".to_string(),
actual: "y".to_string(),
},
] {
let report = outcome_to_report(
verification_with(chain.clone(), Some(census)),
"/tmp/store.sqlite".to_string(),
);
assert_eq!(report.attributed_rows, Some(6), "chain: {chain:?}");
assert_eq!(report.unattributed_rows, Some(1), "chain: {chain:?}");
assert_eq!(report.rows_without_principal, Some(2), "chain: {chain:?}");
assert_eq!(report.rows_unattested, Some(3), "chain: {chain:?}");
assert_eq!(report.rows_naming_no_account, Some(6), "chain: {chain:?}");
assert_eq!(report.rows_censused, Some(12), "chain: {chain:?}");
}
}
#[test]
fn a_cannot_verify_report_keeps_the_census_it_was_given() {
use sysknife_daemon::audit_chain::{AttributionCensus, VerifyOutcome};
let report = outcome_to_report(
verification_with(
VerifyOutcome::CannotVerify {
reason: "row seq=9 declares chain_version=4".to_string(),
},
Some(AttributionCensus::from_counts_for_tests(5, 2, 9, 0)),
),
"/tmp/store.sqlite".to_string(),
);
assert_eq!(report.status, "cannot_verify");
assert_eq!(
report.chain_status, "cannot_verify",
"the chain's own verdict must be recoverable, not only the aggregate"
);
assert_eq!(
report.attributed_rows,
Some(5),
"the census survived the read, so the report must not invent zeros"
);
assert_eq!(report.unattributed_rows, Some(2));
assert_eq!(report.rows_without_principal, Some(9));
assert_eq!(report.rows_censused, Some(16));
}
#[test]
fn a_broken_event_chain_does_not_make_the_transaction_chain_look_broken() {
use sysknife_daemon::audit_chain::{
AttributionCensus, AuditVerification, BindingOutcome, VerifyOutcome,
};
let report = outcome_to_report(
AuditVerification {
chain: VerifyOutcome::Intact { rows_checked: 3 },
events: VerifyOutcome::Broken {
rows_checked: 0,
first_broken_seq: 1,
first_broken_transaction_id: "ev1".to_string(),
expected: "x".to_string(),
actual: "y".to_string(),
},
binding: BindingOutcome::Consistent {
bindings_checked: 0,
},
attribution: Some(AttributionCensus::from_counts_for_tests(3, 0, 0, 0)),
},
"/tmp/store.sqlite".to_string(),
);
assert_eq!(
report.status, "broken",
"the headline must still reflect the worst of the three checks"
);
assert_eq!(
report.chain_status, "intact",
"while the transaction chain's own verdict stays recoverable"
);
assert_eq!(report.attributed_rows, Some(3));
assert_eq!(
report.rows_censused, report.attributed_rows,
"nothing was counted that was not also checked here"
);
}
#[test]
fn a_report_for_a_store_that_was_never_read_nulls_every_count() {
let report = cannot_verify_report("/tmp/store.sqlite".into(), "no key".into());
assert!(report.attributed_rows.is_none());
assert!(report.unattributed_rows.is_none());
assert!(report.rows_without_principal.is_none());
assert!(report.rows_unattested.is_none());
assert!(report.rows_naming_no_account.is_none());
assert!(report.rows_censused.is_none());
}
fn sample_preview() -> sysknife_types::PreviewEnvelope {
sysknife_types::PreviewEnvelope {
summary: "install vim 2:9.1".into(),
risk_level: RiskLevel::Medium,
current_state: serde_json::json!({"installed": false}),
proposed_change: serde_json::json!({"action": "AptInstall", "package": "vim"}),
expected_side_effects: vec!["apt lists updated".into()],
reboot_required: true,
rollback_available: true,
warnings: vec!["a reboot is required".into()],
request_hash: sysknife_types::RequestHash::new("deadbeef".to_string()),
}
}
#[test]
fn a_plan_step_carries_the_whole_preview_not_a_slice_of_it() {
let mut step = PlanStepOutput::default();
merge_preview_into_step(&mut step, &sample_preview());
assert_eq!(step.risk_level, "medium");
assert_eq!(step.proposed_change["package"], "vim");
assert_eq!(step.current_state["installed"], false);
assert_eq!(step.expected_side_effects, vec!["apt lists updated"]);
assert!(step.reboot_required, "reboot requirement must survive");
assert!(
step.rollback_available,
"rollback availability must survive"
);
assert_eq!(step.warnings, vec!["a reboot is required"]);
}
#[test]
fn merging_a_preview_overwrites_a_planner_supplied_risk() {
let mut step = PlanStepOutput {
risk_level: "low".into(),
..PlanStepOutput::default()
};
let mut preview = sample_preview();
preview.risk_level = RiskLevel::High;
merge_preview_into_step(&mut step, &preview);
assert_eq!(step.risk_level, "high");
}
#[test]
fn the_plan_step_schema_exposes_the_preview_fields() {
let schema =
serde_json::to_value(schemars::schema_for!(PlanStepOutput)).expect("schema serializes");
let props = schema["properties"]
.as_object()
.expect("PlanStepOutput schema has properties");
for field in [
"current_state",
"proposed_change",
"expected_side_effects",
"reboot_required",
"rollback_available",
"warnings",
] {
assert!(
props.contains_key(field),
"PlanStep schema must expose {field}; an agent cannot relay what it \
cannot see. Present: {:?}",
props.keys().collect::<Vec<_>>()
);
}
}
#[test]
fn the_step_result_schema_exposes_rollback_ref() {
let schema =
serde_json::to_value(schemars::schema_for!(StepResult)).expect("schema serializes");
let props = schema["properties"]
.as_object()
.expect("StepResult schema has properties");
assert!(
props.contains_key("rollback_ref"),
"StepResult must report what was rolled back; present: {:?}",
props.keys().collect::<Vec<_>>()
);
}
#[test]
fn check_plan_steps_distro_rejects_mismatched_action() {
let distro = sysknife_core::distro::DistroId::Fedora { version: 41 };
let result = check_plan_steps_distro(["AptInstall"].into_iter(), Some(&distro));
assert!(
result.is_err(),
"AptInstall on Fedora must be rejected by the MCP planning path too"
);
assert!(result.unwrap_err().contains("Debian-family"));
}
#[test]
fn check_plan_steps_distro_allows_generic_action() {
let distro = sysknife_core::distro::DistroId::Ubuntu {
major: 24,
minor: 4,
};
let result = check_plan_steps_distro(["GetMemoryInfo"].into_iter(), Some(&distro));
assert!(result.is_ok());
let atomic = sysknife_core::distro::DistroId::FedoraSilverblue { version: 41 };
let result = check_plan_steps_distro(["GetMemoryInfo"].into_iter(), Some(&atomic));
assert!(
result.is_ok(),
"a generic action is generic on both families"
);
}
#[test]
fn check_plan_steps_distro_allows_all_when_distro_unknown() {
let result = check_plan_steps_distro(["AptInstall", "RebaseSystem"].into_iter(), None);
assert!(result.is_ok());
}
#[test]
fn check_plan_steps_distro_stops_at_first_mismatched_step() {
let distro = sysknife_core::distro::DistroId::Ubuntu {
major: 24,
minor: 4,
};
let result = check_plan_steps_distro(
["GetSystemState", "RebaseSystem", "AptInstall"].into_iter(),
Some(&distro),
);
assert!(result.is_err(), "RebaseSystem on Ubuntu must be rejected");
assert!(result.unwrap_err().contains("Fedora-family"));
}
#[tokio::test]
async fn enrich_with_commands_fails_closed_when_daemon_unreachable() {
let mut output = PlanOutput {
intent: "set the timezone".to_string(),
summary: "plan".to_string(),
explanation: String::new(),
steps: vec![PlanStepOutput {
action_name: "SetTimezone".to_string(),
params: serde_json::json!({ "timezone": "UTC" }),
..Default::default()
}],
};
let client = DaemonClient::new(std::path::PathBuf::from(
"/nonexistent/sysknife-unreachable.sock",
));
let result = enrich_with_commands(&mut output, &client).await;
assert!(
result.is_err(),
"enrich must fail closed when the daemon is unreachable"
);
assert!(
output.steps[0].transaction_id.is_empty(),
"no synthetic transaction_id may be presented for an un-previewed step"
);
}
#[test]
fn rmcp_tool_router_registers_every_sysknife_tool() {
let router = SysknifeMcpServer::tool_router();
let tools = router.list_all();
let names: std::collections::HashSet<String> =
tools.iter().map(|t| t.name.to_string()).collect();
for expected in [
"sysknife_plan",
"sysknife_execute",
"sysknife_history",
"sysknife_doctor",
"sysknife_audit_verify",
] {
assert!(
names.contains(expected),
"MCP tool registry missing {expected}: registered = {names:?}"
);
}
for t in &tools {
assert!(
t.description.as_ref().is_some_and(|d| !d.is_empty()),
"tool {} has empty description",
t.name
);
}
}
#[test]
fn rmcp_sysknife_plan_description_warns_to_stop_after_planning() {
let router = SysknifeMcpServer::tool_router();
let tools = router.list_all();
let plan = tools
.iter()
.find(|t| t.name == "sysknife_plan")
.expect("sysknife_plan must be registered");
let desc = plan
.description
.as_ref()
.expect("plan tool has a description");
assert!(
desc.to_lowercase().contains("stop"),
"sysknife_plan description must tell the agent to STOP after planning; got: {desc}"
);
}
#[test]
fn discovery_resource_is_present_and_readable() {
let resource = sysknife_about_resource();
assert_eq!(resource.uri, SYSKNIFE_DISCOVERY_URI);
assert_eq!(resource.name, SYSKNIFE_DISCOVERY_NAME);
assert_eq!(resource.title.as_deref(), Some(SYSKNIFE_DISCOVERY_TITLE));
assert_eq!(
resource.description.as_deref(),
Some(SYSKNIFE_DISCOVERY_DESCRIPTION)
);
assert_eq!(resource.mime_type.as_deref(), Some("text/plain"));
}
#[test]
fn the_server_introduces_itself_as_sysknife() {
let info = SysknifeMcpServer.get_info();
assert_eq!(info.server_info.name, "sysknife");
assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn get_info_exposes_resources_capability_for_codex() {
let info = SysknifeMcpServer.get_info();
assert!(
info.capabilities.resources.is_some(),
"Codex-compatible MCP servers should advertise resources"
);
assert!(
info.capabilities.tools.is_some(),
"SysKnife must continue advertising tools"
);
}
#[test]
fn execute_input_without_approval_receipt_is_rejected() {
let input = serde_json::json!({
"steps": [{
"transaction_id": "tx-1",
"action_name": "AptInstall",
"params": {"package": "vim"}
}]
});
assert!(serde_json::from_value::<ExecuteInput>(input).is_err());
}
#[test]
fn truncate_output_short_output_unchanged() {
let lines: Vec<String> = (0..10).map(|i| format!("line {i}")).collect();
let result = truncate_output(lines.clone());
assert_eq!(result, lines);
}
#[test]
fn truncate_output_at_limit_unchanged() {
let lines: Vec<String> = (0..OUTPUT_LINE_LIMIT)
.map(|i| format!("line {i}"))
.collect();
let result = truncate_output(lines.clone());
assert_eq!(result, lines);
}
#[test]
fn truncate_output_over_limit_adds_marker() {
let lines: Vec<String> = (0..OUTPUT_LINE_LIMIT + 50)
.map(|i| format!("line {i}"))
.collect();
let result = truncate_output(lines);
assert_eq!(result.len(), OUTPUT_LINE_LIMIT + 1);
assert!(result.last().unwrap().contains("truncated"));
assert!(result.last().unwrap().contains("50"));
}
#[tokio::test]
async fn mcp_tools_integrate_with_a_daemon_over_the_socket() {
use sysknife_daemon::transport::framing::FramedStream;
use tokio::net::UnixListener;
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("fake-daemon.sock");
let listener = UnixListener::bind(&sock).unwrap();
let server = tokio::spawn(async move {
loop {
let (stream, _) = match listener.accept().await {
Ok(pair) => pair,
Err(_) => break,
};
tokio::spawn(async move {
let mut framed = FramedStream::new(stream);
let Ok(raw) = framed.recv().await else { return };
let req: serde_json::Value = serde_json::from_slice(&raw).unwrap();
let resp = match req["type"].as_str() {
Some("query_history") => serde_json::json!({
"type": "history_response",
"request_id": req["request_id"],
"entries": [{
"transaction_id": "tx-abc123",
"action_name": "GetDiskUsage",
"risk_level": "low",
"status": "succeeded",
"summary": "check disk usage",
"created_at": "2026-07-19T12:00:00Z"
}]
}),
Some("execute") => serde_json::json!({
"type": "error_response",
"request_id": req["request_id"],
"category": "stale_approval",
"message": "transaction is not approved for this receipt"
}),
other => serde_json::json!({
"type": "error_response",
"request_id": req["request_id"],
"category": "validation_failure",
"message": format!("unexpected request: {other:?}")
}),
};
let _ = framed.send(&serde_json::to_vec(&resp).unwrap()).await;
});
}
});
std::env::set_var("SYSKNIFE_SOCKET", sock.to_str().unwrap());
let entries = history_inner(HistoryInput {
status: None,
action: None,
since: None,
limit: Some(5),
})
.await
.expect("history over socket");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].action, "GetDiskUsage");
assert_eq!(
entries[0].created_at.as_deref(),
Some("2026-07-19T12:00:00Z")
);
assert_eq!(entries[0].risk_level.as_deref(), Some("low"));
let result = execute_steps_inner(vec![StepToExecute {
transaction_id: "tx-abc123".to_string(),
action_name: "GetDiskUsage".to_string(),
params: serde_json::json!({}),
approval_receipt: "receipt-the-daemon-will-reject".to_string(),
}])
.await;
assert!(
result.is_err(),
"a daemon-rejected receipt must surface as an MCP error, got: {result:?}"
);
assert!(
result.unwrap_err().contains("stale_approval"),
"the rejection reason must reach the caller"
);
std::env::remove_var("SYSKNIFE_SOCKET");
server.abort();
}
#[tokio::test]
async fn doctor_reports_the_socket_as_a_uri_not_rust_debug() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("daemon.sock");
std::env::set_var("SYSKNIFE_SOCKET", sock.to_str().unwrap());
let report = doctor_inner().await;
std::env::remove_var("SYSKNIFE_SOCKET");
assert_eq!(
report.daemon_socket,
format!("unix://{}", sock.display()),
"the MCP report must use the same URI form as the CLI"
);
assert!(
!report.daemon_socket.contains("Unix("),
"no Rust Debug formatting in MCP output: {}",
report.daemon_socket
);
assert_eq!(
crate::client::SocketTarget::try_from_str(&report.daemon_socket).unwrap(),
crate::client::SocketTarget::Unix(sock.clone()),
);
}
#[test]
fn the_doctor_schema_documents_the_uri_form_not_the_debug_form() {
let schema = serde_json::to_string(&schemars::schema_for!(DoctorReport)).unwrap();
assert!(
!schema.contains("Unix("),
"the schema still documents Rust Debug formatting: {schema}"
);
assert!(
schema.contains("unix://"),
"the schema should show the URI form as its example"
);
}
#[test]
fn no_source_file_formats_a_socket_target_with_debug() {
let needle = concat!("{socket", ":?}");
for entry in std::fs::read_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/src")).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let src = std::fs::read_to_string(&path).unwrap();
for (n, line) in src.lines().enumerate() {
if line.trim_start().starts_with("//") {
continue;
}
assert!(
!line.contains(needle),
"{}:{} formats a socket with Debug; use SocketTarget::label()",
path.display(),
n + 1
);
}
}
}
}