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::RiskLevel;
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>,
}
#[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,
}
#[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 backend: 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))
}
}
#[tool_handler]
impl ServerHandler for SysknifeMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build(),
)
.with_server_info(Implementation::from_build_env())
.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;
step.risk_level = match prepared.preview.risk_level {
RiskLevel::Low => "low",
RiskLevel::Medium => "medium",
RiskLevel::High => "high",
}
.to_string();
step.warnings = prepared.preview.warnings.clone();
}
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(
&step.transaction_id,
&step.action_name,
&step.params,
&step.approval_receipt,
|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,
});
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 = format!("{socket:?}");
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, 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,
};
match outcome {
VerifyOutcome::Intact { .. } => VerifyOutcomeKind::Intact,
VerifyOutcome::Broken { .. } => VerifyOutcomeKind::Broken,
VerifyOutcome::CannotVerify { reason } => {
warnings.push(format!("audit chain cannot be verified: {reason}"));
VerifyOutcomeKind::Unknown
}
}
}
async fn audit_verify_inner() -> 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_to_report(
outcome: sysknife_daemon::audit_chain::VerifyOutcome,
backend: String,
) -> AuditVerifyReport {
use sysknife_daemon::audit_chain::VerifyOutcome;
match outcome {
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,
},
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,
},
VerifyOutcome::CannotVerify { reason } => cannot_verify_report(backend, reason),
}
}
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,
}
}
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 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::Fedora { version: 41 };
let result = check_plan_steps_distro(["GetSystemState"].into_iter(), Some(&distro));
assert!(result.is_ok());
}
#[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 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();
}
}