From 0dff2d5c475c131608dbb9a333d71d0e7fcc716b Mon Sep 17 00:00:00 2001 From: dave Date: Sun, 26 Apr 2026 21:05:07 +0000 Subject: [PATCH] refactor: split http/mcp/mod.rs into 3 logical files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1882-line mod.rs is split into: - tools_list.rs: handle_tools_list — the static schema for every MCP tool (1172 lines) - dispatch.rs: handle_tools_call — the tool-name → *_tools router (157 lines) - mod.rs: doc, sub-mod decls, JsonRpc structs, Poem handlers, handle_initialize (586 lines) Tests stay co-located with the code they exercise. No behaviour change. All 267 http::mcp tests pass; full suite green (2635 tests with --test-threads=1). --- server/src/http/mcp/dispatch.rs | 162 ++++ server/src/http/mcp/mod.rs | 1314 +---------------------------- server/src/http/mcp/tools_list.rs | 1172 +++++++++++++++++++++++++ 3 files changed, 1343 insertions(+), 1305 deletions(-) create mode 100644 server/src/http/mcp/dispatch.rs create mode 100644 server/src/http/mcp/tools_list.rs diff --git a/server/src/http/mcp/dispatch.rs b/server/src/http/mcp/dispatch.rs new file mode 100644 index 00000000..a2f1c29d --- /dev/null +++ b/server/src/http/mcp/dispatch.rs @@ -0,0 +1,162 @@ +//! `tools/call` MCP method — dispatches a tool name to the appropriate `*_tools` module. + +use serde_json::{Value, json}; + +use super::JsonRpcResponse; +use crate::http::context::AppContext; +use super::{ + agent_tools, diagnostics, git_tools, merge_tools, qa_tools, shell_tools, status_tools, + story_tools, wizard_tools, +}; +use crate::slog_warn; + +// ── Tool dispatch ───────────────────────────────────────────────── + +pub(super) async fn handle_tools_call(id: Option, params: &Value, ctx: &AppContext) -> JsonRpcResponse { + let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let args = params.get("arguments").cloned().unwrap_or(json!({})); + + let result = match tool_name { + // Workflow tools + "create_story" => story_tools::tool_create_story(&args, ctx), + "validate_stories" => story_tools::tool_validate_stories(ctx), + "list_upcoming" => story_tools::tool_list_upcoming(ctx), + "get_story_todos" => story_tools::tool_get_story_todos(&args, ctx), + "record_tests" => story_tools::tool_record_tests(&args, ctx), + "ensure_acceptance" => story_tools::tool_ensure_acceptance(&args, ctx), + // Agent tools (async) + "start_agent" => agent_tools::tool_start_agent(&args, ctx).await, + "stop_agent" => agent_tools::tool_stop_agent(&args, ctx).await, + "list_agents" => agent_tools::tool_list_agents(ctx), + "get_agent_config" => agent_tools::tool_get_agent_config(ctx), + "reload_agent_config" => agent_tools::tool_get_agent_config(ctx), + "get_agent_output" => agent_tools::tool_get_agent_output(&args, ctx).await, + "wait_for_agent" => agent_tools::tool_wait_for_agent(&args, ctx).await, + "get_agent_remaining_turns_and_budget" => { + agent_tools::tool_get_agent_remaining_turns_and_budget(&args, ctx) + } + // Worktree tools + "create_worktree" => agent_tools::tool_create_worktree(&args, ctx).await, + "list_worktrees" => agent_tools::tool_list_worktrees(ctx), + "remove_worktree" => agent_tools::tool_remove_worktree(&args, ctx).await, + // Editor tools + "get_editor_command" => agent_tools::tool_get_editor_command(&args, ctx), + // Lifecycle tools + "accept_story" => story_tools::tool_accept_story(&args, ctx), + // Story mutation tools (auto-commit to master) + "check_criterion" => story_tools::tool_check_criterion(&args, ctx), + "edit_criterion" => story_tools::tool_edit_criterion(&args, ctx), + "add_criterion" => story_tools::tool_add_criterion(&args, ctx), + "remove_criterion" => story_tools::tool_remove_criterion(&args, ctx), + "update_story" => story_tools::tool_update_story(&args, ctx), + // Spike lifecycle tools + "create_spike" => story_tools::tool_create_spike(&args, ctx), + // Bug lifecycle tools + "create_bug" => story_tools::tool_create_bug(&args, ctx), + "list_bugs" => story_tools::tool_list_bugs(ctx), + "close_bug" => story_tools::tool_close_bug(&args, ctx), + // Refactor lifecycle tools + "create_refactor" => story_tools::tool_create_refactor(&args, ctx), + "list_refactors" => story_tools::tool_list_refactors(ctx), + // Mergemaster tools + "merge_agent_work" => merge_tools::tool_merge_agent_work(&args, ctx).await, + "get_merge_status" => merge_tools::tool_get_merge_status(&args, ctx), + "move_story_to_merge" => merge_tools::tool_move_story_to_merge(&args, ctx).await, + "report_merge_failure" => merge_tools::tool_report_merge_failure(&args, ctx), + // QA tools + "request_qa" => qa_tools::tool_request_qa(&args, ctx).await, + "approve_qa" => qa_tools::tool_approve_qa(&args, ctx).await, + "reject_qa" => qa_tools::tool_reject_qa(&args, ctx).await, + "launch_qa_app" => qa_tools::tool_launch_qa_app(&args, ctx).await, + // Pipeline status + "get_pipeline_status" => story_tools::tool_get_pipeline_status(ctx), + // Diagnostics + "get_server_logs" => diagnostics::tool_get_server_logs(&args), + "get_version" => diagnostics::tool_get_version(ctx), + // Server lifecycle + "rebuild_and_restart" => diagnostics::tool_rebuild_and_restart(ctx).await, + // Permission bridge (Claude Code → frontend dialog) + "prompt_permission" => diagnostics::tool_prompt_permission(&args, ctx).await, + // Token usage + "get_token_usage" => diagnostics::tool_get_token_usage(&args, ctx), + // Delete story + "delete_story" => story_tools::tool_delete_story(&args, ctx).await, + // Purge story (CRDT tombstone — story 521) + "purge_story" => story_tools::tool_purge_story(&args, ctx), + // Debug CRDT dump (story 515) + "dump_crdt" => diagnostics::tool_dump_crdt(&args), + // Arbitrary pipeline movement + "move_story" => diagnostics::tool_move_story(&args, ctx), + // Unblock story + "unblock_story" => story_tools::tool_unblock_story(&args, ctx), + // Shell command execution + "run_command" => shell_tools::tool_run_command(&args, ctx).await, + "run_tests" => shell_tools::tool_run_tests(&args, ctx).await, + "get_test_result" => shell_tools::tool_get_test_result(&args, ctx).await, + "run_build" => shell_tools::tool_run_build(&args, ctx).await, + "run_lint" => shell_tools::tool_run_lint(&args, ctx).await, + // Git operations + "git_status" => git_tools::tool_git_status(&args, ctx).await, + "git_diff" => git_tools::tool_git_diff(&args, ctx).await, + "git_add" => git_tools::tool_git_add(&args, ctx).await, + "git_commit" => git_tools::tool_git_commit(&args, ctx).await, + "git_log" => git_tools::tool_git_log(&args, ctx).await, + // Story triage + "status" => status_tools::tool_status(&args, ctx).await, + // File line count + "loc_file" => diagnostics::tool_loc_file(&args, ctx), + // Setup wizard tools + "wizard_status" => wizard_tools::tool_wizard_status(ctx), + "wizard_generate" => wizard_tools::tool_wizard_generate(&args, ctx), + "wizard_confirm" => wizard_tools::tool_wizard_confirm(ctx), + "wizard_skip" => wizard_tools::tool_wizard_skip(ctx), + "wizard_retry" => wizard_tools::tool_wizard_retry(ctx), + _ => Err(format!("Unknown tool: {tool_name}")), + }; + + match result { + Ok(content) => JsonRpcResponse::success( + id, + json!({ + "content": [{ "type": "text", "text": content }] + }), + ), + Err(msg) => { + slog_warn!("[mcp] Tool call failed: tool={tool_name} error={msg}"); + JsonRpcResponse::success( + id, + json!({ + "content": [{ "type": "text", "text": msg }], + "isError": true + }), + ) + } + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::test_helpers::test_ctx; + + #[test] + fn handle_tools_call_unknown_tool() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let rt = tokio::runtime::Runtime::new().unwrap(); + let resp = rt.block_on(handle_tools_call( + Some(json!(1)), + &json!({"name": "bogus_tool", "arguments": {}}), + &ctx, + )); + let result = resp.result.unwrap(); + assert_eq!(result["isError"], true); + assert!( + result["content"][0]["text"] + .as_str() + .unwrap() + .contains("Unknown tool") + ); + } +} diff --git a/server/src/http/mcp/mod.rs b/server/src/http/mcp/mod.rs index 1d53e3e1..72b1fa90 100644 --- a/server/src/http/mcp/mod.rs +++ b/server/src/http/mcp/mod.rs @@ -1,4 +1,5 @@ -//! MCP server — Model Context Protocol endpoint dispatching tool calls to handlers. +//! HTTP MCP server module. + use crate::http::context::AppContext; use crate::slog_warn; use poem::handler; @@ -19,6 +20,13 @@ pub mod status_tools; pub mod story_tools; pub mod wizard_tools; + +mod dispatch; +mod tools_list; + +use dispatch::handle_tools_call; +use tools_list::handle_tools_list; + /// Returns true when the Accept header includes text/event-stream. fn wants_sse(req: &Request) -> bool { req.header("accept") @@ -198,1209 +206,6 @@ fn handle_initialize(id: Option, params: &Value) -> JsonRpcResponse { ) } -fn handle_tools_list(id: Option) -> JsonRpcResponse { - JsonRpcResponse::success( - id, - json!({ - "tools": [ - { - "name": "create_story", - "description": "Create a new story file with front matter in upcoming/. Returns the story_id.", - "inputSchema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Human-readable story name" - }, - "user_story": { - "type": "string", - "description": "Optional user story text (As a..., I want..., so that...)" - }, - "description": { - "type": "string", - "description": "Optional description / background context for the story" - }, - "acceptance_criteria": { - "type": "array", - "items": { "type": "string" }, - "description": "Optional list of acceptance criteria" - }, - "depends_on": { - "type": "array", - "items": { "type": "integer" }, - "description": "Optional list of story IDs this story depends on; written as a YAML inline sequence in front matter" - }, - "commit": { - "type": "boolean", - "description": "If true, git-add and git-commit the new story file to the current branch" - } - }, - "required": ["name"] - } - }, - { - "name": "validate_stories", - "description": "Validate front matter on all current and upcoming story files.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "list_upcoming", - "description": "List all upcoming stories with their names and any parsing errors.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "get_story_todos", - "description": "Get unchecked acceptance criteria (todos) for a story file in current/.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (filename stem, e.g. '28_my_story')" - } - }, - "required": ["story_id"] - } - }, - { - "name": "record_tests", - "description": "Record test results for a story. Only one failing test at a time is allowed.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier" - }, - "unit": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "status": { "type": "string", "enum": ["pass", "fail"] }, - "details": { "type": "string" } - }, - "required": ["name", "status"] - }, - "description": "Unit test results" - }, - "integration": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "status": { "type": "string", "enum": ["pass", "fail"] }, - "details": { "type": "string" } - }, - "required": ["name", "status"] - }, - "description": "Integration test results" - } - }, - "required": ["story_id", "unit", "integration"] - } - }, - { - "name": "ensure_acceptance", - "description": "Check whether a story can be accepted. Returns acceptance status with reasons if blocked.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier" - } - }, - "required": ["story_id"] - } - }, - { - "name": "start_agent", - "description": "Start an agent for a story. Creates a worktree, runs setup, and spawns the agent process.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (e.g. '28_my_story')" - }, - "agent_name": { - "type": "string", - "description": "Agent name from project.toml config. If omitted, uses the first coder agent (stage = \"coder\"). Supervisor must be requested explicitly by name." - } - }, - "required": ["story_id"] - } - }, - { - "name": "stop_agent", - "description": "Stop a running agent. Worktree is preserved for inspection.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier" - }, - "agent_name": { - "type": "string", - "description": "Agent name to stop" - } - }, - "required": ["story_id", "agent_name"] - } - }, - { - "name": "list_agents", - "description": "List all agents with their current status, story assignment, and worktree path.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "get_agent_config", - "description": "Get the configured agent roster from project.toml (names, roles, models, allowed tools, limits).", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "reload_agent_config", - "description": "Reload project.toml and return the updated agent roster.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "get_agent_output", - "description": "Read agent session logs from disk as a human-readable timeline. Stitches all sessions for the story together in chronological order — text output, tool calls, tool results, errors. Works for both running and completed agents. If agent_name is omitted, returns logs from every agent that worked on the story. If the named agent is currently running, live buffered events are appended.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (e.g. '42_story_my_feature')" - }, - "agent_name": { - "type": "string", - "description": "Optional: filter to a specific agent (e.g. 'mergemaster', 'coder-1'). Omit to see all agents." - }, - "lines": { - "type": "integer", - "description": "Optional: return only the last N lines (tail). Useful for large logs." - }, - "filter": { - "type": "string", - "description": "Optional: return only lines containing this substring (e.g. 'ERROR', 'TOOL:', a function name)." - } - }, - "required": ["story_id"] - } - }, - { - "name": "wait_for_agent", - "description": "Block until the agent reaches a terminal state (completed, failed, stopped). Returns final status and summary including session_id, worktree_path, and any commits made. Use this instead of polling get_agent_output when you want to fire-and-forget and be notified on completion.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier" - }, - "agent_name": { - "type": "string", - "description": "Agent name to wait for" - }, - "timeout_ms": { - "type": "integer", - "description": "Maximum time to wait in milliseconds (default: 300000 = 5 minutes)" - } - }, - "required": ["story_id", "agent_name"] - } - }, - { - "name": "get_agent_remaining_turns_and_budget", - "description": "Get remaining turns and budget for a running agent. Returns turns used, max turns, remaining turns, budget used (from completed sessions), max budget, and remaining budget. Only works for agents in running or pending state.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (e.g. '42_story_my_feature')" - }, - "agent_name": { - "type": "string", - "description": "Agent name (e.g. 'coder-1', 'mergemaster', 'qa')" - } - }, - "required": ["story_id", "agent_name"] - } - }, - { - "name": "create_worktree", - "description": "Create a git worktree for a story under .huskies/worktrees/{story_id} with deterministic naming. Writes .mcp.json and runs component setup. Returns the worktree path.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (e.g. '42_my_story')" - } - }, - "required": ["story_id"] - } - }, - { - "name": "list_worktrees", - "description": "List all worktrees under .huskies/worktrees/ for the current project.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "remove_worktree", - "description": "Remove a git worktree and its feature branch for a story.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier" - } - }, - "required": ["story_id"] - } - }, - { - "name": "get_editor_command", - "description": "Get the open-in-editor command for a worktree. Returns a ready-to-paste shell command like 'zed /path/to/worktree'. Requires the editor preference to be configured via PUT /api/settings/editor.", - "inputSchema": { - "type": "object", - "properties": { - "worktree_path": { - "type": "string", - "description": "Absolute path to the worktree directory" - } - }, - "required": ["worktree_path"] - } - }, - { - "name": "accept_story", - "description": "Accept a story: moves it from current/ to done/ and auto-commits to master.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (filename stem, e.g. '28_my_story')" - } - }, - "required": ["story_id"] - } - }, - { - "name": "check_criterion", - "description": "Check off an acceptance criterion (- [ ] → - [x]) by 0-based index among unchecked items, then auto-commit to master. Use get_story_todos to see the current list of unchecked criteria.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (filename stem, e.g. '28_my_story')" - }, - "criterion_index": { - "type": "integer", - "description": "0-based index of the unchecked criterion to check off" - } - }, - "required": ["story_id", "criterion_index"] - } - }, - { - "name": "edit_criterion", - "description": "Update the text of an existing acceptance criterion in place, preserving its checked/unchecked state. Uses a 0-based index counting all criteria (both checked and unchecked).", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (filename stem, e.g. '28_my_story')" - }, - "criterion_index": { - "type": "integer", - "description": "0-based index of the criterion to edit (counts all criteria)" - }, - "new_text": { - "type": "string", - "description": "New text for the criterion (without the '- [ ] ' or '- [x] ' prefix)" - } - }, - "required": ["story_id", "criterion_index", "new_text"] - } - }, - { - "name": "add_criterion", - "description": "Add an acceptance criterion to an existing story file. Appends '- [ ] {criterion}' after the last existing criterion in the '## Acceptance Criteria' section. Auto-commits via the filesystem watcher.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (filename stem, e.g. '28_my_story')" - }, - "criterion": { - "type": "string", - "description": "The acceptance criterion text to add (without the '- [ ] ' prefix)" - } - }, - "required": ["story_id", "criterion"] - } - }, - { - "name": "remove_criterion", - "description": "Remove an acceptance criterion from a story by its 0-based index (counting all criteria, both checked and unchecked). Returns an error if the index is out of range. Auto-commits via the filesystem watcher.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (filename stem, e.g. '28_my_story')" - }, - "criterion_index": { - "type": "integer", - "description": "0-based index of the criterion to remove (counts all criteria)" - } - }, - "required": ["story_id", "criterion_index"] - } - }, - { - "name": "update_story", - "description": "Update an existing story file. Can replace the '## User Story' and/or '## Description' section content, and/or set YAML front matter fields (e.g. agent, qa). Auto-commits via the filesystem watcher.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (filename stem, e.g. '28_my_story')" - }, - "user_story": { - "type": "string", - "description": "New user story text to replace the '## User Story' section content" - }, - "description": { - "type": "string", - "description": "New description text to replace the '## Description' section content" - }, - "agent": { - "type": "string", - "description": "Set or change the 'agent' YAML front matter field" - }, - "front_matter": { - "type": "object", - "description": "Arbitrary YAML front matter key-value pairs to set or update. Values may be strings, booleans, integers, numbers, or arrays (e.g. [490, 491]).", - "additionalProperties": { - "oneOf": [ - {"type": "string"}, - {"type": "boolean"}, - {"type": "integer"}, - {"type": "number"}, - {"type": "array"} - ] - } - } - }, - "required": ["story_id"] - } - }, - { - "name": "create_spike", - "description": "Create a spike file in .huskies/work/1_backlog/ with a deterministic filename and YAML front matter. Returns the spike_id.", - "inputSchema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Human-readable spike name" - }, - "description": { - "type": "string", - "description": "Optional description / question the spike aims to answer" - } - }, - "required": ["name"] - } - }, - { - "name": "create_bug", - "description": "Create a bug file in work/1_backlog/ with a deterministic filename and auto-commit to master. Returns the bug_id.", - "inputSchema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Short human-readable bug name" - }, - "description": { - "type": "string", - "description": "Description of the bug" - }, - "steps_to_reproduce": { - "type": "string", - "description": "Steps to reproduce the bug" - }, - "actual_result": { - "type": "string", - "description": "What actually happens" - }, - "expected_result": { - "type": "string", - "description": "What should happen" - }, - "acceptance_criteria": { - "type": "array", - "items": { "type": "string" }, - "description": "Optional list of acceptance criteria for the fix" - }, - "depends_on": { - "type": "array", - "items": { "type": "integer" }, - "description": "Optional list of story numbers this bug depends on (e.g. [42, 43]). Persisted as depends_on in YAML front matter." - } - }, - "required": ["name", "description", "steps_to_reproduce", "actual_result", "expected_result"] - } - }, - { - "name": "list_bugs", - "description": "List all open bugs in work/1_backlog/ matching the _bug_ naming convention.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "create_refactor", - "description": "Create a refactor work item in work/1_backlog/ with a deterministic filename and YAML front matter. Returns the refactor_id.", - "inputSchema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Short human-readable refactor name" - }, - "description": { - "type": "string", - "description": "Optional description of the desired state after refactoring" - }, - "acceptance_criteria": { - "type": "array", - "items": { "type": "string" }, - "description": "Optional list of acceptance criteria" - }, - "depends_on": { - "type": "array", - "items": { "type": "integer" }, - "description": "Optional list of story numbers this refactor depends on (e.g. [42, 43]). Persisted as depends_on in YAML front matter." - } - }, - "required": ["name"] - } - }, - { - "name": "list_refactors", - "description": "List all open refactors in work/1_backlog/ matching the _refactor_ naming convention.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "close_bug", - "description": "Archive a bug from work/2_current/ or work/1_backlog/ to work/5_done/ and auto-commit to master.", - "inputSchema": { - "type": "object", - "properties": { - "bug_id": { - "type": "string", - "description": "Bug identifier (e.g. 'bug-3-login_crash')" - } - }, - "required": ["bug_id"] - } - }, - { - "name": "merge_agent_work", - "description": "Run the mergemaster pipeline for a completed story. Blocks until the merge completes or fails, then returns the full result — no polling needed. The pipeline squash-merges the feature branch into master, runs quality gates, moves the story to done, and cleans up.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (e.g. '52_story_mergemaster_agent_role')" - }, - "agent_name": { - "type": "string", - "description": "Optional: name of the coder agent whose work is being merged (for logging)" - } - }, - "required": ["story_id"] - } - }, - { - "name": "get_merge_status", - "description": "Check the cached result of a merge_agent_work job. Returns the full merge report immediately — no polling needed. Useful if merge_agent_work already returned but you need the result again.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (same as passed to merge_agent_work)" - } - }, - "required": ["story_id"] - } - }, - { - "name": "move_story_to_merge", - "description": "Move a story or bug from work/2_current/ to work/4_merge/ to queue it for the mergemaster pipeline and automatically spawn the mergemaster agent to squash-merge, run quality gates, and archive.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (filename stem, e.g. '28_my_story')" - }, - "agent_name": { - "type": "string", - "description": "Agent name to use for merging (defaults to 'mergemaster')" - } - }, - "required": ["story_id"] - } - }, - { - "name": "report_merge_failure", - "description": "Report that a merge failed for a story. Leaves the story in work/4_merge/ and logs the failure reason. Use this when merge_agent_work returns success=false instead of manually moving the story file.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (e.g. '52_story_mergemaster_agent_role')" - }, - "reason": { - "type": "string", - "description": "Human-readable explanation of why the merge failed" - } - }, - "required": ["story_id", "reason"] - } - }, - { - "name": "request_qa", - "description": "Trigger QA review of a completed story worktree: moves the item from work/2_current/ to work/3_qa/ and starts the qa agent to run quality gates, tests, and generate a manual testing plan.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (e.g. '53_story_qa_agent_role')" - }, - "agent_name": { - "type": "string", - "description": "Agent name to use for QA (defaults to 'qa')" - } - }, - "required": ["story_id"] - } - }, - { - "name": "approve_qa", - "description": "Approve a story that passed machine QA and is awaiting human review. Moves the story from work/3_qa/ to work/4_merge/ and starts the mergemaster agent.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (e.g. '247_story_human_qa_gate')" - } - }, - "required": ["story_id"] - } - }, - { - "name": "reject_qa", - "description": "Reject a story during human QA review. Moves the story from work/3_qa/ back to work/2_current/ with rejection notes so the coder agent can fix the issues.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (e.g. '247_story_human_qa_gate')" - }, - "notes": { - "type": "string", - "description": "Explanation of what is broken or needs fixing" - } - }, - "required": ["story_id", "notes"] - } - }, - { - "name": "launch_qa_app", - "description": "Launch the app from a story's worktree for manual QA testing. Automatically assigns a free port, writes it to .huskies_port, and starts the backend server. Only one QA app instance runs at a time.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier whose worktree app to launch" - } - }, - "required": ["story_id"] - } - }, - { - "name": "get_pipeline_status", - "description": "Return a structured snapshot of the full work item pipeline. Includes all active stages (current, qa, merge, done) with each item's stage, name, and assigned agent. Also includes upcoming backlog items.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "get_server_logs", - "description": "Return recent server log lines captured in the in-process ring buffer. Useful for diagnosing runtime behaviour such as WebSocket events, MCP call flow, and filesystem watcher activity.", - "inputSchema": { - "type": "object", - "properties": { - "lines": { - "type": "integer", - "description": "Number of recent lines to return (default 100, max 1000)" - }, - "filter": { - "type": "string", - "description": "Optional substring filter (e.g. 'watcher', 'mcp', 'permission')" - }, - "severity": { - "type": "string", - "description": "Filter by severity level: ERROR, WARN, or INFO. Returns only entries at that level." - } - } - } - }, - { - "name": "get_version", - "description": "Return the server version, build hash, and running port.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "rebuild_and_restart", - "description": "Rebuild the server binary from source and re-exec with the new binary. Gracefully stops all running agents before restart. If the build fails, the server stays up and returns the build error.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "prompt_permission", - "description": "Present a permission request to the user via the web UI. Used by Claude Code's --permission-prompt-tool to delegate permission decisions to the frontend dialog. Returns on approval; returns an error on denial.", - "inputSchema": { - "type": "object", - "properties": { - "tool_name": { - "type": "string", - "description": "The tool requesting permission (e.g. 'Bash', 'Write')" - }, - "input": { - "type": "object", - "description": "The tool's input arguments" - } - }, - "required": ["tool_name", "input"] - } - }, - { - "name": "get_token_usage", - "description": "Return per-agent token usage records from the persistent log. Shows input tokens, output tokens, cache tokens, and cost in USD for each agent session. Optionally filter by story_id.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Optional: filter records to a specific story (e.g. '42_my_story')" - } - } - } - }, - { - "name": "delete_story", - "description": "Delete a work item from the pipeline entirely. Stops any running agent, removes the worktree, and deletes the story file. Use only for removing obsolete or duplicate items.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Work item identifier (filename stem, e.g. '28_story_my_feature')" - } - }, - "required": ["story_id"] - } - }, - { - "name": "purge_story", - "description": "Write a CRDT tombstone op for a story (story 521). Marks the in-memory CRDT item as deleted, persists the tombstone to crdt_ops so it survives restart, and drops the in-memory content store entry. Does NOT touch running agents, worktrees, the pipeline_items shadow table, timers.json, or filesystem shadows — compose with stop_agent / remove_worktree / etc. for a full cleanup. Use this when a story has gone zombie in the running server's in-memory state and direct sqlite deletes alone are not enough to clear it.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Work item identifier (filename stem, e.g. '28_story_my_feature')" - } - }, - "required": ["story_id"] - } - }, - { - "name": "dump_crdt", - "description": "DEBUG TOOL: Dump the raw in-memory CRDT state. Returns every item the running server knows about, including tombstoned (deleted) entries, with internal op metadata (content_index, is_deleted, stage, etc.). Use this when diagnosing CRDT/state divergence — NOT for normal pipeline introspection (use get_pipeline_status for that). Optional story_id filter returns a single item.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Optional: restrict output to this single work item identifier (filename stem, e.g. '42_story_my_feature')" - } - }, - "required": [] - } - }, - { - "name": "move_story", - "description": "Move a work item (story, bug, spike, or refactor) to an arbitrary pipeline stage. Prefer dedicated tools when available: use accept_story to mark items done, move_story_to_merge to queue for merging, or request_qa to trigger QA review. Use move_story only for arbitrary moves that lack a dedicated tool — for example, moving a story back to backlog or recovering a ghost story by moving it back to current.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Work item identifier (filename stem, e.g. '28_story_my_feature')" - }, - "target_stage": { - "type": "string", - "enum": ["backlog", "current", "qa", "merge", "done"], - "description": "Target pipeline stage: backlog (1_backlog), current (2_current), qa (3_qa), merge (4_merge), done (5_done)" - } - }, - "required": ["story_id", "target_stage"] - } - }, - { - "name": "unblock_story", - "description": "Clear the blocked flag and reset retry_count to 0 on a work item. Use this when an agent is stuck and needs to be restarted from a clean state.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Work item identifier (filename stem, e.g. '42_story_my_feature')" - } - }, - "required": ["story_id"] - } - }, - { - "name": "run_command", - "description": "Execute a shell command in an agent's worktree directory. The working_dir must be inside .huskies/worktrees/. Returns stdout, stderr, exit_code, and timed_out. Supports SSE streaming (send Accept: text/event-stream) for long-running commands. Dangerous commands (rm -rf /, sudo, etc.) are blocked.", - "inputSchema": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The bash command to execute (passed to bash -c)" - }, - "working_dir": { - "type": "string", - "description": "Absolute path to the worktree directory to run the command in. Must be inside .huskies/worktrees/." - }, - "timeout": { - "type": "integer", - "description": "Timeout in seconds (default: 120, max: 600)" - } - }, - "required": ["command", "working_dir"] - } - }, - { - "name": "run_tests", - "description": "Start the project's test suite (script/test) as a background job. Returns immediately with {\"status\": \"started\"}. Poll get_test_result with the same worktree_path to check for completion. If the previous run already finished, returns the result inline.", - "inputSchema": { - "type": "object", - "properties": { - "worktree_path": { - "type": "string", - "description": "Optional absolute path to a worktree to run tests in. Must be inside .huskies/worktrees/. Defaults to the project root." - } - }, - "required": [] - } - }, - { - "name": "get_test_result", - "description": "Check on a running test job started by run_tests. Returns {\"status\": \"running\", \"elapsed_secs\": N} if still in progress, or the full test result (passed, exit_code, test counts, output) if finished.", - "inputSchema": { - "type": "object", - "properties": { - "worktree_path": { - "type": "string", - "description": "Optional absolute path to the worktree. Must match the worktree_path used in run_tests." - } - }, - "required": [] - } - }, - { - "name": "run_build", - "description": "Run the project's build script (script/build) in the given worktree and return the result as truncated JSON with passed, exit_code, and output fields.", - "inputSchema": { - "type": "object", - "properties": { - "worktree_path": { - "type": "string", - "description": "Optional absolute path to a worktree to run the build in. Must be inside .huskies/worktrees/. Defaults to the project root." - } - }, - "required": [] - } - }, - { - "name": "run_lint", - "description": "Run the project's lint script (script/lint) in the given worktree and return the result as truncated JSON with passed, exit_code, and output fields.", - "inputSchema": { - "type": "object", - "properties": { - "worktree_path": { - "type": "string", - "description": "Optional absolute path to a worktree to run lint in. Must be inside .huskies/worktrees/. Defaults to the project root." - } - }, - "required": [] - } - }, - { - "name": "git_status", - "description": "Return the working tree status of an agent's worktree (staged, unstaged, and untracked files). The worktree_path must be inside .huskies/worktrees/. Push and remote operations are not available.", - "inputSchema": { - "type": "object", - "properties": { - "worktree_path": { - "type": "string", - "description": "Absolute path to the worktree directory. Must be inside .huskies/worktrees/." - } - }, - "required": ["worktree_path"] - } - }, - { - "name": "git_diff", - "description": "Return diff output for an agent's worktree. Supports unstaged (default), staged, or a commit range. The worktree_path must be inside .huskies/worktrees/.", - "inputSchema": { - "type": "object", - "properties": { - "worktree_path": { - "type": "string", - "description": "Absolute path to the worktree directory. Must be inside .huskies/worktrees/." - }, - "staged": { - "type": "boolean", - "description": "If true, show staged diff (--staged). Default: false." - }, - "commit_range": { - "type": "string", - "description": "Optional commit range (e.g. 'HEAD~3..HEAD', 'abc123..def456')." - } - }, - "required": ["worktree_path"] - } - }, - { - "name": "git_add", - "description": "Stage files by path in an agent's worktree. The worktree_path must be inside .huskies/worktrees/.", - "inputSchema": { - "type": "object", - "properties": { - "worktree_path": { - "type": "string", - "description": "Absolute path to the worktree directory. Must be inside .huskies/worktrees/." - }, - "paths": { - "type": "array", - "items": { "type": "string" }, - "description": "List of file paths to stage (relative to worktree_path)." - } - }, - "required": ["worktree_path", "paths"] - } - }, - { - "name": "git_commit", - "description": "Commit staged changes in an agent's worktree with the given message. The worktree_path must be inside .huskies/worktrees/. Push and remote operations are not available.", - "inputSchema": { - "type": "object", - "properties": { - "worktree_path": { - "type": "string", - "description": "Absolute path to the worktree directory. Must be inside .huskies/worktrees/." - }, - "message": { - "type": "string", - "description": "Commit message." - } - }, - "required": ["worktree_path", "message"] - } - }, - { - "name": "git_log", - "description": "Return commit history for an agent's worktree with configurable count and format. The worktree_path must be inside .huskies/worktrees/.", - "inputSchema": { - "type": "object", - "properties": { - "worktree_path": { - "type": "string", - "description": "Absolute path to the worktree directory. Must be inside .huskies/worktrees/." - }, - "count": { - "type": "integer", - "description": "Number of commits to return (default: 10, max: 500)." - }, - "format": { - "type": "string", - "description": "git pretty-format string (default: '%H%x09%s%x09%an%x09%ai')." - } - }, - "required": ["worktree_path"] - } - }, - { - "name": "status", - "description": "Get a full triage dump for an in-progress story: front matter, AC checklist, active worktree/branch, git diff --stat since master, last 5 commits, and last 20 lines of the most recent agent log. Returns a clear error if the story is not in work/2_current/.", - "inputSchema": { - "type": "object", - "properties": { - "story_id": { - "type": "string", - "description": "Story identifier (filename stem, e.g. '42_story_my_feature')" - } - }, - "required": ["story_id"] - } - }, - { - "name": "loc_file", - "description": "Return the line count for a specific file. Path is resolved relative to the project root. Returns an error if the file does not exist.", - "inputSchema": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to the file, relative to the project root (e.g. 'server/src/main.rs')" - } - }, - "required": ["file_path"] - } - }, - { - "name": "wizard_status", - "description": "Return the current setup wizard state: which step is active, and which are done/skipped/pending. Use this to inspect progress before calling wizard_generate, wizard_confirm, wizard_skip, or wizard_retry.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "wizard_generate", - "description": "Drive content generation for the current wizard step. Call with no arguments to mark the step as 'generating' and receive a hint about what to produce. Call again with a 'content' argument (the full file body you generated) to stage it for review. Content is NOT written to disk until wizard_confirm is called.", - "inputSchema": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "The generated file content to stage for the current step. Omit to receive a generation hint and mark the step as generating." - } - } - } - }, - { - "name": "wizard_confirm", - "description": "Confirm the current wizard step: writes any staged content to disk (only if the target file does not already exist) and advances to the next step. Existing files are never overwritten.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "wizard_skip", - "description": "Skip the current wizard step without writing any file. Use when a step does not apply to this project.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "wizard_retry", - "description": "Discard any staged content for the current wizard step and reset it to pending so it can be regenerated. Use when the generated content needs improvement.", - "inputSchema": { - "type": "object", - "properties": {} - } - } - ] - }), - ) -} - -// ── Tool dispatch ───────────────────────────────────────────────── - -async fn handle_tools_call(id: Option, params: &Value, ctx: &AppContext) -> JsonRpcResponse { - let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let args = params.get("arguments").cloned().unwrap_or(json!({})); - - let result = match tool_name { - // Workflow tools - "create_story" => story_tools::tool_create_story(&args, ctx), - "validate_stories" => story_tools::tool_validate_stories(ctx), - "list_upcoming" => story_tools::tool_list_upcoming(ctx), - "get_story_todos" => story_tools::tool_get_story_todos(&args, ctx), - "record_tests" => story_tools::tool_record_tests(&args, ctx), - "ensure_acceptance" => story_tools::tool_ensure_acceptance(&args, ctx), - // Agent tools (async) - "start_agent" => agent_tools::tool_start_agent(&args, ctx).await, - "stop_agent" => agent_tools::tool_stop_agent(&args, ctx).await, - "list_agents" => agent_tools::tool_list_agents(ctx), - "get_agent_config" => agent_tools::tool_get_agent_config(ctx), - "reload_agent_config" => agent_tools::tool_get_agent_config(ctx), - "get_agent_output" => agent_tools::tool_get_agent_output(&args, ctx).await, - "wait_for_agent" => agent_tools::tool_wait_for_agent(&args, ctx).await, - "get_agent_remaining_turns_and_budget" => { - agent_tools::tool_get_agent_remaining_turns_and_budget(&args, ctx) - } - // Worktree tools - "create_worktree" => agent_tools::tool_create_worktree(&args, ctx).await, - "list_worktrees" => agent_tools::tool_list_worktrees(ctx), - "remove_worktree" => agent_tools::tool_remove_worktree(&args, ctx).await, - // Editor tools - "get_editor_command" => agent_tools::tool_get_editor_command(&args, ctx), - // Lifecycle tools - "accept_story" => story_tools::tool_accept_story(&args, ctx), - // Story mutation tools (auto-commit to master) - "check_criterion" => story_tools::tool_check_criterion(&args, ctx), - "edit_criterion" => story_tools::tool_edit_criterion(&args, ctx), - "add_criterion" => story_tools::tool_add_criterion(&args, ctx), - "remove_criterion" => story_tools::tool_remove_criterion(&args, ctx), - "update_story" => story_tools::tool_update_story(&args, ctx), - // Spike lifecycle tools - "create_spike" => story_tools::tool_create_spike(&args, ctx), - // Bug lifecycle tools - "create_bug" => story_tools::tool_create_bug(&args, ctx), - "list_bugs" => story_tools::tool_list_bugs(ctx), - "close_bug" => story_tools::tool_close_bug(&args, ctx), - // Refactor lifecycle tools - "create_refactor" => story_tools::tool_create_refactor(&args, ctx), - "list_refactors" => story_tools::tool_list_refactors(ctx), - // Mergemaster tools - "merge_agent_work" => merge_tools::tool_merge_agent_work(&args, ctx).await, - "get_merge_status" => merge_tools::tool_get_merge_status(&args, ctx), - "move_story_to_merge" => merge_tools::tool_move_story_to_merge(&args, ctx).await, - "report_merge_failure" => merge_tools::tool_report_merge_failure(&args, ctx), - // QA tools - "request_qa" => qa_tools::tool_request_qa(&args, ctx).await, - "approve_qa" => qa_tools::tool_approve_qa(&args, ctx).await, - "reject_qa" => qa_tools::tool_reject_qa(&args, ctx).await, - "launch_qa_app" => qa_tools::tool_launch_qa_app(&args, ctx).await, - // Pipeline status - "get_pipeline_status" => story_tools::tool_get_pipeline_status(ctx), - // Diagnostics - "get_server_logs" => diagnostics::tool_get_server_logs(&args), - "get_version" => diagnostics::tool_get_version(ctx), - // Server lifecycle - "rebuild_and_restart" => diagnostics::tool_rebuild_and_restart(ctx).await, - // Permission bridge (Claude Code → frontend dialog) - "prompt_permission" => diagnostics::tool_prompt_permission(&args, ctx).await, - // Token usage - "get_token_usage" => diagnostics::tool_get_token_usage(&args, ctx), - // Delete story - "delete_story" => story_tools::tool_delete_story(&args, ctx).await, - // Purge story (CRDT tombstone — story 521) - "purge_story" => story_tools::tool_purge_story(&args, ctx), - // Debug CRDT dump (story 515) - "dump_crdt" => diagnostics::tool_dump_crdt(&args), - // Arbitrary pipeline movement - "move_story" => diagnostics::tool_move_story(&args, ctx), - // Unblock story - "unblock_story" => story_tools::tool_unblock_story(&args, ctx), - // Shell command execution - "run_command" => shell_tools::tool_run_command(&args, ctx).await, - "run_tests" => shell_tools::tool_run_tests(&args, ctx).await, - "get_test_result" => shell_tools::tool_get_test_result(&args, ctx).await, - "run_build" => shell_tools::tool_run_build(&args, ctx).await, - "run_lint" => shell_tools::tool_run_lint(&args, ctx).await, - // Git operations - "git_status" => git_tools::tool_git_status(&args, ctx).await, - "git_diff" => git_tools::tool_git_diff(&args, ctx).await, - "git_add" => git_tools::tool_git_add(&args, ctx).await, - "git_commit" => git_tools::tool_git_commit(&args, ctx).await, - "git_log" => git_tools::tool_git_log(&args, ctx).await, - // Story triage - "status" => status_tools::tool_status(&args, ctx).await, - // File line count - "loc_file" => diagnostics::tool_loc_file(&args, ctx), - // Setup wizard tools - "wizard_status" => wizard_tools::tool_wizard_status(ctx), - "wizard_generate" => wizard_tools::tool_wizard_generate(&args, ctx), - "wizard_confirm" => wizard_tools::tool_wizard_confirm(ctx), - "wizard_skip" => wizard_tools::tool_wizard_skip(ctx), - "wizard_retry" => wizard_tools::tool_wizard_retry(ctx), - _ => Err(format!("Unknown tool: {tool_name}")), - }; - - match result { - Ok(content) => JsonRpcResponse::success( - id, - json!({ - "content": [{ "type": "text", "text": content }] - }), - ), - Err(msg) => { - slog_warn!("[mcp] Tool call failed: tool={tool_name} error={msg}"); - JsonRpcResponse::success( - id, - json!({ - "content": [{ "type": "text", "text": msg }], - "isError": true - }), - ) - } - } -} #[cfg(test)] mod tests { @@ -1435,107 +240,6 @@ mod tests { assert_eq!(result["serverInfo"]["name"], "huskies"); } - #[test] - fn tools_list_returns_all_tools() { - let resp = handle_tools_list(Some(json!(2))); - let result = resp.result.unwrap(); - let tools = result["tools"].as_array().unwrap(); - let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); - assert!(names.contains(&"create_story")); - assert!(names.contains(&"validate_stories")); - assert!(names.contains(&"list_upcoming")); - assert!(names.contains(&"get_story_todos")); - assert!(names.contains(&"record_tests")); - assert!(names.contains(&"ensure_acceptance")); - assert!(names.contains(&"start_agent")); - assert!(names.contains(&"stop_agent")); - assert!(names.contains(&"list_agents")); - assert!(names.contains(&"get_agent_config")); - assert!(names.contains(&"reload_agent_config")); - assert!(names.contains(&"get_agent_output")); - assert!(names.contains(&"wait_for_agent")); - assert!(names.contains(&"get_agent_remaining_turns_and_budget")); - assert!(names.contains(&"create_worktree")); - assert!(names.contains(&"list_worktrees")); - assert!(names.contains(&"remove_worktree")); - assert!(names.contains(&"get_editor_command")); - assert!(!names.contains(&"report_completion")); - assert!(names.contains(&"accept_story")); - assert!(names.contains(&"check_criterion")); - assert!(names.contains(&"add_criterion")); - assert!(names.contains(&"update_story")); - assert!(names.contains(&"create_spike")); - assert!(names.contains(&"create_bug")); - assert!(names.contains(&"list_bugs")); - assert!(names.contains(&"close_bug")); - assert!(names.contains(&"create_refactor")); - assert!(names.contains(&"list_refactors")); - assert!(names.contains(&"merge_agent_work")); - assert!(names.contains(&"get_merge_status")); - assert!(names.contains(&"move_story_to_merge")); - assert!(names.contains(&"report_merge_failure")); - assert!(names.contains(&"request_qa")); - assert!(names.contains(&"approve_qa")); - assert!(names.contains(&"reject_qa")); - assert!(names.contains(&"launch_qa_app")); - assert!(names.contains(&"get_server_logs")); - assert!(names.contains(&"prompt_permission")); - assert!(names.contains(&"get_pipeline_status")); - assert!(names.contains(&"rebuild_and_restart")); - assert!(names.contains(&"get_token_usage")); - assert!(names.contains(&"move_story")); - assert!(names.contains(&"unblock_story")); - assert!(names.contains(&"delete_story")); - assert!(names.contains(&"run_command")); - assert!(names.contains(&"run_tests")); - assert!(names.contains(&"get_test_result")); - assert!(names.contains(&"run_build")); - assert!(names.contains(&"run_lint")); - assert!(names.contains(&"git_status")); - assert!(names.contains(&"git_diff")); - assert!(names.contains(&"git_add")); - assert!(names.contains(&"git_commit")); - assert!(names.contains(&"git_log")); - assert!(names.contains(&"status")); - assert!(names.contains(&"loc_file")); - assert!(names.contains(&"dump_crdt")); - assert!(names.contains(&"get_version")); - assert!(names.contains(&"remove_criterion")); - assert_eq!(tools.len(), 66); - } - - #[test] - fn tools_list_schemas_have_required_fields() { - let resp = handle_tools_list(Some(json!(1))); - let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); - for tool in &tools { - assert!(tool["name"].is_string(), "tool missing name"); - assert!(tool["description"].is_string(), "tool missing description"); - assert!(tool["inputSchema"].is_object(), "tool missing inputSchema"); - assert_eq!(tool["inputSchema"]["type"], "object"); - } - } - - #[test] - fn handle_tools_call_unknown_tool() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let rt = tokio::runtime::Runtime::new().unwrap(); - let resp = rt.block_on(handle_tools_call( - Some(json!(1)), - &json!({"name": "bogus_tool", "arguments": {}}), - &ctx, - )); - let result = resp.result.unwrap(); - assert_eq!(result["isError"], true); - assert!( - result["content"][0]["text"] - .as_str() - .unwrap() - .contains("Unknown tool") - ); - } - #[test] fn to_sse_response_wraps_in_data_prefix() { let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true})); diff --git a/server/src/http/mcp/tools_list.rs b/server/src/http/mcp/tools_list.rs new file mode 100644 index 00000000..cb098532 --- /dev/null +++ b/server/src/http/mcp/tools_list.rs @@ -0,0 +1,1172 @@ +//! `tools/list` MCP method — returns the static schema for every tool the server exposes. + +use serde_json::{Value, json}; + +use super::JsonRpcResponse; + +pub(super) fn handle_tools_list(id: Option) -> JsonRpcResponse { + JsonRpcResponse::success( + id, + json!({ + "tools": [ + { + "name": "create_story", + "description": "Create a new story file with front matter in upcoming/. Returns the story_id.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable story name" + }, + "user_story": { + "type": "string", + "description": "Optional user story text (As a..., I want..., so that...)" + }, + "description": { + "type": "string", + "description": "Optional description / background context for the story" + }, + "acceptance_criteria": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional list of acceptance criteria" + }, + "depends_on": { + "type": "array", + "items": { "type": "integer" }, + "description": "Optional list of story IDs this story depends on; written as a YAML inline sequence in front matter" + }, + "commit": { + "type": "boolean", + "description": "If true, git-add and git-commit the new story file to the current branch" + } + }, + "required": ["name"] + } + }, + { + "name": "validate_stories", + "description": "Validate front matter on all current and upcoming story files.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "list_upcoming", + "description": "List all upcoming stories with their names and any parsing errors.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "get_story_todos", + "description": "Get unchecked acceptance criteria (todos) for a story file in current/.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + } + }, + "required": ["story_id"] + } + }, + { + "name": "record_tests", + "description": "Record test results for a story. Only one failing test at a time is allowed.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier" + }, + "unit": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "status": { "type": "string", "enum": ["pass", "fail"] }, + "details": { "type": "string" } + }, + "required": ["name", "status"] + }, + "description": "Unit test results" + }, + "integration": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "status": { "type": "string", "enum": ["pass", "fail"] }, + "details": { "type": "string" } + }, + "required": ["name", "status"] + }, + "description": "Integration test results" + } + }, + "required": ["story_id", "unit", "integration"] + } + }, + { + "name": "ensure_acceptance", + "description": "Check whether a story can be accepted. Returns acceptance status with reasons if blocked.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier" + } + }, + "required": ["story_id"] + } + }, + { + "name": "start_agent", + "description": "Start an agent for a story. Creates a worktree, runs setup, and spawns the agent process.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (e.g. '28_my_story')" + }, + "agent_name": { + "type": "string", + "description": "Agent name from project.toml config. If omitted, uses the first coder agent (stage = \"coder\"). Supervisor must be requested explicitly by name." + } + }, + "required": ["story_id"] + } + }, + { + "name": "stop_agent", + "description": "Stop a running agent. Worktree is preserved for inspection.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier" + }, + "agent_name": { + "type": "string", + "description": "Agent name to stop" + } + }, + "required": ["story_id", "agent_name"] + } + }, + { + "name": "list_agents", + "description": "List all agents with their current status, story assignment, and worktree path.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "get_agent_config", + "description": "Get the configured agent roster from project.toml (names, roles, models, allowed tools, limits).", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "reload_agent_config", + "description": "Reload project.toml and return the updated agent roster.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "get_agent_output", + "description": "Read agent session logs from disk as a human-readable timeline. Stitches all sessions for the story together in chronological order — text output, tool calls, tool results, errors. Works for both running and completed agents. If agent_name is omitted, returns logs from every agent that worked on the story. If the named agent is currently running, live buffered events are appended.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (e.g. '42_story_my_feature')" + }, + "agent_name": { + "type": "string", + "description": "Optional: filter to a specific agent (e.g. 'mergemaster', 'coder-1'). Omit to see all agents." + }, + "lines": { + "type": "integer", + "description": "Optional: return only the last N lines (tail). Useful for large logs." + }, + "filter": { + "type": "string", + "description": "Optional: return only lines containing this substring (e.g. 'ERROR', 'TOOL:', a function name)." + } + }, + "required": ["story_id"] + } + }, + { + "name": "wait_for_agent", + "description": "Block until the agent reaches a terminal state (completed, failed, stopped). Returns final status and summary including session_id, worktree_path, and any commits made. Use this instead of polling get_agent_output when you want to fire-and-forget and be notified on completion.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier" + }, + "agent_name": { + "type": "string", + "description": "Agent name to wait for" + }, + "timeout_ms": { + "type": "integer", + "description": "Maximum time to wait in milliseconds (default: 300000 = 5 minutes)" + } + }, + "required": ["story_id", "agent_name"] + } + }, + { + "name": "get_agent_remaining_turns_and_budget", + "description": "Get remaining turns and budget for a running agent. Returns turns used, max turns, remaining turns, budget used (from completed sessions), max budget, and remaining budget. Only works for agents in running or pending state.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (e.g. '42_story_my_feature')" + }, + "agent_name": { + "type": "string", + "description": "Agent name (e.g. 'coder-1', 'mergemaster', 'qa')" + } + }, + "required": ["story_id", "agent_name"] + } + }, + { + "name": "create_worktree", + "description": "Create a git worktree for a story under .huskies/worktrees/{story_id} with deterministic naming. Writes .mcp.json and runs component setup. Returns the worktree path.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (e.g. '42_my_story')" + } + }, + "required": ["story_id"] + } + }, + { + "name": "list_worktrees", + "description": "List all worktrees under .huskies/worktrees/ for the current project.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "remove_worktree", + "description": "Remove a git worktree and its feature branch for a story.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier" + } + }, + "required": ["story_id"] + } + }, + { + "name": "get_editor_command", + "description": "Get the open-in-editor command for a worktree. Returns a ready-to-paste shell command like 'zed /path/to/worktree'. Requires the editor preference to be configured via PUT /api/settings/editor.", + "inputSchema": { + "type": "object", + "properties": { + "worktree_path": { + "type": "string", + "description": "Absolute path to the worktree directory" + } + }, + "required": ["worktree_path"] + } + }, + { + "name": "accept_story", + "description": "Accept a story: moves it from current/ to done/ and auto-commits to master.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + } + }, + "required": ["story_id"] + } + }, + { + "name": "check_criterion", + "description": "Check off an acceptance criterion (- [ ] → - [x]) by 0-based index among unchecked items, then auto-commit to master. Use get_story_todos to see the current list of unchecked criteria.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + }, + "criterion_index": { + "type": "integer", + "description": "0-based index of the unchecked criterion to check off" + } + }, + "required": ["story_id", "criterion_index"] + } + }, + { + "name": "edit_criterion", + "description": "Update the text of an existing acceptance criterion in place, preserving its checked/unchecked state. Uses a 0-based index counting all criteria (both checked and unchecked).", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + }, + "criterion_index": { + "type": "integer", + "description": "0-based index of the criterion to edit (counts all criteria)" + }, + "new_text": { + "type": "string", + "description": "New text for the criterion (without the '- [ ] ' or '- [x] ' prefix)" + } + }, + "required": ["story_id", "criterion_index", "new_text"] + } + }, + { + "name": "add_criterion", + "description": "Add an acceptance criterion to an existing story file. Appends '- [ ] {criterion}' after the last existing criterion in the '## Acceptance Criteria' section. Auto-commits via the filesystem watcher.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + }, + "criterion": { + "type": "string", + "description": "The acceptance criterion text to add (without the '- [ ] ' prefix)" + } + }, + "required": ["story_id", "criterion"] + } + }, + { + "name": "remove_criterion", + "description": "Remove an acceptance criterion from a story by its 0-based index (counting all criteria, both checked and unchecked). Returns an error if the index is out of range. Auto-commits via the filesystem watcher.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + }, + "criterion_index": { + "type": "integer", + "description": "0-based index of the criterion to remove (counts all criteria)" + } + }, + "required": ["story_id", "criterion_index"] + } + }, + { + "name": "update_story", + "description": "Update an existing story file. Can replace the '## User Story' and/or '## Description' section content, and/or set YAML front matter fields (e.g. agent, qa). Auto-commits via the filesystem watcher.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + }, + "user_story": { + "type": "string", + "description": "New user story text to replace the '## User Story' section content" + }, + "description": { + "type": "string", + "description": "New description text to replace the '## Description' section content" + }, + "agent": { + "type": "string", + "description": "Set or change the 'agent' YAML front matter field" + }, + "front_matter": { + "type": "object", + "description": "Arbitrary YAML front matter key-value pairs to set or update. Values may be strings, booleans, integers, numbers, or arrays (e.g. [490, 491]).", + "additionalProperties": { + "oneOf": [ + {"type": "string"}, + {"type": "boolean"}, + {"type": "integer"}, + {"type": "number"}, + {"type": "array"} + ] + } + } + }, + "required": ["story_id"] + } + }, + { + "name": "create_spike", + "description": "Create a spike file in .huskies/work/1_backlog/ with a deterministic filename and YAML front matter. Returns the spike_id.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable spike name" + }, + "description": { + "type": "string", + "description": "Optional description / question the spike aims to answer" + } + }, + "required": ["name"] + } + }, + { + "name": "create_bug", + "description": "Create a bug file in work/1_backlog/ with a deterministic filename and auto-commit to master. Returns the bug_id.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Short human-readable bug name" + }, + "description": { + "type": "string", + "description": "Description of the bug" + }, + "steps_to_reproduce": { + "type": "string", + "description": "Steps to reproduce the bug" + }, + "actual_result": { + "type": "string", + "description": "What actually happens" + }, + "expected_result": { + "type": "string", + "description": "What should happen" + }, + "acceptance_criteria": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional list of acceptance criteria for the fix" + }, + "depends_on": { + "type": "array", + "items": { "type": "integer" }, + "description": "Optional list of story numbers this bug depends on (e.g. [42, 43]). Persisted as depends_on in YAML front matter." + } + }, + "required": ["name", "description", "steps_to_reproduce", "actual_result", "expected_result"] + } + }, + { + "name": "list_bugs", + "description": "List all open bugs in work/1_backlog/ matching the _bug_ naming convention.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "create_refactor", + "description": "Create a refactor work item in work/1_backlog/ with a deterministic filename and YAML front matter. Returns the refactor_id.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Short human-readable refactor name" + }, + "description": { + "type": "string", + "description": "Optional description of the desired state after refactoring" + }, + "acceptance_criteria": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional list of acceptance criteria" + }, + "depends_on": { + "type": "array", + "items": { "type": "integer" }, + "description": "Optional list of story numbers this refactor depends on (e.g. [42, 43]). Persisted as depends_on in YAML front matter." + } + }, + "required": ["name"] + } + }, + { + "name": "list_refactors", + "description": "List all open refactors in work/1_backlog/ matching the _refactor_ naming convention.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "close_bug", + "description": "Archive a bug from work/2_current/ or work/1_backlog/ to work/5_done/ and auto-commit to master.", + "inputSchema": { + "type": "object", + "properties": { + "bug_id": { + "type": "string", + "description": "Bug identifier (e.g. 'bug-3-login_crash')" + } + }, + "required": ["bug_id"] + } + }, + { + "name": "merge_agent_work", + "description": "Run the mergemaster pipeline for a completed story. Blocks until the merge completes or fails, then returns the full result — no polling needed. The pipeline squash-merges the feature branch into master, runs quality gates, moves the story to done, and cleans up.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (e.g. '52_story_mergemaster_agent_role')" + }, + "agent_name": { + "type": "string", + "description": "Optional: name of the coder agent whose work is being merged (for logging)" + } + }, + "required": ["story_id"] + } + }, + { + "name": "get_merge_status", + "description": "Check the cached result of a merge_agent_work job. Returns the full merge report immediately — no polling needed. Useful if merge_agent_work already returned but you need the result again.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (same as passed to merge_agent_work)" + } + }, + "required": ["story_id"] + } + }, + { + "name": "move_story_to_merge", + "description": "Move a story or bug from work/2_current/ to work/4_merge/ to queue it for the mergemaster pipeline and automatically spawn the mergemaster agent to squash-merge, run quality gates, and archive.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + }, + "agent_name": { + "type": "string", + "description": "Agent name to use for merging (defaults to 'mergemaster')" + } + }, + "required": ["story_id"] + } + }, + { + "name": "report_merge_failure", + "description": "Report that a merge failed for a story. Leaves the story in work/4_merge/ and logs the failure reason. Use this when merge_agent_work returns success=false instead of manually moving the story file.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (e.g. '52_story_mergemaster_agent_role')" + }, + "reason": { + "type": "string", + "description": "Human-readable explanation of why the merge failed" + } + }, + "required": ["story_id", "reason"] + } + }, + { + "name": "request_qa", + "description": "Trigger QA review of a completed story worktree: moves the item from work/2_current/ to work/3_qa/ and starts the qa agent to run quality gates, tests, and generate a manual testing plan.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (e.g. '53_story_qa_agent_role')" + }, + "agent_name": { + "type": "string", + "description": "Agent name to use for QA (defaults to 'qa')" + } + }, + "required": ["story_id"] + } + }, + { + "name": "approve_qa", + "description": "Approve a story that passed machine QA and is awaiting human review. Moves the story from work/3_qa/ to work/4_merge/ and starts the mergemaster agent.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (e.g. '247_story_human_qa_gate')" + } + }, + "required": ["story_id"] + } + }, + { + "name": "reject_qa", + "description": "Reject a story during human QA review. Moves the story from work/3_qa/ back to work/2_current/ with rejection notes so the coder agent can fix the issues.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (e.g. '247_story_human_qa_gate')" + }, + "notes": { + "type": "string", + "description": "Explanation of what is broken or needs fixing" + } + }, + "required": ["story_id", "notes"] + } + }, + { + "name": "launch_qa_app", + "description": "Launch the app from a story's worktree for manual QA testing. Automatically assigns a free port, writes it to .huskies_port, and starts the backend server. Only one QA app instance runs at a time.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier whose worktree app to launch" + } + }, + "required": ["story_id"] + } + }, + { + "name": "get_pipeline_status", + "description": "Return a structured snapshot of the full work item pipeline. Includes all active stages (current, qa, merge, done) with each item's stage, name, and assigned agent. Also includes upcoming backlog items.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "get_server_logs", + "description": "Return recent server log lines captured in the in-process ring buffer. Useful for diagnosing runtime behaviour such as WebSocket events, MCP call flow, and filesystem watcher activity.", + "inputSchema": { + "type": "object", + "properties": { + "lines": { + "type": "integer", + "description": "Number of recent lines to return (default 100, max 1000)" + }, + "filter": { + "type": "string", + "description": "Optional substring filter (e.g. 'watcher', 'mcp', 'permission')" + }, + "severity": { + "type": "string", + "description": "Filter by severity level: ERROR, WARN, or INFO. Returns only entries at that level." + } + } + } + }, + { + "name": "get_version", + "description": "Return the server version, build hash, and running port.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "rebuild_and_restart", + "description": "Rebuild the server binary from source and re-exec with the new binary. Gracefully stops all running agents before restart. If the build fails, the server stays up and returns the build error.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "prompt_permission", + "description": "Present a permission request to the user via the web UI. Used by Claude Code's --permission-prompt-tool to delegate permission decisions to the frontend dialog. Returns on approval; returns an error on denial.", + "inputSchema": { + "type": "object", + "properties": { + "tool_name": { + "type": "string", + "description": "The tool requesting permission (e.g. 'Bash', 'Write')" + }, + "input": { + "type": "object", + "description": "The tool's input arguments" + } + }, + "required": ["tool_name", "input"] + } + }, + { + "name": "get_token_usage", + "description": "Return per-agent token usage records from the persistent log. Shows input tokens, output tokens, cache tokens, and cost in USD for each agent session. Optionally filter by story_id.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Optional: filter records to a specific story (e.g. '42_my_story')" + } + } + } + }, + { + "name": "delete_story", + "description": "Delete a work item from the pipeline entirely. Stops any running agent, removes the worktree, and deletes the story file. Use only for removing obsolete or duplicate items.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Work item identifier (filename stem, e.g. '28_story_my_feature')" + } + }, + "required": ["story_id"] + } + }, + { + "name": "purge_story", + "description": "Write a CRDT tombstone op for a story (story 521). Marks the in-memory CRDT item as deleted, persists the tombstone to crdt_ops so it survives restart, and drops the in-memory content store entry. Does NOT touch running agents, worktrees, the pipeline_items shadow table, timers.json, or filesystem shadows — compose with stop_agent / remove_worktree / etc. for a full cleanup. Use this when a story has gone zombie in the running server's in-memory state and direct sqlite deletes alone are not enough to clear it.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Work item identifier (filename stem, e.g. '28_story_my_feature')" + } + }, + "required": ["story_id"] + } + }, + { + "name": "dump_crdt", + "description": "DEBUG TOOL: Dump the raw in-memory CRDT state. Returns every item the running server knows about, including tombstoned (deleted) entries, with internal op metadata (content_index, is_deleted, stage, etc.). Use this when diagnosing CRDT/state divergence — NOT for normal pipeline introspection (use get_pipeline_status for that). Optional story_id filter returns a single item.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Optional: restrict output to this single work item identifier (filename stem, e.g. '42_story_my_feature')" + } + }, + "required": [] + } + }, + { + "name": "move_story", + "description": "Move a work item (story, bug, spike, or refactor) to an arbitrary pipeline stage. Prefer dedicated tools when available: use accept_story to mark items done, move_story_to_merge to queue for merging, or request_qa to trigger QA review. Use move_story only for arbitrary moves that lack a dedicated tool — for example, moving a story back to backlog or recovering a ghost story by moving it back to current.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Work item identifier (filename stem, e.g. '28_story_my_feature')" + }, + "target_stage": { + "type": "string", + "enum": ["backlog", "current", "qa", "merge", "done"], + "description": "Target pipeline stage: backlog (1_backlog), current (2_current), qa (3_qa), merge (4_merge), done (5_done)" + } + }, + "required": ["story_id", "target_stage"] + } + }, + { + "name": "unblock_story", + "description": "Clear the blocked flag and reset retry_count to 0 on a work item. Use this when an agent is stuck and needs to be restarted from a clean state.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Work item identifier (filename stem, e.g. '42_story_my_feature')" + } + }, + "required": ["story_id"] + } + }, + { + "name": "run_command", + "description": "Execute a shell command in an agent's worktree directory. The working_dir must be inside .huskies/worktrees/. Returns stdout, stderr, exit_code, and timed_out. Supports SSE streaming (send Accept: text/event-stream) for long-running commands. Dangerous commands (rm -rf /, sudo, etc.) are blocked.", + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute (passed to bash -c)" + }, + "working_dir": { + "type": "string", + "description": "Absolute path to the worktree directory to run the command in. Must be inside .huskies/worktrees/." + }, + "timeout": { + "type": "integer", + "description": "Timeout in seconds (default: 120, max: 600)" + } + }, + "required": ["command", "working_dir"] + } + }, + { + "name": "run_tests", + "description": "Start the project's test suite (script/test) as a background job. Returns immediately with {\"status\": \"started\"}. Poll get_test_result with the same worktree_path to check for completion. If the previous run already finished, returns the result inline.", + "inputSchema": { + "type": "object", + "properties": { + "worktree_path": { + "type": "string", + "description": "Optional absolute path to a worktree to run tests in. Must be inside .huskies/worktrees/. Defaults to the project root." + } + }, + "required": [] + } + }, + { + "name": "get_test_result", + "description": "Check on a running test job started by run_tests. Returns {\"status\": \"running\", \"elapsed_secs\": N} if still in progress, or the full test result (passed, exit_code, test counts, output) if finished.", + "inputSchema": { + "type": "object", + "properties": { + "worktree_path": { + "type": "string", + "description": "Optional absolute path to the worktree. Must match the worktree_path used in run_tests." + } + }, + "required": [] + } + }, + { + "name": "run_build", + "description": "Run the project's build script (script/build) in the given worktree and return the result as truncated JSON with passed, exit_code, and output fields.", + "inputSchema": { + "type": "object", + "properties": { + "worktree_path": { + "type": "string", + "description": "Optional absolute path to a worktree to run the build in. Must be inside .huskies/worktrees/. Defaults to the project root." + } + }, + "required": [] + } + }, + { + "name": "run_lint", + "description": "Run the project's lint script (script/lint) in the given worktree and return the result as truncated JSON with passed, exit_code, and output fields.", + "inputSchema": { + "type": "object", + "properties": { + "worktree_path": { + "type": "string", + "description": "Optional absolute path to a worktree to run lint in. Must be inside .huskies/worktrees/. Defaults to the project root." + } + }, + "required": [] + } + }, + { + "name": "git_status", + "description": "Return the working tree status of an agent's worktree (staged, unstaged, and untracked files). The worktree_path must be inside .huskies/worktrees/. Push and remote operations are not available.", + "inputSchema": { + "type": "object", + "properties": { + "worktree_path": { + "type": "string", + "description": "Absolute path to the worktree directory. Must be inside .huskies/worktrees/." + } + }, + "required": ["worktree_path"] + } + }, + { + "name": "git_diff", + "description": "Return diff output for an agent's worktree. Supports unstaged (default), staged, or a commit range. The worktree_path must be inside .huskies/worktrees/.", + "inputSchema": { + "type": "object", + "properties": { + "worktree_path": { + "type": "string", + "description": "Absolute path to the worktree directory. Must be inside .huskies/worktrees/." + }, + "staged": { + "type": "boolean", + "description": "If true, show staged diff (--staged). Default: false." + }, + "commit_range": { + "type": "string", + "description": "Optional commit range (e.g. 'HEAD~3..HEAD', 'abc123..def456')." + } + }, + "required": ["worktree_path"] + } + }, + { + "name": "git_add", + "description": "Stage files by path in an agent's worktree. The worktree_path must be inside .huskies/worktrees/.", + "inputSchema": { + "type": "object", + "properties": { + "worktree_path": { + "type": "string", + "description": "Absolute path to the worktree directory. Must be inside .huskies/worktrees/." + }, + "paths": { + "type": "array", + "items": { "type": "string" }, + "description": "List of file paths to stage (relative to worktree_path)." + } + }, + "required": ["worktree_path", "paths"] + } + }, + { + "name": "git_commit", + "description": "Commit staged changes in an agent's worktree with the given message. The worktree_path must be inside .huskies/worktrees/. Push and remote operations are not available.", + "inputSchema": { + "type": "object", + "properties": { + "worktree_path": { + "type": "string", + "description": "Absolute path to the worktree directory. Must be inside .huskies/worktrees/." + }, + "message": { + "type": "string", + "description": "Commit message." + } + }, + "required": ["worktree_path", "message"] + } + }, + { + "name": "git_log", + "description": "Return commit history for an agent's worktree with configurable count and format. The worktree_path must be inside .huskies/worktrees/.", + "inputSchema": { + "type": "object", + "properties": { + "worktree_path": { + "type": "string", + "description": "Absolute path to the worktree directory. Must be inside .huskies/worktrees/." + }, + "count": { + "type": "integer", + "description": "Number of commits to return (default: 10, max: 500)." + }, + "format": { + "type": "string", + "description": "git pretty-format string (default: '%H%x09%s%x09%an%x09%ai')." + } + }, + "required": ["worktree_path"] + } + }, + { + "name": "status", + "description": "Get a full triage dump for an in-progress story: front matter, AC checklist, active worktree/branch, git diff --stat since master, last 5 commits, and last 20 lines of the most recent agent log. Returns a clear error if the story is not in work/2_current/.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '42_story_my_feature')" + } + }, + "required": ["story_id"] + } + }, + { + "name": "loc_file", + "description": "Return the line count for a specific file. Path is resolved relative to the project root. Returns an error if the file does not exist.", + "inputSchema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the file, relative to the project root (e.g. 'server/src/main.rs')" + } + }, + "required": ["file_path"] + } + }, + { + "name": "wizard_status", + "description": "Return the current setup wizard state: which step is active, and which are done/skipped/pending. Use this to inspect progress before calling wizard_generate, wizard_confirm, wizard_skip, or wizard_retry.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "wizard_generate", + "description": "Drive content generation for the current wizard step. Call with no arguments to mark the step as 'generating' and receive a hint about what to produce. Call again with a 'content' argument (the full file body you generated) to stage it for review. Content is NOT written to disk until wizard_confirm is called.", + "inputSchema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The generated file content to stage for the current step. Omit to receive a generation hint and mark the step as generating." + } + } + } + }, + { + "name": "wizard_confirm", + "description": "Confirm the current wizard step: writes any staged content to disk (only if the target file does not already exist) and advances to the next step. Existing files are never overwritten.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "wizard_skip", + "description": "Skip the current wizard step without writing any file. Use when a step does not apply to this project.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "wizard_retry", + "description": "Discard any staged content for the current wizard step and reset it to pending so it can be regenerated. Use when the generated content needs improvement.", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ] + }), + ) +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tools_list_returns_all_tools() { + let resp = handle_tools_list(Some(json!(2))); + let result = resp.result.unwrap(); + let tools = result["tools"].as_array().unwrap(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + assert!(names.contains(&"create_story")); + assert!(names.contains(&"validate_stories")); + assert!(names.contains(&"list_upcoming")); + assert!(names.contains(&"get_story_todos")); + assert!(names.contains(&"record_tests")); + assert!(names.contains(&"ensure_acceptance")); + assert!(names.contains(&"start_agent")); + assert!(names.contains(&"stop_agent")); + assert!(names.contains(&"list_agents")); + assert!(names.contains(&"get_agent_config")); + assert!(names.contains(&"reload_agent_config")); + assert!(names.contains(&"get_agent_output")); + assert!(names.contains(&"wait_for_agent")); + assert!(names.contains(&"get_agent_remaining_turns_and_budget")); + assert!(names.contains(&"create_worktree")); + assert!(names.contains(&"list_worktrees")); + assert!(names.contains(&"remove_worktree")); + assert!(names.contains(&"get_editor_command")); + assert!(!names.contains(&"report_completion")); + assert!(names.contains(&"accept_story")); + assert!(names.contains(&"check_criterion")); + assert!(names.contains(&"add_criterion")); + assert!(names.contains(&"update_story")); + assert!(names.contains(&"create_spike")); + assert!(names.contains(&"create_bug")); + assert!(names.contains(&"list_bugs")); + assert!(names.contains(&"close_bug")); + assert!(names.contains(&"create_refactor")); + assert!(names.contains(&"list_refactors")); + assert!(names.contains(&"merge_agent_work")); + assert!(names.contains(&"get_merge_status")); + assert!(names.contains(&"move_story_to_merge")); + assert!(names.contains(&"report_merge_failure")); + assert!(names.contains(&"request_qa")); + assert!(names.contains(&"approve_qa")); + assert!(names.contains(&"reject_qa")); + assert!(names.contains(&"launch_qa_app")); + assert!(names.contains(&"get_server_logs")); + assert!(names.contains(&"prompt_permission")); + assert!(names.contains(&"get_pipeline_status")); + assert!(names.contains(&"rebuild_and_restart")); + assert!(names.contains(&"get_token_usage")); + assert!(names.contains(&"move_story")); + assert!(names.contains(&"unblock_story")); + assert!(names.contains(&"delete_story")); + assert!(names.contains(&"run_command")); + assert!(names.contains(&"run_tests")); + assert!(names.contains(&"get_test_result")); + assert!(names.contains(&"run_build")); + assert!(names.contains(&"run_lint")); + assert!(names.contains(&"git_status")); + assert!(names.contains(&"git_diff")); + assert!(names.contains(&"git_add")); + assert!(names.contains(&"git_commit")); + assert!(names.contains(&"git_log")); + assert!(names.contains(&"status")); + assert!(names.contains(&"loc_file")); + assert!(names.contains(&"dump_crdt")); + assert!(names.contains(&"get_version")); + assert!(names.contains(&"remove_criterion")); + assert_eq!(tools.len(), 66); + } + + #[test] + fn tools_list_schemas_have_required_fields() { + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + for tool in &tools { + assert!(tool["name"].is_string(), "tool missing name"); + assert!(tool["description"].is_string(), "tool missing description"); + assert!(tool["inputSchema"].is_object(), "tool missing inputSchema"); + assert_eq!(tool["inputSchema"]["type"], "object"); + } + } +}