use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::mcp::audit::AuditLog;
use crate::mcp::config::McpConfig;
use crate::mcp::error::{McpError, McpResult};
use crate::mcp::safety::RateLimiter;
use crate::mcp::safety::{
ConfirmationResult, prompt_mutation_confirmation, resolve_within_workspace,
};
use crate::mcp::tools::McpToolDefinition;
pub struct MutationCtx<'a> {
pub config: &'a McpConfig,
pub rate_limiter: &'a RateLimiter,
pub audit_log: &'a AuditLog,
pub client_name: Option<&'a str>,
pub workspace_root: PathBuf,
}
impl MutationCtx<'_> {
pub fn workspace(&self) -> &Path {
&self.workspace_root
}
}
fn sanitize_effect_summary(input: &str) -> std::borrow::Cow<'_, str> {
if input.bytes().all(|b| b >= 0x20 && b != 0x7F) {
return std::borrow::Cow::Borrowed(input);
}
let mut out = String::with_capacity(input.len());
for c in input.chars() {
let b = c as u32;
if b < 0x20 || b == 0x7F {
out.push('?');
} else {
out.push(c);
}
}
std::borrow::Cow::Owned(out)
}
fn is_wizard_session() -> bool {
crate::wizard::session::is_active()
}
pub fn gate_mutation(
ctx: &MutationCtx<'_>,
tool_name: &str,
effect_summary: &str,
) -> McpResult<()> {
let effect_summary = sanitize_effect_summary(effect_summary);
let effect_summary = effect_summary.as_ref();
ctx.audit_log
.log_mcp_mutation_requested(ctx.client_name, tool_name, Some(effect_summary));
ctx.rate_limiter.check_install_limit().inspect_err(|_| {
ctx.audit_log.log_rate_limited(ctx.client_name, tool_name);
})?;
let global_auto_approve = crate::init::initialize().mcp.auto_approve_installs;
if !ctx.config.mcp.require_confirmation || global_auto_approve {
ctx.audit_log.log_mcp_mutation(
ctx.client_name,
tool_name,
false,
true,
Some(effect_summary),
);
return Ok(());
}
let expected_clients = ["claude-code", "codex"];
let client_unexpected = ctx
.client_name
.map(|c| !expected_clients.contains(&c))
.unwrap_or(true);
if is_wizard_session() && !client_unexpected {
if crate::observability::telemetry_gate::is_enabled() {
let session_id = std::env::var("JARVY_WIZARD_SESSION_ID").unwrap_or_default();
let client_name = ctx.client_name.unwrap_or("unknown");
tracing::info!(
event = "mcp.mutation.wizard_bypass",
tool = tool_name,
client = client_name,
workspace = %ctx.workspace().display(),
effect = effect_summary,
pid = std::process::id(),
wizard_session_id = %session_id,
);
}
ctx.audit_log.log_mcp_mutation(
ctx.client_name,
tool_name,
false,
true,
Some(effect_summary),
);
return Ok(());
}
if is_wizard_session()
&& client_unexpected
&& crate::observability::telemetry_gate::is_enabled()
{
let session_id = std::env::var("JARVY_WIZARD_SESSION_ID").unwrap_or_default();
tracing::warn!(
event = "mcp.mutation.wizard_bypass_unexpected_client",
tool = tool_name,
client = ctx.client_name.unwrap_or("unknown"),
workspace = %ctx.workspace().display(),
wizard_session_id = %session_id,
);
}
match prompt_mutation_confirmation(tool_name, effect_summary, ctx.client_name)? {
ConfirmationResult::Yes => {
ctx.audit_log.log_mcp_mutation(
ctx.client_name,
tool_name,
false,
true,
Some(effect_summary),
);
Ok(())
}
ConfirmationResult::No => {
ctx.audit_log.log_cancelled(ctx.client_name, tool_name);
Err(McpError::user_cancelled())
}
ConfirmationResult::Always => {
if let Err(e) = crate::init::modify_global_config(|cfg| {
cfg.mcp.auto_approve_installs = true;
}) {
if crate::observability::telemetry_gate::is_enabled() {
tracing::warn!(
event = "mcp.auto_approve.persist_failed",
tool = %tool_name,
error = %e,
);
}
} else if crate::observability::telemetry_gate::is_enabled() {
tracing::info!(
event = "mcp.auto_approve.enabled",
tool = %tool_name,
client = ctx.client_name.unwrap_or("unknown"),
);
}
ctx.audit_log.log_mcp_mutation(
ctx.client_name,
tool_name,
false,
true,
Some(effect_summary),
);
Ok(())
}
}
}
pub fn extended_definitions() -> Vec<McpToolDefinition> {
vec![
def(
"jarvy_ai_hooks_list",
"List configured AI hooks in jarvy.toml and the curated built-in library. Use this to understand what guardrails Jarvy can ship to AI coding agents.",
json!({
"type": "object",
"properties": {
"config_path": { "type": "string", "description": "Path to jarvy.toml (default: ./jarvy.toml)" },
"library": { "type": "boolean", "description": "Show built-in library instead of project config" }
}
}),
),
def(
"jarvy_ai_hooks_check",
"Detect drift between configured AI hooks and what is currently provisioned in each agent's settings file. Returns per-agent missing + extra-jarvy lists.",
json!({
"type": "object",
"properties": {
"config_path": { "type": "string" }
}
}),
),
def(
"jarvy_ai_hooks_apply",
"Apply the AI hooks configuration. Defaults to dry_run = true so the agent can preview what would change. Set dry_run = false to actually write the agent settings files.",
json!({
"type": "object",
"properties": {
"config_path": { "type": "string" },
"dry_run": { "type": "boolean", "description": "Preview only (default true)" }
}
}),
),
def(
"jarvy_mcp_register_list",
"List MCP servers Jarvy is configured to register with AI agents. Includes the always-on jarvy entry plus any allow-listed custom servers.",
json!({
"type": "object",
"properties": {
"config_path": { "type": "string" }
}
}),
),
def(
"jarvy_mcp_register_check",
"Detect drift between configured MCP server registrations and each agent's on-disk config file.",
json!({
"type": "object",
"properties": {
"config_path": { "type": "string" }
}
}),
),
def(
"jarvy_mcp_register_apply",
"Apply MCP server registrations to every configured agent. Defaults to dry_run = true.",
json!({
"type": "object",
"properties": {
"config_path": { "type": "string" },
"dry_run": { "type": "boolean" }
}
}),
),
def(
"jarvy_drift_check",
"Detect configuration drift in the current project — installed tool versions vs the jarvy.toml baseline state.",
json!({
"type": "object",
"properties": {
"project_dir": { "type": "string", "description": "Path to the project root (default: cwd)" }
}
}),
),
def(
"jarvy_drift_status",
"Show the current drift baseline state file (tools tracked, file hashes, last update).",
json!({
"type": "object",
"properties": {
"project_dir": { "type": "string" }
}
}),
),
def(
"jarvy_roles_list",
"List roles defined in jarvy.toml. Each role bundles a set of tools so heterogeneous teams (frontend, devops, data) can share one config.",
json!({
"type": "object",
"properties": {
"config_path": { "type": "string" }
}
}),
),
def(
"jarvy_roles_show",
"Show full details for a specific role, including tools, inherited parents, and resolved tool list.",
json!({
"type": "object",
"properties": {
"config_path": { "type": "string" },
"name": { "type": "string", "description": "Role name (e.g. 'frontend')" }
},
"required": ["name"]
}),
),
def(
"jarvy_services_status",
"Check whether project services (docker-compose, Tilt) are running and which backend is active.",
json!({
"type": "object",
"properties": {
"project_dir": { "type": "string" }
}
}),
),
def(
"jarvy_services_start",
"Start project services (docker-compose up / tilt up). Defaults to dry_run = true; preview prints what would run. Pass detach = false to run in the foreground (attached).",
json!({
"type": "object",
"properties": {
"project_dir": { "type": "string" },
"dry_run": { "type": "boolean", "description": "Preview only (default true)" },
"detach": { "type": "boolean", "description": "Run detached / in background (default true)" }
}
}),
),
def(
"jarvy_templates_list",
"List built-in jarvy.toml templates (node-bun, python-uv, k8s-platform, etc.) — useful for scaffolding new projects.",
json!({
"type": "object",
"properties": {
"category": { "type": "string", "description": "Optional category filter" }
}
}),
),
def(
"jarvy_templates_show",
"Show full details for a specific built-in template — tools, hooks, env vars, description.",
json!({
"type": "object",
"properties": {
"name": { "type": "string" }
},
"required": ["name"]
}),
),
def(
"jarvy_templates_use",
"Scaffold a jarvy.toml from a built-in template. Defaults to dry_run = true; preview returns the would-be content. Set dry_run = false to write to disk (refuses to overwrite an existing file unless force = true).",
json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Template name (run jarvy_templates_list to discover)" },
"output_path": { "type": "string", "description": "Where to write (default ./jarvy.toml)" },
"dry_run": { "type": "boolean" },
"force": { "type": "boolean", "description": "Overwrite an existing file (default false)" }
},
"required": ["name"]
}),
),
def(
"jarvy_validate_config",
"Parse and validate jarvy.toml. Returns the structured error list when the file is malformed or refers to unknown tools.",
json!({
"type": "object",
"properties": {
"config_path": { "type": "string" }
}
}),
),
def(
"jarvy_discover_scan",
"Scan the project directory for marker files (Cargo.toml, package.json, Dockerfile, k8s/, …) and return suggested tools. Read-only. Use jarvy_discover_apply to actually write to jarvy.toml.",
json!({
"type": "object",
"properties": {
"project_dir": { "type": "string", "description": "Path to the project root (default: cwd / workspace root)" },
"config_path": { "type": "string", "description": "Path to jarvy.toml (used to dedupe against already-pinned tools; default ./jarvy.toml)" }
}
}),
),
def(
"jarvy_discover_apply",
"Run discover and merge suggested tools into jarvy.toml. Append-only — hand-pinned tools survive untouched. Defaults to dry_run = true.",
json!({
"type": "object",
"properties": {
"config_path": { "type": "string", "description": "Path to jarvy.toml (default ./jarvy.toml)" },
"dry_run": { "type": "boolean", "description": "Preview only (default true)" }
}
}),
),
def(
"jarvy_workspace_list",
"Enumerate workspace members declared in [workspace] members and their resolved tool sets. Read-only.",
json!({
"type": "object",
"properties": {
"config_path": { "type": "string", "description": "Path to root jarvy.toml (default ./jarvy.toml)" }
}
}),
),
def(
"jarvy_workspace_show",
"Show one workspace member's resolved config with inheritance / override annotations.",
json!({
"type": "object",
"properties": {
"config_path": { "type": "string" },
"name": { "type": "string", "description": "Member name as declared in [workspace] members" }
},
"required": ["name"]
}),
),
def(
"jarvy_workspace_validate",
"Validate that every workspace member exists and its jarvy.toml parses. Returns errors / warnings / refused-members.",
json!({
"type": "object",
"properties": {
"config_path": { "type": "string" }
}
}),
),
def(
"jarvy_library_list",
"List every library currently in the process cache (URL, publisher, ai_hook/mcp_server/skill counts).",
json!({"type": "object", "properties": {}}),
),
def(
"jarvy_library_show",
"Show items inside one cached library (by URL).",
json!({
"type": "object",
"properties": {
"url": { "type": "string", "description": "Library URL as declared in [<subsystem>.library_sources]" }
},
"required": ["url"]
}),
),
def(
"jarvy_wizard_plan",
"Produce the agent-driven setup plan for the current project: discover detections, required / recommended tools, and a greenfield-vs-refinement flag. Read-only — the agent uses this to present a plan before calling jarvy_discover_apply / jarvy_ai_hooks_apply / etc.",
json!({
"type": "object",
"properties": {
"project_dir": { "type": "string", "description": "Path to the project root (default: cwd / workspace root)" },
"config_path": { "type": "string", "description": "Path to jarvy.toml — used to set the greenfield flag and dedupe (default ./jarvy.toml)" }
}
}),
),
]
}
fn def(name: &str, description: &str, schema: Value) -> McpToolDefinition {
McpToolDefinition {
name: name.to_string(),
description: description.to_string(),
input_schema: schema,
}
}
fn envelope(value: Value) -> McpResult<Value> {
Ok(json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(&value)?
}]
}))
}
#[derive(Deserialize, Default)]
struct PathArgs {
#[serde(default)]
config_path: Option<String>,
#[serde(default)]
project_dir: Option<String>,
}
fn config_path(args: &PathArgs) -> String {
args.config_path
.clone()
.unwrap_or_else(|| crate::cli::DEFAULT_CONFIG_FILE.to_string())
}
fn project_dir(args: &PathArgs) -> PathBuf {
args.project_dir
.clone()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}
fn parse<P: Default + serde::de::DeserializeOwned>(arguments: Option<Value>) -> McpResult<P> {
Ok(arguments
.map(serde_json::from_value)
.transpose()?
.unwrap_or_default())
}
#[derive(Deserialize, Default)]
struct AiHooksListArgs {
#[serde(default)]
config_path: Option<String>,
#[serde(default)]
library: bool,
}
pub fn handle_ai_hooks_list(arguments: Option<Value>) -> McpResult<Value> {
let args: AiHooksListArgs = parse(arguments)?;
if args.library {
let entries: Vec<Value> = crate::ai_hooks::library::LIBRARY
.iter()
.map(|h| {
json!({
"name": h.name,
"description": h.description,
"event": h.event.to_string(),
"matcher": h.matcher,
"timeout_ms": h.timeout_ms,
})
})
.collect();
return envelope(json!({ "library": entries, "count": entries.len() }));
}
let file = args
.config_path
.unwrap_or_else(|| crate::cli::DEFAULT_CONFIG_FILE.to_string());
let Some(cfg) = load_ai_hooks(&file) else {
return envelope(json!({
"configured": false,
"config_path": file,
"message": "No [ai_hooks] section in config"
}));
};
let refused = crate::ai_hooks::runner::audit_custom_commands(&cfg);
let hooks: Vec<Value> = cfg
.hooks
.iter()
.map(|h| {
json!({
"identifier": h.identifier(),
"kind": if h.is_library() { "library" } else if h.is_custom_command() { "custom" } else { "invalid" },
})
})
.collect();
envelope(json!({
"configured": true,
"config_path": file,
"agents": cfg.unique_agents().iter().map(|a| a.slug()).collect::<Vec<_>>(),
"scope": format!("{:?}", cfg.scope),
"allow_custom_commands": cfg.allow_custom_commands,
"origin": format!("{:?}", cfg.origin),
"hooks": hooks,
"refused_custom": refused,
}))
}
pub fn handle_ai_hooks_check(arguments: Option<Value>) -> McpResult<Value> {
let args: PathArgs = parse(arguments)?;
let file = config_path(&args);
let Some(cfg) = load_ai_hooks(&file) else {
return envelope(json!({ "configured": false, "config_path": file }));
};
let outcomes = crate::ai_hooks::check(&cfg);
let mut report = Vec::with_capacity(outcomes.len());
let mut drifted = 0usize;
let mut errored = 0usize;
for r in outcomes {
match r {
Ok(o) => {
if !o.is_clean() {
drifted += 1;
}
report.push(json!({
"agent": o.agent,
"path": o.path.display().to_string(),
"clean": o.is_clean(),
"missing": o.missing,
"extra_jarvy": o.extra_jarvy,
}));
}
Err((agent, e)) => {
errored += 1;
report.push(json!({
"agent": agent.slug(),
"error_type": e.kind(),
}));
}
}
}
envelope(json!({
"configured": true,
"config_path": file,
"agents_checked": report.len(),
"drifted": drifted,
"errored": errored,
"report": report,
}))
}
#[derive(Deserialize, Default)]
struct ApplyArgs {
#[serde(default)]
config_path: Option<String>,
#[serde(default)]
dry_run: Option<bool>,
}
pub fn handle_ai_hooks_apply(arguments: Option<Value>, ctx: &MutationCtx<'_>) -> McpResult<Value> {
let args: ApplyArgs = parse(arguments)?;
let file = args
.config_path
.unwrap_or_else(|| crate::cli::DEFAULT_CONFIG_FILE.to_string());
let Some(cfg) = load_ai_hooks(&file) else {
return envelope(json!({ "configured": false, "config_path": file }));
};
let dry_run = args.dry_run.unwrap_or(true);
if dry_run {
let refused = crate::ai_hooks::runner::audit_custom_commands(&cfg);
ctx.audit_log.log_mcp_mutation(
ctx.client_name,
"jarvy_ai_hooks_apply",
true,
true,
Some(&format!(
"preview: {} hook(s) across {} agent(s)",
cfg.hooks.len(),
cfg.unique_agents().len()
)),
);
return envelope(json!({
"dry_run": true,
"would_apply_hooks": cfg.hooks.len(),
"would_target_agents": cfg.unique_agents().iter().map(|a| a.slug()).collect::<Vec<_>>(),
"would_refuse_custom": refused,
"notes": "Set dry_run to false to actually write agent settings files. Mutating changes go through the host's stderr confirmation flow.",
}));
}
let summary = format!(
"Write {} hook(s) into the settings file of {} AI agent(s): {}",
cfg.hooks.len(),
cfg.unique_agents().len(),
cfg.unique_agents()
.iter()
.map(|a| a.slug())
.collect::<Vec<_>>()
.join(", ")
);
gate_mutation(ctx, "jarvy_ai_hooks_apply", &summary)?;
match crate::ai_hooks::apply(&cfg) {
Ok(report) => envelope(json!({
"dry_run": false,
"applied": report.total_applied(),
"agents_touched": report.agents_touched(),
"successes": report.successes.iter().map(|o| json!({
"agent": o.agent,
"path": o.path.display().to_string(),
"applied": o.applied,
})).collect::<Vec<_>>(),
"failures": report.failures.iter().map(|(t, e)| json!({
"agent": t.slug(),
"error_type": e.kind(),
})).collect::<Vec<_>>(),
"refused_custom": report.refused_custom,
"remote_refused": report.remote_refused_custom,
})),
Err(e) => Err(McpError::internal_error(format!(
"ai_hooks::apply failed ({}): {e}",
e.kind()
))),
}
}
fn load_ai_hooks(file: &str) -> Option<crate::ai_hooks::AiHooksConfig> {
let body = std::fs::read_to_string(file).ok()?;
let cfg: crate::config::Config = toml::from_str(&body).ok()?;
let mut ai = cfg.ai_hooks?;
ai.origin = crate::ai_hooks::ConfigOrigin::Local;
Some(ai)
}
pub fn handle_mcp_register_list(arguments: Option<Value>) -> McpResult<Value> {
let args: PathArgs = parse(arguments)?;
let file = config_path(&args);
let Some(cfg) = load_mcp_register(&file) else {
return envelope(json!({ "configured": false, "config_path": file }));
};
let refused = crate::mcp_register::runner::audit_custom_servers(&cfg);
envelope(json!({
"configured": true,
"config_path": file,
"agents": cfg.unique_agents().iter().map(|a| a.slug()).collect::<Vec<_>>(),
"scope": format!("{:?}", cfg.scope),
"allow_custom_servers": cfg.allow_custom_servers,
"origin": format!("{:?}", cfg.origin),
"jarvy_server": "built-in (always registered)",
"custom_servers": cfg.servers.iter().map(|s| json!({
"name": s.name,
"transport": format!("{:?}", s.transport),
})).collect::<Vec<_>>(),
"refused_custom": refused,
}))
}
pub fn handle_mcp_register_check(arguments: Option<Value>) -> McpResult<Value> {
let args: PathArgs = parse(arguments)?;
let file = config_path(&args);
let Some(cfg) = load_mcp_register(&file) else {
return envelope(json!({ "configured": false, "config_path": file }));
};
let outcomes = crate::mcp_register::check(&cfg);
let mut report = Vec::with_capacity(outcomes.len());
let mut drifted = 0usize;
let mut errored = 0usize;
for r in outcomes {
match r {
Ok(o) => {
if !o.is_clean() {
drifted += 1;
}
report.push(json!({
"agent": o.agent,
"path": o.path.display().to_string(),
"clean": o.is_clean(),
"missing": o.missing,
"extra_jarvy": o.extra_jarvy,
}));
}
Err((agent, e)) => {
errored += 1;
report.push(json!({ "agent": agent.slug(), "error_type": e.kind() }));
}
}
}
envelope(json!({
"configured": true,
"config_path": file,
"agents_checked": report.len(),
"drifted": drifted,
"errored": errored,
"report": report,
}))
}
pub fn handle_mcp_register_apply(
arguments: Option<Value>,
ctx: &MutationCtx<'_>,
) -> McpResult<Value> {
let args: ApplyArgs = parse(arguments)?;
let file = args
.config_path
.unwrap_or_else(|| crate::cli::DEFAULT_CONFIG_FILE.to_string());
let Some(cfg) = load_mcp_register(&file) else {
return envelope(json!({ "configured": false, "config_path": file }));
};
let dry_run = args.dry_run.unwrap_or(true);
if dry_run {
ctx.audit_log.log_mcp_mutation(
ctx.client_name,
"jarvy_mcp_register_apply",
true,
true,
Some(&format!(
"preview: {} server(s) across {} agent(s)",
cfg.servers.len() + 1,
cfg.unique_agents().len()
)),
);
return envelope(json!({
"dry_run": true,
"would_register_servers": cfg.servers.len() + 1,
"would_target_agents": cfg.unique_agents().iter().map(|a| a.slug()).collect::<Vec<_>>(),
"notes": "Set dry_run to false to actually write agent MCP config files.",
}));
}
let summary = format!(
"Register {} MCP server(s) (jarvy + custom) with {} agent(s): {}",
cfg.servers.len() + 1,
cfg.unique_agents().len(),
cfg.unique_agents()
.iter()
.map(|a| a.slug())
.collect::<Vec<_>>()
.join(", ")
);
gate_mutation(ctx, "jarvy_mcp_register_apply", &summary)?;
match crate::mcp_register::apply(&cfg) {
Ok(report) => envelope(json!({
"dry_run": false,
"applied": report.total_applied(),
"agents_touched": report.agents_touched(),
"successes": report.successes.iter().map(|o| json!({
"agent": o.agent,
"path": o.path.display().to_string(),
"applied": o.applied,
})).collect::<Vec<_>>(),
"failures": report.failures.iter().map(|(t, e)| json!({
"agent": t.slug(),
"error_type": e.kind(),
})).collect::<Vec<_>>(),
"refused_custom": report.refused_custom,
"remote_refused": report.remote_refused,
})),
Err(e) => Err(McpError::internal_error(format!(
"mcp_register::apply failed ({}): {e}",
e.kind()
))),
}
}
fn load_mcp_register(file: &str) -> Option<crate::mcp_register::McpRegisterConfig> {
let body = std::fs::read_to_string(file).ok()?;
let cfg: crate::config::Config = toml::from_str(&body).ok()?;
let mut mcp = cfg.mcp_register?;
mcp.origin = crate::ai_hooks::ConfigOrigin::Local;
Some(mcp)
}
pub fn handle_drift_check(arguments: Option<Value>) -> McpResult<Value> {
let args: PathArgs = parse(arguments)?;
let dir = project_dir(&args);
let state_path = crate::paths::state_json(&dir);
if !state_path.exists() {
return envelope(json!({
"baseline_exists": false,
"project_dir": dir.display().to_string(),
"message": "No drift baseline at .jarvy/state.json. Run `jarvy setup` first to capture one.",
}));
}
match crate::drift::state::EnvironmentState::load(&dir) {
Ok(Some(state)) => envelope(json!({
"baseline_exists": true,
"project_dir": dir.display().to_string(),
"tool_count": state.tool_count(),
"files_tracked": state.file_count(),
"notes": "Run `jarvy drift check` for the full per-tool comparison.",
})),
Ok(None) => envelope(json!({ "baseline_exists": false })),
Err(e) => Err(McpError::internal_error(format!(
"drift state load failed: {e}"
))),
}
}
pub fn handle_drift_status(arguments: Option<Value>) -> McpResult<Value> {
let args: PathArgs = parse(arguments)?;
let dir = project_dir(&args);
match crate::drift::state::EnvironmentState::load(&dir) {
Ok(Some(state)) => envelope(json!({
"baseline_exists": true,
"project_dir": dir.display().to_string(),
"tool_count": state.tool_count(),
"files_tracked": state.file_count(),
})),
Ok(None) => envelope(json!({
"baseline_exists": false,
"project_dir": dir.display().to_string(),
})),
Err(e) => Err(McpError::internal_error(format!(
"drift status load failed: {e}"
))),
}
}
pub fn handle_roles_list(arguments: Option<Value>) -> McpResult<Value> {
let args: PathArgs = parse(arguments)?;
let file = config_path(&args);
let Some(roles) = load_roles(&file) else {
return envelope(json!({ "configured": false, "config_path": file }));
};
let entries: Vec<Value> = roles
.iter()
.map(|(name, def)| {
json!({
"name": name,
"description": def.description,
"extends": def.get_extends(),
"tool_count": def.tool_count(),
})
})
.collect();
envelope(json!({
"configured": true,
"config_path": file,
"count": entries.len(),
"roles": entries,
}))
}
#[derive(Deserialize)]
struct RolesShowArgs {
name: String,
#[serde(default)]
config_path: Option<String>,
}
pub fn handle_roles_show(arguments: Option<Value>) -> McpResult<Value> {
let args: RolesShowArgs = arguments
.ok_or_else(|| McpError::invalid_params("Missing role name"))
.and_then(|v| serde_json::from_value(v).map_err(McpError::from))?;
let file = args
.config_path
.unwrap_or_else(|| crate::cli::DEFAULT_CONFIG_FILE.to_string());
let Some(roles) = load_roles(&file) else {
return envelope(json!({ "configured": false, "config_path": file }));
};
let Some(def) = roles.get(&args.name) else {
return Err(McpError::invalid_params(format!(
"Unknown role: {}",
args.name
)));
};
envelope(json!({
"name": args.name,
"description": def.description,
"extends": def.get_extends(),
"tools": def.get_tools(),
"tool_count": def.tool_count(),
}))
}
fn load_roles(
file: &str,
) -> Option<std::collections::HashMap<String, crate::roles::definition::RoleDefinition>> {
let body = std::fs::read_to_string(file).ok()?;
let cfg: crate::config::Config = toml::from_str(&body).ok()?;
let mut out = std::collections::HashMap::new();
for (name, raw) in cfg.roles_config.roles.into_iter() {
out.insert(name, raw.into_definition());
}
if out.is_empty() { None } else { Some(out) }
}
pub fn handle_services_status(arguments: Option<Value>) -> McpResult<Value> {
let args: PathArgs = parse(arguments)?;
let dir = project_dir(&args);
let Some((backend, config_path)) = crate::services::detect_backend(&dir) else {
return envelope(json!({
"backend": null,
"project_dir": dir.display().to_string(),
"message": "No service backend detected (no docker-compose.yml / Tiltfile in project).",
}));
};
let backend_impl = crate::services::get_backend(backend);
envelope(json!({
"backend": format!("{:?}", backend),
"config_path": config_path.display().to_string(),
"installed": backend_impl.is_installed(),
"project_dir": dir.display().to_string(),
}))
}
#[derive(Deserialize, Default)]
struct ServicesStartArgs {
#[serde(default)]
project_dir: Option<String>,
#[serde(default)]
dry_run: Option<bool>,
#[serde(default)]
detach: Option<bool>,
}
pub fn handle_services_start(arguments: Option<Value>, ctx: &MutationCtx<'_>) -> McpResult<Value> {
let args: ServicesStartArgs = parse(arguments)?;
let requested = args
.project_dir
.clone()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let dir = resolve_within_workspace(ctx.workspace(), &requested)?;
let dry_run = args.dry_run.unwrap_or(true);
let detach = args.detach.unwrap_or(true);
let Some((backend, config_path)) = crate::services::detect_backend(&dir) else {
return envelope(json!({
"started": false,
"project_dir": dir.display().to_string(),
"message": "No docker-compose / Tilt config detected — nothing to start.",
}));
};
let backend_impl = crate::services::get_backend(backend);
if !backend_impl.is_installed() {
return envelope(json!({
"started": false,
"backend": format!("{:?}", backend),
"config_path": config_path.display().to_string(),
"installed": false,
"message": "Backend is not installed on this machine; run `jarvy setup` first.",
}));
}
if dry_run {
ctx.audit_log.log_mcp_mutation(
ctx.client_name,
"jarvy_services_start",
true,
true,
Some(&format!(
"preview: {backend:?} {} from {}",
if detach { "(detached)" } else { "(attached)" },
config_path.display()
)),
);
return envelope(json!({
"dry_run": true,
"backend": format!("{:?}", backend),
"config_path": config_path.display().to_string(),
"detach": detach,
"notes": "Set dry_run to false to actually start. Mutating ops go through the host's stderr confirmation flow.",
}));
}
let summary = format!(
"Start the {backend:?} backend using {} (detach={detach})",
config_path.display()
);
gate_mutation(ctx, "jarvy_services_start", &summary)?;
match backend_impl.start(&config_path, detach) {
Ok(result) => envelope(json!({
"dry_run": false,
"backend": format!("{:?}", result.backend),
"config_path": config_path.display().to_string(),
"success": result.success,
"message": result.message,
})),
Err(e) => Err(McpError::internal_error(format!(
"services::start failed: {e}"
))),
}
}
#[derive(Deserialize, Default)]
struct TemplatesListArgs {
#[serde(default)]
category: Option<String>,
}
pub fn handle_templates_list(arguments: Option<Value>) -> McpResult<Value> {
let args: TemplatesListArgs = parse(arguments)?;
let all = crate::templates::builtin::list_builtin_templates();
let filtered: Vec<&crate::templates::builtin::BuiltinTemplate> = match args.category {
Some(ref c) => all
.iter()
.filter(|t| t.category.eq_ignore_ascii_case(c))
.collect(),
None => all.iter().collect(),
};
let entries: Vec<Value> = filtered
.iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"category": t.category,
})
})
.collect();
envelope(json!({
"count": entries.len(),
"categories": crate::templates::builtin::all_categories(),
"templates": entries,
}))
}
#[derive(Deserialize)]
struct TemplatesShowArgs {
name: String,
}
pub fn handle_templates_show(arguments: Option<Value>) -> McpResult<Value> {
let args: TemplatesShowArgs = arguments
.ok_or_else(|| McpError::invalid_params("Missing template name"))
.and_then(|v| serde_json::from_value(v).map_err(McpError::from))?;
let Some(template) = crate::templates::builtin::get_builtin_template(&args.name) else {
return Err(McpError::invalid_params(format!(
"Unknown template: {}",
args.name
)));
};
let full = template.to_template();
envelope(json!({
"name": template.name,
"description": template.description,
"category": template.category,
"tools": full.tools.tools,
"meta": full.template,
}))
}
#[derive(Deserialize)]
struct TemplatesUseArgs {
name: String,
#[serde(default)]
output_path: Option<String>,
#[serde(default)]
dry_run: Option<bool>,
#[serde(default)]
force: Option<bool>,
}
pub fn handle_templates_use(arguments: Option<Value>, ctx: &MutationCtx<'_>) -> McpResult<Value> {
let args: TemplatesUseArgs = arguments
.ok_or_else(|| McpError::invalid_params("Missing template name"))
.and_then(|v| serde_json::from_value(v).map_err(McpError::from))?;
let Some(template) = crate::templates::builtin::get_builtin_template(&args.name) else {
return Err(McpError::invalid_params(format!(
"Unknown template: {}",
args.name
)));
};
let dry_run = args.dry_run.unwrap_or(true);
let force = args.force.unwrap_or(false);
let requested = args
.output_path
.clone()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("jarvy.toml"));
let output = resolve_within_workspace(ctx.workspace(), &requested)?;
let content = template.to_template().to_jarvy_toml();
if dry_run {
ctx.audit_log.log_mcp_mutation(
ctx.client_name,
"jarvy_templates_use",
true,
true,
Some(&format!("preview: {} → {}", args.name, output.display())),
);
return envelope(json!({
"dry_run": true,
"template": args.name,
"output_path": output.display().to_string(),
"tool_count": template.tools.len(),
"would_overwrite": output.exists(),
"content_preview": content,
}));
}
if output.exists() && !force {
return envelope(json!({
"dry_run": false,
"created": false,
"output_path": output.display().to_string(),
"error": "file already exists; pass force = true to overwrite",
}));
}
let summary = format!(
"Scaffold {} ({} bytes) → {}",
args.name,
content.len(),
output.display()
);
gate_mutation(ctx, "jarvy_templates_use", &summary)?;
let (backup, backed_up) = write_template_atomic(&output, content.as_bytes(), force)?;
envelope(json!({
"dry_run": false,
"created": true,
"template": args.name,
"output_path": output.display().to_string(),
"tool_count": template.tools.len(),
"bytes_written": content.len(),
"backed_up": backed_up,
"backup_path": backup.map(|p| p.display().to_string()),
}))
}
fn write_template_atomic(
path: &Path,
content: &[u8],
force: bool,
) -> McpResult<(Option<PathBuf>, bool)> {
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
let mut backup_path: Option<PathBuf> = None;
let mut backed_up = false;
if path.exists() {
if !force {
return Err(McpError::invalid_params(format!(
"file already exists at {}; pass force = true to overwrite",
path.display()
)));
}
let bak = path.with_extension({
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("toml");
format!("{ext}.bak")
});
fs::copy(path, &bak).map_err(|e| {
McpError::internal_error(format!(
"failed to back up existing {}: {e}",
path.display()
))
})?;
backup_path = Some(bak);
backed_up = true;
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
McpError::internal_error(format!("failed to create parent {}: {e}", parent.display()))
})?;
}
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = path.with_extension(format!("jarvy.tmp.{pid}.{nanos}"));
{
let mut f = OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp)
.map_err(|e| {
McpError::internal_error(format!(
"failed to create tempfile {}: {e}",
tmp.display()
))
})?;
f.write_all(content)
.map_err(|e| McpError::internal_error(format!("failed to write tempfile: {e}")))?;
f.sync_all()
.map_err(|e| McpError::internal_error(format!("failed to fsync tempfile: {e}")))?;
}
fs::rename(&tmp, path).map_err(|e| {
McpError::internal_error(format!("failed to rename tempfile into place: {e}"))
})?;
Ok((backup_path, backed_up))
}
pub fn handle_validate_config(arguments: Option<Value>) -> McpResult<Value> {
let args: PathArgs = parse(arguments)?;
let file = config_path(&args);
let path = Path::new(&file);
if !path.exists() {
return envelope(json!({
"valid": false,
"config_path": file,
"error_type": "missing",
"message": format!("File not found: {file}"),
}));
}
let body = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
return envelope(json!({
"valid": false,
"config_path": file,
"error_type": "io",
"message": e.to_string(),
}));
}
};
match toml::from_str::<crate::config::Config>(&body) {
Ok(cfg) => envelope(json!({
"valid": true,
"config_path": file,
"tool_count": cfg.tool_configs_len(),
"has_ai_hooks": cfg.ai_hooks.is_some(),
"has_mcp_register": cfg.mcp_register.is_some(),
"has_git": cfg.git.is_some(),
"has_npm": cfg.npm.is_some(),
"has_pip": cfg.pip.is_some(),
"has_cargo": cfg.cargo.is_some(),
"has_drift": cfg.drift.is_some(),
})),
Err(e) => envelope(json!({
"valid": false,
"config_path": file,
"error_type": "parse",
"message": e.to_string(),
})),
}
}
#[derive(Deserialize, Default)]
struct DiscoverScanArgs {
#[serde(default)]
project_dir: Option<String>,
#[serde(default)]
config_path: Option<String>,
}
pub fn handle_discover_scan(arguments: Option<Value>) -> McpResult<Value> {
let args: DiscoverScanArgs = arguments
.map(serde_json::from_value)
.transpose()
.map_err(|e| McpError::invalid_params(e.to_string()))?
.unwrap_or_default();
let project_dir = std::path::PathBuf::from(args.project_dir.unwrap_or_else(|| ".".into()));
let config_path = args
.config_path
.unwrap_or_else(|| crate::cli::DEFAULT_CONFIG_FILE.into());
let existing_text = std::fs::read_to_string(&config_path).ok();
let already_configured: std::collections::HashSet<String> = existing_text
.as_deref()
.and_then(|t| t.parse::<toml::Table>().ok())
.and_then(|t| t.get("provisioner").and_then(|v| v.as_table()).cloned())
.map(|t| t.keys().cloned().collect())
.unwrap_or_default();
crate::tools::register_all();
let known_tools: std::collections::HashSet<String> =
crate::tools::registry::registered_tool_names()
.into_iter()
.collect();
let report = crate::discover::analyze(&project_dir, &already_configured, &known_tools);
envelope(json!({
"project_dir": project_dir.display().to_string(),
"detections": report.detections,
"required": report.required,
"recommended": report.recommended,
"already_configured": report.already_configured,
}))
}
#[derive(Deserialize, Default)]
struct DiscoverApplyArgs {
#[serde(default)]
config_path: Option<String>,
#[serde(default = "default_true_arg")]
dry_run: bool,
}
fn default_true_arg() -> bool {
true
}
pub fn handle_discover_apply(arguments: Option<Value>, ctx: &MutationCtx) -> McpResult<Value> {
let args: DiscoverApplyArgs = arguments
.map(serde_json::from_value)
.transpose()
.map_err(|e| McpError::invalid_params(e.to_string()))?
.unwrap_or(DiscoverApplyArgs {
config_path: None,
dry_run: true,
});
let config_path = args
.config_path
.unwrap_or_else(|| crate::cli::DEFAULT_CONFIG_FILE.into());
let resolved = resolve_within_workspace(ctx.workspace(), std::path::Path::new(&config_path))?;
if args.dry_run {
return handle_discover_scan(Some(json!({
"config_path": resolved.display().to_string(),
})));
}
gate_mutation(
ctx,
"jarvy_discover_apply",
&format!("write suggested tools to {}", resolved.display()),
)?;
let exit = crate::discover::commands::run_discover(
resolved.to_str().unwrap_or(&config_path),
true,
false,
"json",
);
envelope(json!({
"status": if exit == 0 { "applied" } else { "failed" },
"exit_code": exit,
"config_path": resolved.display().to_string(),
}))
}
#[derive(Deserialize, Default)]
struct WorkspaceListArgs {
#[serde(default)]
config_path: Option<String>,
}
pub fn handle_workspace_list(arguments: Option<Value>) -> McpResult<Value> {
let args: WorkspaceListArgs = arguments
.map(serde_json::from_value)
.transpose()
.map_err(|e| McpError::invalid_params(e.to_string()))?
.unwrap_or_default();
let config_path = args
.config_path
.unwrap_or_else(|| crate::cli::DEFAULT_CONFIG_FILE.into());
let project_dir = crate::paths::config_parent_dir(&config_path);
match crate::workspace::find_workspace_root(&project_dir) {
Some(ctx) => {
envelope(json!({
"workspace_root": ctx.root_config.parent().map(|p| p.display().to_string()),
"inherit": ctx.workspace.effective_inherit(),
"members": ctx.workspace.members,
}))
}
None => envelope(json!({
"status": "no_workspace",
"searched_from": project_dir.display().to_string(),
})),
}
}
#[derive(Deserialize)]
struct WorkspaceShowArgs {
#[serde(default)]
config_path: Option<String>,
name: String,
}
pub fn handle_workspace_show(arguments: Option<Value>) -> McpResult<Value> {
let args: WorkspaceShowArgs = arguments
.ok_or_else(|| McpError::invalid_params("name is required"))
.and_then(|v| {
serde_json::from_value(v).map_err(|e| McpError::invalid_params(e.to_string()))
})?;
let config_path = args
.config_path
.unwrap_or_else(|| crate::cli::DEFAULT_CONFIG_FILE.into());
let project_dir = crate::paths::config_parent_dir(&config_path);
let ctx = match crate::workspace::find_workspace_root(&project_dir) {
Some(c) => c,
None => {
return envelope(json!({
"status": "no_workspace",
"searched_from": project_dir.display().to_string(),
}));
}
};
if !ctx.workspace.members.iter().any(|m| m == &args.name) {
return envelope(json!({"status": "unknown_member", "name": args.name}));
}
envelope(json!({
"workspace_root": ctx.root_config.parent().map(|p| p.display().to_string()),
"member": args.name,
"inherit": ctx.workspace.effective_inherit(),
}))
}
#[derive(Deserialize, Default)]
struct WorkspaceValidateArgs {
#[serde(default)]
config_path: Option<String>,
}
pub fn handle_workspace_validate(arguments: Option<Value>) -> McpResult<Value> {
let args: WorkspaceValidateArgs = arguments
.map(serde_json::from_value)
.transpose()
.map_err(|e| McpError::invalid_params(e.to_string()))?
.unwrap_or_default();
let config_path = args
.config_path
.unwrap_or_else(|| crate::cli::DEFAULT_CONFIG_FILE.into());
let project_dir = crate::paths::config_parent_dir(&config_path);
let ctx = match crate::workspace::find_workspace_root(&project_dir) {
Some(c) => c,
None => {
return envelope(json!({
"status": "no_workspace",
"searched_from": project_dir.display().to_string(),
}));
}
};
let root_dir = ctx
.root_config
.parent()
.unwrap_or(std::path::Path::new("."));
let mut errors = Vec::new();
let mut warnings = Vec::new();
let mut entries = Vec::new();
for member in &ctx.workspace.members {
let p = std::path::Path::new(member);
if p.is_absolute()
|| p.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
errors.push(format!("{member}: escapes workspace root"));
entries.push(json!({"name": member, "refused": true}));
continue;
}
let member_dir = root_dir.join(member);
let cfg = member_dir.join("jarvy.toml");
let dir_exists = member_dir.is_dir();
let cfg_exists = cfg.exists();
let parses = if cfg_exists {
std::fs::read_to_string(&cfg)
.ok()
.and_then(|s| toml::from_str::<toml::Value>(&s).ok())
.is_some()
} else {
true
};
if !dir_exists {
errors.push(format!("{member}: directory missing"));
} else if !cfg_exists {
warnings.push(format!("{member}: no jarvy.toml"));
} else if !parses {
errors.push(format!("{member}: jarvy.toml failed to parse"));
}
entries.push(json!({
"name": member,
"dir_exists": dir_exists,
"config_exists": cfg_exists,
"config_parses": parses,
}));
}
let status = if !errors.is_empty() {
"invalid"
} else if !warnings.is_empty() {
"warnings"
} else {
"ok"
};
envelope(json!({
"status": status,
"errors": errors,
"warnings": warnings,
"members": entries,
}))
}
pub fn handle_library_list(_arguments: Option<Value>) -> McpResult<Value> {
let libs = crate::library_registry::list_cached();
envelope(json!({
"count": libs.len(),
"libraries": libs,
}))
}
#[derive(Deserialize)]
struct LibraryShowArgs {
url: String,
}
pub fn handle_library_show(arguments: Option<Value>) -> McpResult<Value> {
let args: LibraryShowArgs = arguments
.ok_or_else(|| McpError::invalid_params("url is required"))
.and_then(|v| {
serde_json::from_value(v).map_err(|e| McpError::invalid_params(e.to_string()))
})?;
match crate::library_registry::get_cached(&args.url) {
Some((url, manifest)) => envelope(json!({
"url": url,
"publisher": manifest.publisher,
"description": manifest.description,
"items": manifest.items,
})),
None => envelope(json!({"status": "not_found", "url": args.url})),
}
}
#[derive(Deserialize, Default)]
struct WizardPlanArgs {
#[serde(default)]
project_dir: Option<String>,
#[serde(default)]
config_path: Option<String>,
}
pub fn handle_wizard_plan(arguments: Option<Value>) -> McpResult<Value> {
let args: WizardPlanArgs = arguments
.map(serde_json::from_value)
.transpose()
.map_err(|e| McpError::invalid_params(e.to_string()))?
.unwrap_or_default();
let project_dir = std::path::PathBuf::from(args.project_dir.unwrap_or_else(|| ".".into()));
let config_path = args
.config_path
.unwrap_or_else(|| crate::cli::DEFAULT_CONFIG_FILE.into());
let existing_text = std::fs::read_to_string(&config_path).ok();
let has_jarvy_toml = existing_text.is_some();
let already_configured: std::collections::HashSet<String> = existing_text
.as_deref()
.and_then(|t| t.parse::<toml::Table>().ok())
.and_then(|t| t.get("provisioner").and_then(|v| v.as_table()).cloned())
.map(|t| t.keys().cloned().collect())
.unwrap_or_default();
crate::tools::register_all();
let known_tools: std::collections::HashSet<String> =
crate::tools::registry::registered_tool_names()
.into_iter()
.collect();
let report = crate::discover::analyze(&project_dir, &already_configured, &known_tools);
envelope(json!({
"project_dir": project_dir.display().to_string(),
"config_path": config_path,
"has_jarvy_toml": has_jarvy_toml,
"greenfield": !has_jarvy_toml,
"detections": report.detections,
"required": report.required,
"recommended": report.recommended,
"already_configured": report.already_configured,
"uninstallable": report.uninstallable,
"next_actions": [
if has_jarvy_toml {
"Present this plan to the user. If they confirm, call jarvy_discover_apply (merge mode) to add missing tools to [provisioner]."
} else {
"Greenfield: present this plan to the user. If they confirm, call jarvy_discover_apply with apply=true to bootstrap a starter jarvy.toml."
},
"Then call jarvy_validate_config to confirm the resulting jarvy.toml parses cleanly.",
"Finally, remind the user to run `jarvy setup` themselves — don't run install commands from the wizard."
]
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::audit::AuditLog;
use crate::mcp::config::McpConfig;
use crate::mcp::safety::RateLimiter;
use serde_json::json;
use tempfile::TempDir;
struct TestCtx {
config: McpConfig,
rate_limiter: RateLimiter,
audit_log: AuditLog,
workspace_root: std::path::PathBuf,
}
impl TestCtx {
fn new(workspace: &std::path::Path) -> Self {
let mut config = McpConfig::default();
config.mcp.require_confirmation = false;
let rate_limiter = RateLimiter::new(&config);
let audit_log = AuditLog::disabled();
Self {
config,
rate_limiter,
audit_log,
workspace_root: workspace.to_path_buf(),
}
}
fn ctx(&self) -> MutationCtx<'_> {
MutationCtx {
config: &self.config,
rate_limiter: &self.rate_limiter,
audit_log: &self.audit_log,
client_name: Some("jarvy-tests"),
workspace_root: self.workspace_root.clone(),
}
}
}
#[test]
fn ai_hooks_list_library_returns_curated_set() {
let resp = handle_ai_hooks_list(Some(json!({ "library": true }))).unwrap();
let text = resp["content"][0]["text"].as_str().unwrap();
assert!(text.contains("block-rm-rf"));
assert!(text.contains("audit-log"));
}
#[test]
fn ai_hooks_list_returns_not_configured_for_missing_file() {
let resp =
handle_ai_hooks_list(Some(json!({ "config_path": "/nonexistent.toml" }))).unwrap();
let text = resp["content"][0]["text"].as_str().unwrap();
assert!(text.contains("\"configured\": false"));
}
#[test]
fn validate_config_reports_missing_file() {
let resp = handle_validate_config(Some(json!({ "config_path": "/nope.toml" }))).unwrap();
let text = resp["content"][0]["text"].as_str().unwrap();
assert!(text.contains("\"valid\": false"));
assert!(text.contains("missing"));
}
#[test]
fn validate_config_parses_minimal_config() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("jarvy.toml");
std::fs::write(
&p,
r#"[provisioner]
git = "latest"
"#,
)
.unwrap();
let resp = handle_validate_config(Some(json!({
"config_path": p.to_str().unwrap()
})))
.unwrap();
let text = resp["content"][0]["text"].as_str().unwrap();
assert!(text.contains("\"valid\": true"));
assert!(text.contains("\"tool_count\": 1"));
}
#[test]
fn templates_list_returns_built_in_templates() {
let resp = handle_templates_list(None).unwrap();
let text = resp["content"][0]["text"].as_str().unwrap();
assert!(text.contains("templates"));
assert!(text.contains("\"count\":"));
}
#[test]
fn drift_status_reports_no_baseline_when_absent() {
let dir = TempDir::new().unwrap();
let resp = handle_drift_status(Some(json!({
"project_dir": dir.path().to_str().unwrap()
})))
.unwrap();
let text = resp["content"][0]["text"].as_str().unwrap();
assert!(text.contains("\"baseline_exists\": false"));
}
#[test]
fn services_status_reports_no_backend_in_empty_dir() {
let dir = TempDir::new().unwrap();
let resp = handle_services_status(Some(json!({
"project_dir": dir.path().to_str().unwrap()
})))
.unwrap();
let text = resp["content"][0]["text"].as_str().unwrap();
assert!(text.contains("\"backend\": null"));
}
#[test]
fn services_start_in_empty_dir_reports_not_started() {
let dir = TempDir::new().unwrap();
let tc = TestCtx::new(dir.path());
let resp = handle_services_start(Some(json!({ "project_dir": "." })), &tc.ctx()).unwrap();
let text = resp["content"][0]["text"].as_str().unwrap();
assert!(text.contains("\"started\": false"));
}
#[test]
fn templates_use_dry_run_returns_preview_without_writing() {
let dir = TempDir::new().unwrap();
let tc = TestCtx::new(dir.path());
let any_name = crate::templates::builtin::list_builtin_templates()
.first()
.map(|t| t.name.to_string())
.expect("at least one built-in template");
let resp = handle_templates_use(
Some(json!({
"name": any_name,
"output_path": "jarvy.toml",
"dry_run": true
})),
&tc.ctx(),
)
.unwrap();
let text = resp["content"][0]["text"].as_str().unwrap();
assert!(text.contains("\"dry_run\": true"));
assert!(text.contains("\"content_preview\""));
assert!(!dir.path().join("jarvy.toml").exists());
}
#[test]
fn templates_use_refuses_to_overwrite_without_force() {
let dir = TempDir::new().unwrap();
let tc = TestCtx::new(dir.path());
let out = dir.path().join("jarvy.toml");
std::fs::write(&out, b"existing").unwrap();
let any_name = crate::templates::builtin::list_builtin_templates()
.first()
.map(|t| t.name.to_string())
.unwrap();
let resp = handle_templates_use(
Some(json!({
"name": any_name,
"output_path": "jarvy.toml",
"dry_run": false,
"force": false
})),
&tc.ctx(),
)
.unwrap();
let text = resp["content"][0]["text"].as_str().unwrap();
assert!(text.contains("\"created\": false"));
assert!(text.contains("already exists"));
assert_eq!(std::fs::read(&out).unwrap(), b"existing");
}
#[test]
fn templates_use_writes_when_force_is_set_and_backs_up_existing() {
let dir = TempDir::new().unwrap();
let tc = TestCtx::new(dir.path());
let out = dir.path().join("jarvy.toml");
std::fs::write(&out, b"existing").unwrap();
let any_name = crate::templates::builtin::list_builtin_templates()
.first()
.map(|t| t.name.to_string())
.unwrap();
let resp = handle_templates_use(
Some(json!({
"name": any_name,
"output_path": "jarvy.toml",
"dry_run": false,
"force": true
})),
&tc.ctx(),
)
.unwrap();
let text = resp["content"][0]["text"].as_str().unwrap();
assert!(text.contains("\"created\": true"));
assert!(text.contains("\"backed_up\": true"));
let body = std::fs::read_to_string(&out).unwrap();
assert!(body.contains("[provisioner]") || body.contains("provisioner"));
let bak = dir.path().join("jarvy.toml.bak");
assert!(bak.exists(), "force should produce a .bak sibling");
assert_eq!(std::fs::read(&bak).unwrap(), b"existing");
}
#[test]
fn templates_use_unknown_template_returns_error() {
let dir = TempDir::new().unwrap();
let tc = TestCtx::new(dir.path());
let resp = handle_templates_use(
Some(json!({ "name": "definitely-not-a-real-template" })),
&tc.ctx(),
);
let err = resp.unwrap_err();
assert!(err.to_string().contains("Unknown template"));
}
#[test]
fn templates_use_refuses_absolute_path_outside_workspace() {
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let tc = TestCtx::new(dir.path());
let any_name = crate::templates::builtin::list_builtin_templates()
.first()
.map(|t| t.name.to_string())
.unwrap();
let attempt = outside.path().join("jarvy.toml");
let err = handle_templates_use(
Some(json!({
"name": any_name,
"output_path": attempt.to_str().unwrap(),
"dry_run": false,
})),
&tc.ctx(),
)
.unwrap_err();
assert!(
err.to_string().to_lowercase().contains("workspace"),
"expected workspace refusal, got: {err}"
);
assert!(!attempt.exists(), "must not have written outside workspace");
}
#[test]
fn templates_use_refuses_parent_traversal() {
let dir = TempDir::new().unwrap();
let tc = TestCtx::new(dir.path());
let any_name = crate::templates::builtin::list_builtin_templates()
.first()
.map(|t| t.name.to_string())
.unwrap();
let err = handle_templates_use(
Some(json!({
"name": any_name,
"output_path": "../escape.toml",
"dry_run": false,
})),
&tc.ctx(),
)
.unwrap_err();
let s = err.to_string().to_lowercase();
assert!(s.contains("workspace") || s.contains("escape"));
}
#[cfg(unix)]
#[test]
fn templates_use_refuses_to_write_through_symlink() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let tc = TestCtx::new(dir.path());
let target = dir.path().join("real.toml");
std::fs::write(&target, b"real-content").unwrap();
let link = dir.path().join("jarvy.toml");
symlink(&target, &link).unwrap();
let any_name = crate::templates::builtin::list_builtin_templates()
.first()
.map(|t| t.name.to_string())
.unwrap();
let err = handle_templates_use(
Some(json!({
"name": any_name,
"output_path": "jarvy.toml",
"dry_run": false,
"force": true
})),
&tc.ctx(),
)
.unwrap_err();
assert!(err.to_string().to_lowercase().contains("symlink"));
assert_eq!(std::fs::read(&target).unwrap(), b"real-content");
}
#[test]
fn services_start_refuses_absolute_outside_workspace() {
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let tc = TestCtx::new(dir.path());
let err = handle_services_start(
Some(json!({ "project_dir": outside.path().to_str().unwrap(), "dry_run": false })),
&tc.ctx(),
)
.unwrap_err();
assert!(err.to_string().to_lowercase().contains("workspace"));
}
#[test]
fn ai_hooks_apply_dry_run_emits_audit_event_without_writing() {
let dir = TempDir::new().unwrap();
let tc = TestCtx::new(dir.path());
let f = dir.path().join("jarvy.toml");
std::fs::write(
&f,
r#"[provisioner]
git = "latest"
[ai_hooks]
agents = ["claude-code"]
[[ai_hooks.hook]]
use = "block-rm-rf"
"#,
)
.unwrap();
let resp = handle_ai_hooks_apply(
Some(json!({ "config_path": f.to_str().unwrap(), "dry_run": true })),
&tc.ctx(),
)
.unwrap();
let text = resp["content"][0]["text"].as_str().unwrap();
assert!(text.contains("\"dry_run\": true"));
}
#[test]
fn mcp_register_apply_dry_run_emits_audit_event() {
let dir = TempDir::new().unwrap();
let tc = TestCtx::new(dir.path());
let f = dir.path().join("jarvy.toml");
std::fs::write(
&f,
r#"[provisioner]
git = "latest"
[mcp_register]
agents = ["claude-code"]
"#,
)
.unwrap();
let resp = handle_mcp_register_apply(
Some(json!({ "config_path": f.to_str().unwrap(), "dry_run": true })),
&tc.ctx(),
)
.unwrap();
let text = resp["content"][0]["text"].as_str().unwrap();
assert!(text.contains("\"dry_run\": true"));
}
struct WizardActiveGuard {
_home: tempfile::TempDir,
_session: crate::wizard::session::WizardSessionGuard,
}
impl WizardActiveGuard {
#[allow(unsafe_code)]
fn activate() -> Self {
let home = tempfile::TempDir::new().unwrap();
let uuid = uuid::Uuid::now_v7().to_string();
unsafe { std::env::set_var("JARVY_HOME", home.path()) };
unsafe { std::env::set_var(crate::wizard::session::SESSION_ID_ENV, &uuid) };
let session = crate::wizard::session::WizardSessionGuard::activate(&uuid);
Self {
_home: home,
_session: session,
}
}
}
impl Drop for WizardActiveGuard {
#[allow(unsafe_code)]
fn drop(&mut self) {
unsafe { std::env::remove_var(crate::wizard::session::SESSION_ID_ENV) };
unsafe { std::env::remove_var("JARVY_HOME") };
}
}
fn ctx_requiring_confirmation<'a>(tc: &'a mut TestCtx) -> MutationCtx<'a> {
tc.config.mcp.require_confirmation = true;
MutationCtx {
config: &tc.config,
rate_limiter: &tc.rate_limiter,
audit_log: &tc.audit_log,
client_name: Some("claude-code"),
workspace_root: tc.workspace_root.clone(),
}
}
#[test]
#[serial_test::serial(jarvy_home_env)]
fn wizard_bypass_fires_when_env_and_marker_both_present() {
let dir = TempDir::new().unwrap();
let mut tc = TestCtx::new(dir.path());
let _guard = WizardActiveGuard::activate();
let ctx = ctx_requiring_confirmation(&mut tc);
gate_mutation(&ctx, "jarvy_discover_apply", "write ./jarvy.toml").unwrap();
}
#[test]
#[serial_test::serial(jarvy_home_env)]
fn wizard_bypass_refuses_when_env_set_but_marker_missing() {
let dir = TempDir::new().unwrap();
let mut tc = TestCtx::new(dir.path());
let home = tempfile::TempDir::new().unwrap();
#[allow(unsafe_code)]
unsafe {
std::env::set_var("JARVY_HOME", home.path());
std::env::set_var(crate::wizard::session::SESSION_ID_ENV, "orphaned-uuid");
}
let ctx = ctx_requiring_confirmation(&mut tc);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
gate_mutation(&ctx, "jarvy_discover_apply", "test")
}));
#[allow(unsafe_code)]
unsafe {
std::env::remove_var(crate::wizard::session::SESSION_ID_ENV);
std::env::remove_var("JARVY_HOME");
}
match result {
Ok(Ok(())) => panic!(
"wizard bypass fired despite missing marker file — the \
ambient-env-carrier escape the marker-file check \
exists to close has regressed."
),
Ok(Err(e)) => {
assert_eq!(
e.code, -32005,
"fall-through must reach the confirmation gate and \
produce user_cancelled (-32005); got code {} which \
may indicate a bypass fired then errored downstream. \
Full error: {}",
e.code, e.message
);
}
Err(_) => {
}
}
}
#[test]
#[serial_test::serial(jarvy_home_env)]
fn wizard_bypass_still_checks_rate_limit() {
let dir = TempDir::new().unwrap();
let mut tc = TestCtx::new(dir.path());
let mut config = McpConfig::default();
config.mcp.require_confirmation = true;
config.mcp.max_installs_per_minute = 0;
tc.rate_limiter = RateLimiter::new(&config);
tc.config = config;
let _guard = WizardActiveGuard::activate();
let ctx = MutationCtx {
config: &tc.config,
rate_limiter: &tc.rate_limiter,
audit_log: &tc.audit_log,
client_name: Some("claude-code"),
workspace_root: tc.workspace_root.clone(),
};
let result = gate_mutation(&ctx, "jarvy_discover_apply", "test");
assert!(
result.is_err(),
"wizard bypass must NOT skip the rate-limit gate — \
saturated limiter must refuse the call"
);
}
#[test]
#[serial_test::serial(jarvy_home_env)]
fn wizard_bypass_writes_single_audit_entry_not_two() {
let dir = TempDir::new().unwrap();
let audit_dir = dir.path().join("audit");
std::fs::create_dir_all(&audit_dir).unwrap();
let mut config = McpConfig::default();
config.mcp.require_confirmation = true;
config.mcp.audit_log = audit_dir.join("audit.log").to_string_lossy().into_owned();
let rate_limiter = RateLimiter::new(&config);
let audit_log = AuditLog::new(&config).expect("audit log must init");
let ctx = MutationCtx {
config: &config,
rate_limiter: &rate_limiter,
audit_log: &audit_log,
client_name: Some("claude-code"),
workspace_root: dir.path().to_path_buf(),
};
let _guard = WizardActiveGuard::activate();
gate_mutation(&ctx, "jarvy_discover_apply", "write ./jarvy.toml").unwrap();
drop(audit_log);
let audit_content =
std::fs::read_to_string(audit_dir.join("audit.log")).unwrap_or_default();
let mutation_count = audit_content
.lines()
.filter(|l| l.contains("\"action\":\"mcp_mutation\""))
.count();
assert_eq!(
mutation_count, 1,
"wizard-bypass must produce exactly ONE mcp_mutation audit \
entry (the pre-flight one). The old code double-logged. \
Audit log contents:\n{audit_content}"
);
}
#[test]
#[serial_test::serial(jarvy_home_env)]
#[allow(unsafe_code)]
fn is_wizard_session_returns_false_when_session_env_absent() {
unsafe {
std::env::remove_var(crate::wizard::session::SESSION_ID_ENV);
}
assert!(!is_wizard_session(), "no SESSION_ID_ENV → no bypass");
}
}