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
}
}
pub fn gate_mutation(
ctx: &MutationCtx<'_>,
tool_name: &str,
effect_summary: &str,
) -> McpResult<()> {
ctx.audit_log.log_mcp_mutation(
ctx.client_name,
tool_name,
false,
true,
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 {
return Ok(());
}
match prompt_mutation_confirmation(tool_name, effect_summary, ctx.client_name)? {
ConfirmationResult::Yes => 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;
}) {
tracing::warn!(
event = "mcp.auto_approve.persist_failed",
tool = %tool_name,
error = %e,
);
} else {
tracing::info!(
event = "mcp.auto_approve.enabled",
tool = %tool_name,
client = ctx.client_name.unwrap_or("unknown"),
);
}
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" }
}
}),
),
]
}
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(|| "./jarvy.toml".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(|| "./jarvy.toml".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(|| "./jarvy.toml".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(|| "./jarvy.toml".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(|| "./jarvy.toml".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(),
})),
}
}
#[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"));
}
}