diff --git a/server/src/http/mcp/agent_tools.rs b/server/src/http/mcp/agent_tools/mod.rs similarity index 81% rename from server/src/http/mcp/agent_tools.rs rename to server/src/http/mcp/agent_tools/mod.rs index 34b0d5ce..ea59a27b 100644 --- a/server/src/http/mcp/agent_tools.rs +++ b/server/src/http/mcp/agent_tools/mod.rs @@ -1,13 +1,19 @@ //! MCP agent tools — start, stop, wait, list, and inspect agents via MCP. + use crate::agents::PipelineStage; use crate::config::ProjectConfig; use crate::http::context::AppContext; -use crate::service::settings::get_editor_command; use crate::slog_warn; -use crate::worktree; use serde_json::{Value, json}; -pub(super) async fn tool_start_agent(args: &Value, ctx: &AppContext) -> Result { +mod worktree; + +pub(crate) use worktree::{ + get_worktree_commits, tool_create_worktree, tool_get_editor_command, tool_list_worktrees, + tool_remove_worktree, +}; + +pub(crate) async fn tool_start_agent(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) @@ -45,8 +51,7 @@ pub(super) async fn tool_start_agent(args: &Value, ctx: &AppContext) -> Result Option { +pub(crate) fn read_coverage_percent_from_json(project_root: &std::path::Path) -> Option { let path = project_root .join(".huskies") .join("coverage") @@ -58,7 +63,7 @@ pub(super) fn read_coverage_percent_from_json(project_root: &std::path::Path) -> .and_then(|v| v.as_f64()) } -pub(super) async fn tool_stop_agent(args: &Value, ctx: &AppContext) -> Result { +pub(crate) async fn tool_stop_agent(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) @@ -79,7 +84,7 @@ pub(super) async fn tool_stop_agent(args: &Value, ctx: &AppContext) -> Result Result { +pub(crate) fn tool_list_agents(ctx: &AppContext) -> Result { let project_root = ctx.services.agents.get_project_root(&ctx.state).ok(); let agents = ctx.services.agents.list_agents()?; serde_json::to_string_pretty(&json!( @@ -109,8 +114,7 @@ pub(super) fn tool_list_agents(ctx: &AppContext) -> Result { /// order. If `agent_name` is omitted, logs from every agent are included. /// Supports `lines` (tail the last N lines) and `filter` (substring match). /// If a named agent is currently running, its buffered in-memory events are -/// appended as "live" output so the caller sees everything in one call. -pub(super) async fn tool_get_agent_output( +pub(crate) async fn tool_get_agent_output( args: &Value, ctx: &AppContext, ) -> Result { @@ -196,7 +200,7 @@ pub(super) async fn tool_get_agent_output( Ok(output) } -pub(super) fn tool_get_agent_config(ctx: &AppContext) -> Result { +pub(crate) fn tool_get_agent_config(ctx: &AppContext) -> Result { let project_root = ctx.services.agents.get_project_root(&ctx.state)?; let config = ProjectConfig::load(&project_root)?; @@ -240,8 +244,7 @@ pub(super) fn tool_get_agent_config(ctx: &AppContext) -> Result /// /// Returns turns used, max turns, remaining turns, budget used, max budget, /// and remaining budget for the named agent. Fails if the agent is not -/// currently running or pending. -pub(super) fn tool_get_agent_remaining_turns_and_budget( +pub(crate) fn tool_get_agent_remaining_turns_and_budget( args: &Value, ctx: &AppContext, ) -> Result { @@ -361,7 +364,7 @@ pub(super) fn tool_get_agent_remaining_turns_and_budget( .map_err(|e| format!("Serialization error: {e}")) } -pub(super) async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Result { +pub(crate) async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) @@ -407,106 +410,10 @@ pub(super) async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Resul .map_err(|e| format!("Serialization error: {e}")) } -pub(super) async fn tool_create_worktree(args: &Value, ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - - let project_root = ctx.services.agents.get_project_root(&ctx.state)?; - let info = ctx - .services - .agents - .create_worktree(&project_root, story_id) - .await?; - - serde_json::to_string_pretty(&json!({ - "story_id": story_id, - "worktree_path": info.path.to_string_lossy(), - "branch": info.branch, - "base_branch": info.base_branch, - })) - .map_err(|e| format!("Serialization error: {e}")) -} - -pub(super) fn tool_list_worktrees(ctx: &AppContext) -> Result { - let project_root = ctx.services.agents.get_project_root(&ctx.state)?; - let entries = worktree::list_worktrees(&project_root)?; - - serde_json::to_string_pretty(&json!( - entries - .iter() - .map(|e| json!({ - "story_id": e.story_id, - "path": e.path.to_string_lossy(), - })) - .collect::>() - )) - .map_err(|e| format!("Serialization error: {e}")) -} - -pub(super) async fn tool_remove_worktree(args: &Value, ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - - let project_root = ctx.services.agents.get_project_root(&ctx.state)?; - let config = ProjectConfig::load(&project_root)?; - worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await?; - - Ok(format!("Worktree for story '{story_id}' removed.")) -} - -pub(super) fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result { - let worktree_path = args - .get("worktree_path") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: worktree_path")?; - - let editor = get_editor_command(&*ctx.store) - .ok_or_else(|| "No editor configured. Set one via PUT /api/settings/editor.".to_string())?; - - Ok(format!("{editor} {worktree_path}")) -} - -/// Run `git log ..HEAD --oneline` in the worktree and return the commit -/// summaries, or `None` if git is unavailable or there are no new commits. -pub(super) async fn get_worktree_commits( - worktree_path: &str, - base_branch: &str, -) -> Option> { - let wt = worktree_path.to_string(); - let base = base_branch.to_string(); - tokio::task::spawn_blocking(move || { - let output = std::process::Command::new("git") - .args(["log", &format!("{base}..HEAD"), "--oneline"]) - .current_dir(&wt) - .output() - .ok()?; - - if output.status.success() { - let lines: Vec = String::from_utf8(output.stdout) - .ok()? - .lines() - .filter(|l| !l.is_empty()) - .map(|l| l.to_string()) - .collect(); - Some(lines) - } else { - None - } - }) - .await - .ok() - .flatten() -} - #[cfg(test)] mod tests { use super::*; use crate::http::test_helpers::test_ctx; - use crate::store::StoreOps; #[test] fn tool_list_agents_empty() { @@ -794,92 +701,6 @@ stage = "coder" } } - #[tokio::test] - async fn tool_create_worktree_missing_story_id() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_create_worktree(&json!({}), &ctx).await; - assert!(result.is_err()); - assert!(result.unwrap_err().contains("story_id")); - } - - #[tokio::test] - async fn tool_remove_worktree_missing_story_id() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_remove_worktree(&json!({}), &ctx).await; - assert!(result.is_err()); - assert!(result.unwrap_err().contains("story_id")); - } - - #[test] - fn tool_list_worktrees_empty_dir() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_list_worktrees(&ctx).unwrap(); - let parsed: Vec = serde_json::from_str(&result).unwrap(); - assert!(parsed.is_empty()); - } - - // ── Editor command tool tests ───────────────────────────────── - - #[test] - fn tool_get_editor_command_missing_worktree_path() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_get_editor_command(&json!({}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("worktree_path")); - } - - #[test] - fn tool_get_editor_command_no_editor_configured() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_get_editor_command(&json!({"worktree_path": "/some/path"}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("No editor configured")); - } - - #[test] - fn tool_get_editor_command_formats_correctly() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - ctx.store.set("editor_command", json!("zed")); - - let result = tool_get_editor_command( - &json!({"worktree_path": "/home/user/worktrees/37_my_story"}), - &ctx, - ) - .unwrap(); - assert_eq!(result, "zed /home/user/worktrees/37_my_story"); - } - - #[test] - fn tool_get_editor_command_works_with_vscode() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - ctx.store.set("editor_command", json!("code")); - - let result = - tool_get_editor_command(&json!({"worktree_path": "/path/to/worktree"}), &ctx).unwrap(); - assert_eq!(result, "code /path/to/worktree"); - } - - #[test] - fn get_editor_command_in_tools_list() { - use super::super::handle_tools_list; - let resp = handle_tools_list(Some(json!(1))); - let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); - let tool = tools.iter().find(|t| t["name"] == "get_editor_command"); - assert!(tool.is_some(), "get_editor_command missing from tools list"); - let t = tool.unwrap(); - assert!(t["description"].is_string()); - let required = t["inputSchema"]["required"].as_array().unwrap(); - let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); - assert!(req_names.contains(&"worktree_path")); - } - #[tokio::test] async fn wait_for_agent_tool_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); diff --git a/server/src/http/mcp/agent_tools/worktree.rs b/server/src/http/mcp/agent_tools/worktree.rs new file mode 100644 index 00000000..efabc774 --- /dev/null +++ b/server/src/http/mcp/agent_tools/worktree.rs @@ -0,0 +1,195 @@ +//! MCP worktree tools — create, list, remove, get editor command, and read worktree commits. + +use serde_json::{Value, json}; + +use crate::http::context::AppContext; +use crate::service::settings::get_editor_command; +use crate::config::ProjectConfig; +use crate::worktree; + +pub(crate) async fn tool_create_worktree(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let project_root = ctx.services.agents.get_project_root(&ctx.state)?; + let info = ctx + .services + .agents + .create_worktree(&project_root, story_id) + .await?; + + serde_json::to_string_pretty(&json!({ + "story_id": story_id, + "worktree_path": info.path.to_string_lossy(), + "branch": info.branch, + "base_branch": info.base_branch, + })) + .map_err(|e| format!("Serialization error: {e}")) +} + +pub(crate) fn tool_list_worktrees(ctx: &AppContext) -> Result { + let project_root = ctx.services.agents.get_project_root(&ctx.state)?; + let entries = worktree::list_worktrees(&project_root)?; + + serde_json::to_string_pretty(&json!( + entries + .iter() + .map(|e| json!({ + "story_id": e.story_id, + "path": e.path.to_string_lossy(), + })) + .collect::>() + )) + .map_err(|e| format!("Serialization error: {e}")) +} + +pub(crate) async fn tool_remove_worktree(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let project_root = ctx.services.agents.get_project_root(&ctx.state)?; + let config = ProjectConfig::load(&project_root)?; + worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await?; + + Ok(format!("Worktree for story '{story_id}' removed.")) +} + +pub(crate) fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result { + let worktree_path = args + .get("worktree_path") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: worktree_path")?; + + let editor = get_editor_command(&*ctx.store) + .ok_or_else(|| "No editor configured. Set one via PUT /api/settings/editor.".to_string())?; + + Ok(format!("{editor} {worktree_path}")) +} + +/// Run `git log ..HEAD --oneline` in the worktree and return the commit +pub(crate) async fn get_worktree_commits( + worktree_path: &str, + base_branch: &str, +) -> Option> { + let wt = worktree_path.to_string(); + let base = base_branch.to_string(); + tokio::task::spawn_blocking(move || { + let output = std::process::Command::new("git") + .args(["log", &format!("{base}..HEAD"), "--oneline"]) + .current_dir(&wt) + .output() + .ok()?; + + if output.status.success() { + let lines: Vec = String::from_utf8(output.stdout) + .ok()? + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(); + Some(lines) + } else { + None + } + }) + .await + .ok() + .flatten() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::test_helpers::test_ctx; + use crate::store::StoreOps; + + #[tokio::test] + async fn tool_create_worktree_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_create_worktree(&json!({}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[tokio::test] + async fn tool_remove_worktree_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_remove_worktree(&json!({}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[test] + fn tool_list_worktrees_empty_dir() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_list_worktrees(&ctx).unwrap(); + let parsed: Vec = serde_json::from_str(&result).unwrap(); + assert!(parsed.is_empty()); + } + + // ── Editor command tool tests ───────────────────────────────── + + #[test] + fn tool_get_editor_command_missing_worktree_path() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_get_editor_command(&json!({}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("worktree_path")); + } + + #[test] + fn tool_get_editor_command_no_editor_configured() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_get_editor_command(&json!({"worktree_path": "/some/path"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("No editor configured")); + } + + #[test] + fn tool_get_editor_command_formats_correctly() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + ctx.store.set("editor_command", json!("zed")); + + let result = tool_get_editor_command( + &json!({"worktree_path": "/home/user/worktrees/37_my_story"}), + &ctx, + ) + .unwrap(); + assert_eq!(result, "zed /home/user/worktrees/37_my_story"); + } + + #[test] + fn tool_get_editor_command_works_with_vscode() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + ctx.store.set("editor_command", json!("code")); + + let result = + tool_get_editor_command(&json!({"worktree_path": "/path/to/worktree"}), &ctx).unwrap(); + assert_eq!(result, "code /path/to/worktree"); + } + + #[test] + fn get_editor_command_in_tools_list() { + use super::super::super::tools_list::handle_tools_list; + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + let tool = tools.iter().find(|t| t["name"] == "get_editor_command"); + assert!(tool.is_some(), "get_editor_command missing from tools list"); + let t = tool.unwrap(); + assert!(t["description"].is_string()); + let required = t["inputSchema"]["required"].as_array().unwrap(); + let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(req_names.contains(&"worktree_path")); + } +}