2026-02-20 17:44:06 +00:00
use crate ::agents ::{ close_bug_to_archive , move_story_to_archived , move_story_to_merge , move_story_to_qa } ;
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
use crate ::config ::ProjectConfig ;
feat(story-93): expose server logs to agents via get_server_logs MCP tool
- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API
- Add slog! macro: drop-in for eprintln! that also captures to ring buffer
- Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog!
- Add get_server_logs MCP tool: accepts count (1-500) and optional filter params
- 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer
- 262 tests passing, clippy clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:38:19 +00:00
use crate ::log_buffer ;
2026-02-24 13:48:25 +00:00
use crate ::slog_warn ;
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
use crate ::http ::context ::AppContext ;
2026-02-20 14:42:41 +00:00
use crate ::http ::settings ::get_editor_command_from_store ;
2026-02-20 15:31:13 +00:00
use crate ::http ::workflow ::{
2026-02-23 20:27:09 +00:00
check_criterion_in_file , create_bug_file , create_spike_file , create_story_file , list_bug_files ,
2026-02-23 19:12:05 +00:00
load_upcoming_stories , validate_story_dirs ,
2026-02-20 15:31:13 +00:00
} ;
2026-02-20 14:09:52 +00:00
use crate ::worktree ;
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
use crate ::io ::story_metadata ::{ parse_front_matter , parse_unchecked_todos } ;
use crate ::workflow ::{ evaluate_acceptance_with_coverage , TestCaseResult , TestStatus } ;
use poem ::handler ;
use poem ::http ::StatusCode ;
use poem ::web ::Data ;
use poem ::{ Body , Request , Response } ;
use serde ::{ Deserialize , Serialize } ;
use serde_json ::{ json , Value } ;
use std ::fs ;
use std ::sync ::Arc ;
/// Returns true when the Accept header includes text/event-stream.
fn wants_sse ( req : & Request ) -> bool {
req . header ( " accept " )
. unwrap_or ( " " )
. contains ( " text/event-stream " )
}
// ── JSON-RPC structs ──────────────────────────────────────────────
#[ derive(Deserialize) ]
struct JsonRpcRequest {
jsonrpc : String ,
id : Option < Value > ,
method : String ,
#[ serde(default) ]
params : Value ,
}
#[ derive(Serialize) ]
struct JsonRpcResponse {
jsonrpc : & 'static str ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
id : Option < Value > ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
result : Option < Value > ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
error : Option < JsonRpcError > ,
}
#[ derive(Serialize) ]
struct JsonRpcError {
code : i64 ,
message : String ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
data : Option < Value > ,
}
impl JsonRpcResponse {
fn success ( id : Option < Value > , result : Value ) -> Self {
Self {
jsonrpc : " 2.0 " ,
id ,
result : Some ( result ) ,
error : None ,
}
}
fn error ( id : Option < Value > , code : i64 , message : String ) -> Self {
Self {
jsonrpc : " 2.0 " ,
id ,
result : None ,
error : Some ( JsonRpcError {
code ,
message ,
data : None ,
} ) ,
}
}
}
// ── Poem handlers ─────────────────────────────────────────────────
#[ handler ]
pub async fn mcp_get_handler ( ) -> Response {
Response ::builder ( )
. status ( StatusCode ::METHOD_NOT_ALLOWED )
. body ( Body ::empty ( ) )
}
#[ handler ]
pub async fn mcp_post_handler ( req : & Request , body : Body , ctx : Data < & Arc < AppContext > > ) -> Response {
// Validate Content-Type
let content_type = req . header ( " content-type " ) . unwrap_or ( " " ) ;
if ! content_type . is_empty ( ) & & ! content_type . contains ( " application/json " ) {
return json_rpc_error_response (
None ,
- 32700 ,
" Unsupported Content-Type; expected application/json " . into ( ) ,
) ;
}
let bytes = match body . into_bytes ( ) . await {
Ok ( b ) = > b ,
Err ( _ ) = > return json_rpc_error_response ( None , - 32700 , " Parse error " . into ( ) ) ,
} ;
let rpc : JsonRpcRequest = match serde_json ::from_slice ( & bytes ) {
Ok ( r ) = > r ,
Err ( _ ) = > return json_rpc_error_response ( None , - 32700 , " Parse error " . into ( ) ) ,
} ;
if rpc . jsonrpc ! = " 2.0 " {
return json_rpc_error_response ( rpc . id , - 32600 , " Invalid JSON-RPC version " . into ( ) ) ;
}
// Notifications (no id) — accept silently
if rpc . id . is_none ( ) | | rpc . id . as_ref ( ) = = Some ( & Value ::Null ) {
if rpc . method . starts_with ( " notifications/ " ) {
return Response ::builder ( )
. status ( StatusCode ::ACCEPTED )
. body ( Body ::empty ( ) ) ;
}
return json_rpc_error_response ( None , - 32600 , " Missing id " . into ( ) ) ;
}
let sse = wants_sse ( req ) ;
// Streaming agent output over SSE
if sse & & rpc . method = = " tools/call " {
let tool_name = rpc
. params
. get ( " name " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " " ) ;
if tool_name = = " get_agent_output " {
return handle_agent_output_sse ( rpc . id , & rpc . params , & ctx ) ;
}
}
let resp = match rpc . method . as_str ( ) {
" initialize " = > handle_initialize ( rpc . id , & rpc . params ) ,
" tools/list " = > handle_tools_list ( rpc . id ) ,
" tools/call " = > handle_tools_call ( rpc . id , & rpc . params , & ctx ) . await ,
_ = > JsonRpcResponse ::error ( rpc . id , - 32601 , format! ( " Unknown method: {} " , rpc . method ) ) ,
} ;
if sse {
to_sse_response ( resp )
} else {
to_json_response ( resp )
}
}
fn json_rpc_error_response ( id : Option < Value > , code : i64 , message : String ) -> Response {
to_json_response ( JsonRpcResponse ::error ( id , code , message ) )
}
fn to_json_response ( resp : JsonRpcResponse ) -> Response {
let body = serde_json ::to_vec ( & resp ) . unwrap_or_default ( ) ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( body ) )
}
fn to_sse_response ( resp : JsonRpcResponse ) -> Response {
let json = serde_json ::to_string ( & resp ) . unwrap_or_default ( ) ;
let sse_body = format! ( " data: {json} \n \n " ) ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " text/event-stream " )
. header ( " Cache-Control " , " no-cache " )
. body ( Body ::from_string ( sse_body ) )
}
/// Stream agent events as SSE — each event is a separate JSON-RPC notification,
/// followed by a final JSON-RPC response with the matching request id.
fn handle_agent_output_sse (
id : Option < Value > ,
params : & Value ,
ctx : & AppContext ,
) -> Response {
let args = params . get ( " arguments " ) . cloned ( ) . unwrap_or ( json! ( { } ) ) ;
let story_id = match args . get ( " story_id " ) . and_then ( | v | v . as_str ( ) ) {
Some ( s ) = > s . to_string ( ) ,
None = > return to_sse_response ( JsonRpcResponse ::error (
id ,
- 32602 ,
" Missing required argument: story_id " . into ( ) ,
) ) ,
} ;
let agent_name = match args . get ( " agent_name " ) . and_then ( | v | v . as_str ( ) ) {
Some ( s ) = > s . to_string ( ) ,
None = > return to_sse_response ( JsonRpcResponse ::error (
id ,
- 32602 ,
" Missing required argument: agent_name " . into ( ) ,
) ) ,
} ;
let timeout_ms = args
. get ( " timeout_ms " )
. and_then ( | v | v . as_u64 ( ) )
. unwrap_or ( 10000 )
. min ( 30000 ) ;
let mut rx = match ctx . agents . subscribe ( & story_id , & agent_name ) {
Ok ( rx ) = > rx ,
Err ( e ) = > return to_sse_response ( JsonRpcResponse ::success (
id ,
json! ( { " content " : [ { " type " : " text " , " text " : e } ] , " isError " : true } ) ,
) ) ,
} ;
let final_id = id ;
let stream = async_stream ::stream! {
let deadline = tokio ::time ::Instant ::now ( )
+ std ::time ::Duration ::from_millis ( timeout_ms ) ;
let mut done = false ;
loop {
let remaining = deadline . saturating_duration_since ( tokio ::time ::Instant ::now ( ) ) ;
if remaining . is_zero ( ) {
break ;
}
match tokio ::time ::timeout ( remaining , rx . recv ( ) ) . await {
Ok ( Ok ( event ) ) = > {
let is_terminal = matches! (
& event ,
crate ::agents ::AgentEvent ::Done { .. }
| crate ::agents ::AgentEvent ::Error { .. }
) ;
// Send each event as a JSON-RPC notification (no id)
if let Ok ( event_json ) = serde_json ::to_value ( & event ) {
let notification = json! ( {
" jsonrpc " : " 2.0 " ,
" method " : " notifications/tools/progress " ,
" params " : { " event " : event_json }
} ) ;
if let Ok ( s ) = serde_json ::to_string ( & notification ) {
yield Ok ::< _ , std ::io ::Error > ( format! ( " data: {s} \n \n " ) ) ;
}
}
if is_terminal {
done = true ;
break ;
}
}
Ok ( Err ( tokio ::sync ::broadcast ::error ::RecvError ::Lagged ( n ) ) ) = > {
let notification = json! ( {
" jsonrpc " : " 2.0 " ,
" method " : " notifications/tools/progress " ,
" params " : { " event " : { " type " : " warning " , " message " : format ! ( " Skipped {n} events " ) } }
} ) ;
if let Ok ( s ) = serde_json ::to_string ( & notification ) {
yield Ok ::< _ , std ::io ::Error > ( format! ( " data: {s} \n \n " ) ) ;
}
}
Ok ( Err ( tokio ::sync ::broadcast ::error ::RecvError ::Closed ) ) = > {
done = true ;
break ;
}
Err ( _ ) = > break , // timeout
}
}
// Final response with the request id
let final_resp = JsonRpcResponse ::success (
final_id ,
json! ( {
" content " : [ {
" type " : " text " ,
" text " : if done { " Agent stream ended. " } else { " Stream timed out; call again to continue. " }
} ]
} ) ,
) ;
if let Ok ( s ) = serde_json ::to_string ( & final_resp ) {
yield Ok ::< _ , std ::io ::Error > ( format! ( " data: {s} \n \n " ) ) ;
}
} ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " text/event-stream " )
. header ( " Cache-Control " , " no-cache " )
. body ( Body ::from_bytes_stream (
futures ::StreamExt ::map ( stream , | r | r . map ( bytes ::Bytes ::from ) ) ,
) )
}
// ── MCP protocol handlers ─────────────────────────────────────────
fn handle_initialize ( id : Option < Value > , params : & Value ) -> JsonRpcResponse {
let _protocol_version = params
. get ( " protocolVersion " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " 2025-03-26 " ) ;
JsonRpcResponse ::success (
id ,
json! ( {
" protocolVersion " : " 2025-03-26 " ,
" capabilities " : {
" tools " : { }
} ,
" serverInfo " : {
" name " : " story-kit " ,
" version " : " 1.0.0 "
}
} ) ,
)
}
fn handle_tools_list ( id : Option < Value > ) -> 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...) "
} ,
" acceptance_criteria " : {
" type " : " array " ,
" items " : { " type " : " string " } ,
" description " : " Optional list of acceptance criteria "
2026-02-20 14:09:52 +00:00
} ,
" commit " : {
" type " : " boolean " ,
" description " : " If true, git-add and git-commit the new story file to the current branch "
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
}
} ,
" 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 " ,
2026-02-25 09:30:15 +00:00
" description " : " Agent name from project.toml config. If omitted, uses the first coder agent (stage = \" coder \" ). Supervisor must be requested explicitly by name. "
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
}
} ,
" required " : [ " story_id " ]
}
} ,
{
" name " : " stop_agent " ,
2026-02-20 11:57:25 +00:00
" description " : " Stop a running agent. Worktree is preserved for inspection. " ,
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
" 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 " : " Poll recent output from a running agent. Subscribes to the agent's event stream and collects events for up to 2 seconds. Returns text output and status events. Call repeatedly to follow progress. " ,
" inputSchema " : {
" type " : " object " ,
" properties " : {
" story_id " : {
" type " : " string " ,
" description " : " Story identifier "
} ,
" agent_name " : {
" type " : " string " ,
" description " : " Agent name "
} ,
" timeout_ms " : {
" type " : " integer " ,
" description " : " How long to wait for events in milliseconds (default: 2000, max: 10000) "
}
} ,
" required " : [ " story_id " , " agent_name " ]
}
2026-02-20 13:16:04 +00:00
} ,
{
" 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 " ]
}
2026-02-20 14:09:52 +00:00
} ,
{
" name " : " create_worktree " ,
" description " : " Create a git worktree for a story under .story_kit/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 .story_kit/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 " ]
}
2026-02-20 14:42:41 +00:00
} ,
{
" 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 " ]
}
2026-02-20 15:02:34 +00:00
} ,
2026-02-20 15:09:39 +00:00
{
" name " : " accept_story " ,
2026-02-24 17:01:57 +00:00
" description " : " Accept a story: moves it from current/ to done/ and auto-commits to master. " ,
2026-02-20 15:09:39 +00:00
" inputSchema " : {
" type " : " object " ,
" properties " : {
" story_id " : {
" type " : " string " ,
" description " : " Story identifier (filename stem, e.g. '28_my_story') "
}
} ,
" required " : [ " story_id " ]
}
2026-02-20 15:31:13 +00:00
} ,
{
" 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 " ]
}
} ,
2026-02-23 20:27:09 +00:00
{
" name " : " create_spike " ,
" description " : " Create a spike file in .story_kit/work/1_upcoming/ 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 " ]
}
} ,
2026-02-20 16:34:32 +00:00
{
" name " : " create_bug " ,
" description " : " Create a bug file in .story_kit/bugs/ 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 "
}
} ,
" required " : [ " name " , " description " , " steps_to_reproduce " , " actual_result " , " expected_result " ]
}
} ,
{
" name " : " list_bugs " ,
" description " : " List all open bugs (files in .story_kit/bugs/ excluding archive/). " ,
" inputSchema " : {
" type " : " object " ,
" properties " : { }
}
} ,
{
" name " : " close_bug " ,
" description " : " Move a bug from .story_kit/bugs/ (or current/) to .story_kit/bugs/archive/ 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 " ]
}
2026-02-20 17:36:35 +00:00
} ,
{
" name " : " merge_agent_work " ,
2026-02-24 17:01:57 +00:00
" description " : " Trigger the mergemaster pipeline for a completed story: squash-merge the feature branch into master, run quality gates (cargo clippy, cargo test, pnpm build, pnpm test), move the story from work/4_merge/ or work/2_current/ to work/5_done/, and clean up the worktree and branch. Reports success/failure with details including any conflicts found and gate output. " ,
2026-02-20 17:36:35 +00:00
" 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 " : " move_story_to_merge " ,
2026-02-23 12:12:20 +00:00
" 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. " ,
2026-02-20 17:36:35 +00:00
" inputSchema " : {
" type " : " object " ,
" properties " : {
" story_id " : {
" type " : " string " ,
" description " : " Story identifier (filename stem, e.g. '28_my_story') "
2026-02-23 12:12:20 +00:00
} ,
" agent_name " : {
" type " : " string " ,
" description " : " Agent name to use for merging (defaults to 'mergemaster') "
2026-02-20 17:36:35 +00:00
}
} ,
" required " : [ " story_id " ]
}
2026-02-20 17:44:06 +00:00
} ,
{
" 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 " ]
}
feat(story-93): expose server logs to agents via get_server_logs MCP tool
- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API
- Add slog! macro: drop-in for eprintln! that also captures to ring buffer
- Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog!
- Add get_server_logs MCP tool: accepts count (1-500) and optional filter params
- 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer
- 262 tests passing, clippy clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:38:19 +00:00
} ,
{
" 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') "
2026-02-24 13:48:25 +00:00
} ,
" severity " : {
" type " : " string " ,
" description " : " Filter by severity level: ERROR, WARN, or INFO. Returns only entries at that level. "
feat(story-93): expose server logs to agents via get_server_logs MCP tool
- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API
- Add slog! macro: drop-in for eprintln! that also captures to ring buffer
- Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog!
- Add get_server_logs MCP tool: accepts count (1-500) and optional filter params
- 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer
- 262 tests passing, clippy clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:38:19 +00:00
}
}
}
2026-02-23 21:38:45 +00:00
} ,
{
" 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 " ]
}
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
}
]
} ) ,
)
}
// ── Tool dispatch ─────────────────────────────────────────────────
async fn handle_tools_call (
id : Option < Value > ,
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 " = > tool_create_story ( & args , ctx ) ,
" validate_stories " = > tool_validate_stories ( ctx ) ,
" list_upcoming " = > tool_list_upcoming ( ctx ) ,
" get_story_todos " = > tool_get_story_todos ( & args , ctx ) ,
" record_tests " = > tool_record_tests ( & args , ctx ) ,
" ensure_acceptance " = > tool_ensure_acceptance ( & args , ctx ) ,
// Agent tools (async)
" start_agent " = > tool_start_agent ( & args , ctx ) . await ,
" stop_agent " = > tool_stop_agent ( & args , ctx ) . await ,
" list_agents " = > tool_list_agents ( ctx ) ,
" get_agent_config " = > tool_get_agent_config ( ctx ) ,
" reload_agent_config " = > tool_get_agent_config ( ctx ) ,
2026-02-20 11:57:25 +00:00
" get_agent_output " = > tool_get_agent_output_poll ( & args , ctx ) . await ,
2026-02-20 13:16:04 +00:00
" wait_for_agent " = > tool_wait_for_agent ( & args , ctx ) . await ,
2026-02-20 14:09:52 +00:00
// Worktree tools
" create_worktree " = > tool_create_worktree ( & args , ctx ) . await ,
" list_worktrees " = > tool_list_worktrees ( ctx ) ,
" remove_worktree " = > tool_remove_worktree ( & args , ctx ) . await ,
2026-02-20 14:42:41 +00:00
// Editor tools
" get_editor_command " = > tool_get_editor_command ( & args , ctx ) ,
2026-02-20 15:09:39 +00:00
// Lifecycle tools
" accept_story " = > tool_accept_story ( & args , ctx ) ,
2026-02-20 15:31:13 +00:00
// Story mutation tools (auto-commit to master)
" check_criterion " = > tool_check_criterion ( & args , ctx ) ,
2026-02-23 20:27:09 +00:00
// Spike lifecycle tools
" create_spike " = > tool_create_spike ( & args , ctx ) ,
2026-02-20 16:34:32 +00:00
// Bug lifecycle tools
" create_bug " = > tool_create_bug ( & args , ctx ) ,
" list_bugs " = > tool_list_bugs ( ctx ) ,
" close_bug " = > tool_close_bug ( & args , ctx ) ,
2026-02-20 17:36:35 +00:00
// Mergemaster tools
" merge_agent_work " = > tool_merge_agent_work ( & args , ctx ) . await ,
2026-02-23 12:12:20 +00:00
" move_story_to_merge " = > tool_move_story_to_merge ( & args , ctx ) . await ,
2026-02-20 17:44:06 +00:00
// QA tools
" request_qa " = > tool_request_qa ( & args , ctx ) . await ,
feat(story-93): expose server logs to agents via get_server_logs MCP tool
- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API
- Add slog! macro: drop-in for eprintln! that also captures to ring buffer
- Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog!
- Add get_server_logs MCP tool: accepts count (1-500) and optional filter params
- 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer
- 262 tests passing, clippy clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:38:19 +00:00
// Diagnostics
" get_server_logs " = > tool_get_server_logs ( & args ) ,
2026-02-23 21:38:45 +00:00
// Permission bridge (Claude Code → frontend dialog)
" prompt_permission " = > tool_prompt_permission ( & args , ctx ) . await ,
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
_ = > Err ( format! ( " Unknown tool: {tool_name} " ) ) ,
} ;
match result {
Ok ( content ) = > JsonRpcResponse ::success (
id ,
json! ( {
" content " : [ { " type " : " text " , " text " : content } ]
} ) ,
) ,
2026-02-24 13:48:25 +00:00
Err ( msg ) = > {
slog_warn! ( " [mcp] Tool call failed: tool={tool_name} error={msg} " ) ;
JsonRpcResponse ::success (
id ,
json! ( {
" content " : [ { " type " : " text " , " text " : msg } ] ,
" isError " : true
} ) ,
)
}
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
}
}
// ── Tool implementations ──────────────────────────────────────────
fn tool_create_story ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let name = args
. get ( " name " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: name " ) ? ;
let user_story = args . get ( " user_story " ) . and_then ( | v | v . as_str ( ) ) ;
let acceptance_criteria : Option < Vec < String > > = args
. get ( " acceptance_criteria " )
. and_then ( | v | serde_json ::from_value ( v . clone ( ) ) . ok ( ) ) ;
2026-02-20 19:39:19 +00:00
// Spike 61: write the file only — the filesystem watcher detects the new
// .md file in work/1_upcoming/ and auto-commits with a deterministic message.
let commit = false ;
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
let root = ctx . state . get_project_root ( ) ? ;
let story_id = create_story_file (
& root ,
name ,
user_story ,
acceptance_criteria . as_deref ( ) ,
2026-02-20 14:09:52 +00:00
commit ,
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
) ? ;
Ok ( format! ( " Created story: {story_id} " ) )
}
fn tool_validate_stories ( ctx : & AppContext ) -> Result < String , String > {
let root = ctx . state . get_project_root ( ) ? ;
let results = validate_story_dirs ( & root ) ? ;
serde_json ::to_string_pretty ( & json! ( results
. iter ( )
. map ( | r | json! ( {
" story_id " : r . story_id ,
" valid " : r . valid ,
" error " : r . error ,
} ) )
. collect ::< Vec < _ > > ( ) ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
fn tool_list_upcoming ( ctx : & AppContext ) -> Result < String , String > {
let stories = load_upcoming_stories ( ctx ) ? ;
serde_json ::to_string_pretty ( & json! ( stories
. iter ( )
. map ( | s | json! ( {
" story_id " : s . story_id ,
" name " : s . name ,
" error " : s . error ,
} ) )
. collect ::< Vec < _ > > ( ) ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
fn tool_get_story_todos ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let root = ctx . state . get_project_root ( ) ? ;
2026-02-20 17:16:48 +00:00
let current_dir = root . join ( " .story_kit " ) . join ( " work " ) . join ( " 2_current " ) ;
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
let filepath = current_dir . join ( format! ( " {story_id} .md " ) ) ;
if ! filepath . exists ( ) {
return Err ( format! ( " Story file not found: {story_id} .md " ) ) ;
}
let contents = fs ::read_to_string ( & filepath )
. map_err ( | e | format! ( " Failed to read story file: {e} " ) ) ? ;
let story_name = parse_front_matter ( & contents )
. ok ( )
. and_then ( | m | m . name ) ;
let todos = parse_unchecked_todos ( & contents ) ;
serde_json ::to_string_pretty ( & json! ( {
" story_id " : story_id ,
" story_name " : story_name ,
" todos " : todos ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
fn tool_record_tests ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let unit = parse_test_cases ( args . get ( " unit " ) ) ? ;
let integration = parse_test_cases ( args . get ( " integration " ) ) ? ;
let mut workflow = ctx
. workflow
. lock ( )
. map_err ( | e | format! ( " Lock error: {e} " ) ) ? ;
workflow . record_test_results_validated ( story_id . to_string ( ) , unit , integration ) ? ;
2026-02-25 09:50:31 +00:00
// Persist to story file (best-effort — file write errors are warnings, not failures).
if let Ok ( project_root ) = ctx . state . get_project_root ( )
& & let Some ( results ) = workflow . results . get ( story_id )
& & let Err ( e ) =
crate ::http ::workflow ::write_test_results_to_story_file ( & project_root , story_id , results )
{
slog_warn! ( " [record_tests] Could not persist results to story file: {e} " ) ;
}
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
Ok ( " Test results recorded. " . to_string ( ) )
}
fn tool_ensure_acceptance ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let workflow = ctx
. workflow
. lock ( )
. map_err ( | e | format! ( " Lock error: {e} " ) ) ? ;
2026-02-25 09:50:31 +00:00
// Use in-memory results if present; otherwise fall back to file-persisted results.
let file_results ;
let results = if let Some ( r ) = workflow . results . get ( story_id ) {
r
} else {
let project_root = ctx . state . get_project_root ( ) . ok ( ) ;
file_results = project_root . as_deref ( ) . and_then ( | root | {
crate ::http ::workflow ::read_test_results_from_story_file ( root , story_id )
} ) ;
file_results . as_ref ( ) . map_or_else (
| | {
// No results anywhere — use empty default for the acceptance check
// (it will fail with "No test results recorded")
static EMPTY : std ::sync ::OnceLock < crate ::workflow ::StoryTestResults > =
std ::sync ::OnceLock ::new ( ) ;
EMPTY . get_or_init ( Default ::default )
} ,
| r | r ,
)
} ;
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
let coverage = workflow . coverage . get ( story_id ) ;
let decision = evaluate_acceptance_with_coverage ( results , coverage ) ;
if decision . can_accept {
Ok ( " Story can be accepted. All gates pass. " . to_string ( ) )
} else {
let mut parts = decision . reasons ;
if let Some ( w ) = decision . warning {
parts . push ( w ) ;
}
Err ( format! ( " Acceptance blocked: {} " , parts . join ( " ; " ) ) )
}
}
// ── Agent tool implementations ────────────────────────────────────
async fn tool_start_agent ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let agent_name = args . get ( " agent_name " ) . and_then ( | v | v . as_str ( ) ) ;
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
let info = ctx
. agents
2026-02-23 13:13:41 +00:00
. start_agent ( & project_root , story_id , agent_name , None )
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
. await ? ;
2026-02-25 09:50:31 +00:00
// Snapshot coverage baseline from the most recent coverage report (best-effort).
if let Some ( pct ) = read_coverage_percent_from_json ( & project_root )
& & let Err ( e ) = crate ::http ::workflow ::write_coverage_baseline_to_story_file (
& project_root ,
story_id ,
pct ,
)
{
slog_warn! ( " [start_agent] Could not write coverage baseline to story file: {e} " ) ;
}
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
serde_json ::to_string_pretty ( & json! ( {
" story_id " : info . story_id ,
" agent_name " : info . agent_name ,
" status " : info . status . to_string ( ) ,
" session_id " : info . session_id ,
" worktree_path " : info . worktree_path ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
2026-02-25 09:50:31 +00:00
/// Try to read the overall line coverage percentage from the llvm-cov JSON report.
///
/// Expects the file at `{project_root}/.story_kit/coverage/server.json`.
/// Returns `None` if the file is absent, unreadable, or cannot be parsed.
fn read_coverage_percent_from_json ( project_root : & std ::path ::Path ) -> Option < f64 > {
let path = project_root
. join ( " .story_kit " )
. join ( " coverage " )
. join ( " server.json " ) ;
let contents = std ::fs ::read_to_string ( & path ) . ok ( ) ? ;
let json : Value = serde_json ::from_str ( & contents ) . ok ( ) ? ;
// cargo llvm-cov --json format: data[0].totals.lines.percent
json . pointer ( " /data/0/totals/lines/percent " )
. and_then ( | v | v . as_f64 ( ) )
}
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
async fn tool_stop_agent ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let agent_name = args
. get ( " agent_name " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: agent_name " ) ? ;
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
ctx . agents
. stop_agent ( & project_root , story_id , agent_name )
. await ? ;
Ok ( format! ( " Agent ' {agent_name} ' for story ' {story_id} ' stopped. " ) )
}
fn tool_list_agents ( ctx : & AppContext ) -> Result < String , String > {
2026-02-24 13:20:59 +00:00
let project_root = ctx . agents . get_project_root ( & ctx . state ) . ok ( ) ;
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
let agents = ctx . agents . list_agents ( ) ? ;
serde_json ::to_string_pretty ( & json! ( agents
. iter ( )
2026-02-24 13:20:59 +00:00
. filter ( | a | {
project_root
. as_deref ( )
. map ( | root | ! crate ::http ::agents ::story_is_archived ( root , & a . story_id ) )
. unwrap_or ( true )
} )
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
. map ( | a | json! ( {
" story_id " : a . story_id ,
" agent_name " : a . agent_name ,
" status " : a . status . to_string ( ) ,
" session_id " : a . session_id ,
" worktree_path " : a . worktree_path ,
} ) )
. collect ::< Vec < _ > > ( ) ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
2026-02-20 11:57:25 +00:00
async fn tool_get_agent_output_poll ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let agent_name = args
. get ( " agent_name " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: agent_name " ) ? ;
2026-02-23 20:52:06 +00:00
// Try draining in-memory events first.
match ctx . agents . drain_events ( story_id , agent_name ) {
Ok ( drained ) = > {
let done = drained . iter ( ) . any ( | e | {
matches! (
e ,
crate ::agents ::AgentEvent ::Done { .. }
| crate ::agents ::AgentEvent ::Error { .. }
)
} ) ;
let events : Vec < serde_json ::Value > = drained
. into_iter ( )
. filter_map ( | e | serde_json ::to_value ( & e ) . ok ( ) )
. collect ( ) ;
serde_json ::to_string_pretty ( & json! ( {
" events " : events ,
" done " : done ,
" event_count " : events . len ( ) ,
" message " : if done { " Agent stream ended. " } else if events . is_empty ( ) { " No new events. Call again to continue. " } else { " Events returned. Call again to continue. " }
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
Err ( _ ) = > {
// Agent not in memory — fall back to persistent log file.
get_agent_output_from_log ( story_id , agent_name , ctx )
}
}
}
2026-02-20 11:57:25 +00:00
2026-02-23 20:52:06 +00:00
/// Fall back to reading agent output from the persistent log file on disk.
///
/// Tries to find the log file via the agent's stored log_session_id first,
/// then falls back to `find_latest_log` scanning the log directory.
fn get_agent_output_from_log (
story_id : & str ,
agent_name : & str ,
ctx : & AppContext ,
) -> Result < String , String > {
use crate ::agent_log ;
2026-02-20 11:57:25 +00:00
2026-02-23 20:52:06 +00:00
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
2026-02-20 11:57:25 +00:00
2026-02-23 20:52:06 +00:00
// Try to find the log file: first from in-memory agent info, then by scanning.
let log_path = ctx
. agents
. get_log_info ( story_id , agent_name )
. map ( | ( session_id , root ) | agent_log ::log_file_path ( & root , story_id , agent_name , & session_id ) )
. filter ( | p | p . exists ( ) )
. or_else ( | | agent_log ::find_latest_log ( & project_root , story_id , agent_name ) ) ;
let log_path = match log_path {
Some ( p ) = > p ,
None = > {
return serde_json ::to_string_pretty ( & json! ( {
" events " : [ ] ,
" done " : true ,
" event_count " : 0 ,
" message " : format ! ( " No agent '{agent_name}' for story '{story_id}' and no log file found. " ) ,
" source " : " none " ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) ) ;
}
} ;
match agent_log ::read_log ( & log_path ) {
Ok ( entries ) = > {
let events : Vec < serde_json ::Value > = entries
. into_iter ( )
. map ( | e | {
let mut val = e . event ;
if let serde_json ::Value ::Object ( ref mut map ) = val {
map . insert (
" timestamp " . to_string ( ) ,
serde_json ::Value ::String ( e . timestamp ) ,
) ;
}
val
} )
. collect ( ) ;
let count = events . len ( ) ;
serde_json ::to_string_pretty ( & json! ( {
" events " : events ,
" done " : true ,
" event_count " : count ,
" message " : " Events loaded from persistent log file. " ,
" source " : " log_file " ,
" log_file " : log_path . to_string_lossy ( ) ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
Err ( e ) = > Err ( format! ( " Failed to read log file: {e} " ) ) ,
}
2026-02-20 11:57:25 +00:00
}
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
fn tool_get_agent_config ( ctx : & AppContext ) -> Result < String , String > {
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
let config = ProjectConfig ::load ( & project_root ) ? ;
serde_json ::to_string_pretty ( & json! ( config
. agent
. iter ( )
. map ( | a | json! ( {
" name " : a . name ,
" role " : a . role ,
" model " : a . model ,
" allowed_tools " : a . allowed_tools ,
" max_turns " : a . max_turns ,
" max_budget_usd " : a . max_budget_usd ,
} ) )
. collect ::< Vec < _ > > ( ) ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
2026-02-20 13:16:04 +00:00
async fn tool_wait_for_agent ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let agent_name = args
. get ( " agent_name " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: agent_name " ) ? ;
let timeout_ms = args
. get ( " timeout_ms " )
. and_then ( | v | v . as_u64 ( ) )
. unwrap_or ( 300_000 ) ; // default: 5 minutes
let info = ctx
. agents
. wait_for_agent ( story_id , agent_name , timeout_ms )
. await ? ;
let commits = match ( & info . worktree_path , & info . base_branch ) {
( Some ( wt_path ) , Some ( base ) ) = > get_worktree_commits ( wt_path , base ) . await ,
_ = > None ,
} ;
2026-02-20 15:02:34 +00:00
let completion = info . completion . as_ref ( ) . map ( | r | json! ( {
" summary " : r . summary ,
" gates_passed " : r . gates_passed ,
" gate_output " : r . gate_output ,
} ) ) ;
2026-02-20 13:16:04 +00:00
serde_json ::to_string_pretty ( & json! ( {
" story_id " : info . story_id ,
" agent_name " : info . agent_name ,
" status " : info . status . to_string ( ) ,
" session_id " : info . session_id ,
" worktree_path " : info . worktree_path ,
" base_branch " : info . base_branch ,
" commits " : commits ,
2026-02-20 15:02:34 +00:00
" completion " : completion ,
2026-02-20 13:16:04 +00:00
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
2026-02-20 14:09:52 +00:00
// ── Worktree tool implementations ────────────────────────────────
async fn tool_create_worktree ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
let info = ctx . 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} " ) )
}
fn tool_list_worktrees ( ctx : & AppContext ) -> Result < String , String > {
let project_root = ctx . 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 ::< Vec < _ > > ( ) ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
async fn tool_remove_worktree ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let project_root = ctx . 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. " ) )
}
2026-02-20 14:42:41 +00:00
// ── Editor tool implementations ───────────────────────────────────
fn tool_get_editor_command ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
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_from_store ( ctx )
. ok_or_else ( | | " No editor configured. Set one via PUT /api/settings/editor. " . to_string ( ) ) ? ;
Ok ( format! ( " {editor} {worktree_path} " ) )
}
2026-02-20 15:09:39 +00:00
fn tool_accept_story ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
move_story_to_archived ( & project_root , story_id ) ? ;
2026-02-24 13:20:59 +00:00
ctx . agents . remove_agents_for_story ( story_id ) ;
2026-02-20 15:09:39 +00:00
2026-02-20 15:31:13 +00:00
Ok ( format! (
2026-02-24 17:01:57 +00:00
" Story '{story_id}' accepted, moved to done/, and committed to master. "
2026-02-20 15:31:13 +00:00
) )
}
fn tool_check_criterion ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let criterion_index = args
. get ( " criterion_index " )
. and_then ( | v | v . as_u64 ( ) )
. ok_or ( " Missing required argument: criterion_index " ) ? as usize ;
let root = ctx . state . get_project_root ( ) ? ;
check_criterion_in_file ( & root , story_id , criterion_index ) ? ;
Ok ( format! (
" Criterion {criterion_index} checked for story '{story_id}'. Committed to master. "
) )
}
2026-02-23 20:27:09 +00:00
// ── Spike lifecycle tool implementations ─────────────────────────
fn tool_create_spike ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let name = args
. get ( " name " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: name " ) ? ;
let description = args . get ( " description " ) . and_then ( | v | v . as_str ( ) ) ;
let root = ctx . state . get_project_root ( ) ? ;
let spike_id = create_spike_file ( & root , name , description ) ? ;
Ok ( format! ( " Created spike: {spike_id} " ) )
}
2026-02-20 16:34:32 +00:00
// ── Bug lifecycle tool implementations ───────────────────────────
fn tool_create_bug ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let name = args
. get ( " name " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: name " ) ? ;
let description = args
. get ( " description " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: description " ) ? ;
let steps_to_reproduce = args
. get ( " steps_to_reproduce " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: steps_to_reproduce " ) ? ;
let actual_result = args
. get ( " actual_result " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: actual_result " ) ? ;
let expected_result = args
. get ( " expected_result " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: expected_result " ) ? ;
let acceptance_criteria : Option < Vec < String > > = args
. get ( " acceptance_criteria " )
. and_then ( | v | serde_json ::from_value ( v . clone ( ) ) . ok ( ) ) ;
let root = ctx . state . get_project_root ( ) ? ;
let bug_id = create_bug_file (
& root ,
name ,
description ,
steps_to_reproduce ,
actual_result ,
expected_result ,
acceptance_criteria . as_deref ( ) ,
) ? ;
Ok ( format! ( " Created bug: {bug_id} " ) )
}
fn tool_list_bugs ( ctx : & AppContext ) -> Result < String , String > {
let root = ctx . state . get_project_root ( ) ? ;
let bugs = list_bug_files ( & root ) ? ;
serde_json ::to_string_pretty ( & json! ( bugs
. iter ( )
. map ( | ( id , name ) | json! ( { " bug_id " : id , " name " : name } ) )
. collect ::< Vec < _ > > ( ) ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
fn tool_close_bug ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let bug_id = args
. get ( " bug_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: bug_id " ) ? ;
let root = ctx . agents . get_project_root ( & ctx . state ) ? ;
close_bug_to_archive ( & root , bug_id ) ? ;
2026-02-24 13:20:59 +00:00
ctx . agents . remove_agents_for_story ( bug_id ) ;
2026-02-20 16:34:32 +00:00
Ok ( format! (
" Bug '{bug_id}' closed, moved to bugs/archive/, and committed to master. "
) )
}
2026-02-20 17:36:35 +00:00
// ── Mergemaster tool implementations ─────────────────────────────
async fn tool_merge_agent_work ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let agent_name = args . get ( " agent_name " ) . and_then ( | v | v . as_str ( ) ) ;
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
let report = ctx . agents . merge_agent_work ( & project_root , story_id ) . await ? ;
2026-02-23 23:22:24 +00:00
let status_msg = if report . success & & report . gates_passed & & report . conflicts_resolved {
2026-02-24 18:38:13 +00:00
" Merge complete: conflicts were auto-resolved and all quality gates passed. Story moved to done and worktree cleaned up. "
2026-02-23 23:22:24 +00:00
} else if report . success & & report . gates_passed {
2026-02-24 18:38:13 +00:00
" Merge complete: all quality gates passed. Story moved to done and worktree cleaned up. "
2026-02-23 23:22:24 +00:00
} else if report . had_conflicts & & ! report . conflicts_resolved {
" Merge failed: conflicts detected that could not be auto-resolved. Merge was aborted — master is untouched. Report the conflict details so the human can resolve them. "
2026-02-20 17:36:35 +00:00
} else if report . success & & ! report . gates_passed {
" Merge committed but quality gates failed. Review gate_output and fix issues before re-running. "
} else {
" Merge failed. Review gate_output for details. "
} ;
serde_json ::to_string_pretty ( & json! ( {
" story_id " : story_id ,
" agent_name " : agent_name ,
" success " : report . success ,
" had_conflicts " : report . had_conflicts ,
2026-02-23 23:22:24 +00:00
" conflicts_resolved " : report . conflicts_resolved ,
2026-02-20 17:36:35 +00:00
" conflict_details " : report . conflict_details ,
" gates_passed " : report . gates_passed ,
" gate_output " : report . gate_output ,
" worktree_cleaned_up " : report . worktree_cleaned_up ,
" story_archived " : report . story_archived ,
" message " : status_msg ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
2026-02-23 12:12:20 +00:00
async fn tool_move_story_to_merge ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
2026-02-20 17:36:35 +00:00
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
2026-02-23 12:12:20 +00:00
let agent_name = args
. get ( " agent_name " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " mergemaster " ) ;
2026-02-20 17:36:35 +00:00
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
2026-02-23 12:12:20 +00:00
// Move story from work/2_current/ to work/4_merge/
2026-02-20 17:36:35 +00:00
move_story_to_merge ( & project_root , story_id ) ? ;
2026-02-23 12:12:20 +00:00
// Start the mergemaster agent on the story worktree
let info = ctx
. agents
2026-02-23 13:13:41 +00:00
. start_agent ( & project_root , story_id , Some ( agent_name ) , None )
2026-02-23 12:12:20 +00:00
. await ? ;
serde_json ::to_string_pretty ( & json! ( {
" story_id " : info . story_id ,
" agent_name " : info . agent_name ,
" status " : info . status . to_string ( ) ,
" worktree_path " : info . worktree_path ,
" message " : format ! (
" Story '{story_id}' moved to work/4_merge/ and mergemaster agent '{}' started. " ,
info . agent_name
) ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
2026-02-20 17:36:35 +00:00
}
2026-02-20 17:44:06 +00:00
// ── QA tool implementations ───────────────────────────────────────
async fn tool_request_qa ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let agent_name = args
. get ( " agent_name " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " qa " ) ;
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
// Move story from work/2_current/ to work/3_qa/
move_story_to_qa ( & project_root , story_id ) ? ;
// Start the QA agent on the story worktree
let info = ctx
. agents
2026-02-23 13:13:41 +00:00
. start_agent ( & project_root , story_id , Some ( agent_name ) , None )
2026-02-20 17:44:06 +00:00
. await ? ;
serde_json ::to_string_pretty ( & json! ( {
" story_id " : info . story_id ,
" agent_name " : info . agent_name ,
" status " : info . status . to_string ( ) ,
" worktree_path " : info . worktree_path ,
" message " : format ! (
" Story '{story_id}' moved to work/3_qa/ and QA agent '{}' started. " ,
info . agent_name
) ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
2026-02-20 13:16:04 +00:00
/// Run `git log <base>..HEAD --oneline` in the worktree and return the commit
/// summaries, or `None` if git is unavailable or there are no new commits.
async fn get_worktree_commits ( worktree_path : & str , base_branch : & str ) -> Option < Vec < String > > {
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 > = 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 ( )
}
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
// ── Helpers ───────────────────────────────────────────────────────
fn parse_test_cases ( value : Option < & Value > ) -> Result < Vec < TestCaseResult > , String > {
let arr = match value {
Some ( Value ::Array ( a ) ) = > a ,
Some ( Value ::Null ) | None = > return Ok ( Vec ::new ( ) ) ,
_ = > return Err ( " Expected array for test cases " . to_string ( ) ) ,
} ;
arr . iter ( )
. map ( | item | {
let name = item
. get ( " name " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Test case missing 'name' " ) ?
. to_string ( ) ;
let status_str = item
. get ( " status " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Test case missing 'status' " ) ? ;
let status = match status_str {
" pass " = > TestStatus ::Pass ,
" fail " = > TestStatus ::Fail ,
other = > return Err ( format! ( " Invalid test status ' {other} '. Use 'pass' or 'fail'. " ) ) ,
} ;
let details = item . get ( " details " ) . and_then ( | v | v . as_str ( ) ) . map ( String ::from ) ;
Ok ( TestCaseResult {
name ,
status ,
details ,
} )
} )
. collect ( )
}
feat(story-93): expose server logs to agents via get_server_logs MCP tool
- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API
- Add slog! macro: drop-in for eprintln! that also captures to ring buffer
- Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog!
- Add get_server_logs MCP tool: accepts count (1-500) and optional filter params
- 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer
- 262 tests passing, clippy clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:38:19 +00:00
fn tool_get_server_logs ( args : & Value ) -> Result < String , String > {
2026-02-24 12:49:29 +00:00
let lines_count = args
feat(story-93): expose server logs to agents via get_server_logs MCP tool
- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API
- Add slog! macro: drop-in for eprintln! that also captures to ring buffer
- Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog!
- Add get_server_logs MCP tool: accepts count (1-500) and optional filter params
- 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer
- 262 tests passing, clippy clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:38:19 +00:00
. get ( " lines " )
. and_then ( | v | v . as_u64 ( ) )
. map ( | n | n . min ( 1000 ) as usize )
. unwrap_or ( 100 ) ;
let filter = args . get ( " filter " ) . and_then ( | v | v . as_str ( ) ) ;
2026-02-24 13:48:25 +00:00
let severity = args
. get ( " severity " )
. and_then ( | v | v . as_str ( ) )
. and_then ( log_buffer ::LogLevel ::from_str_ci ) ;
feat(story-93): expose server logs to agents via get_server_logs MCP tool
- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API
- Add slog! macro: drop-in for eprintln! that also captures to ring buffer
- Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog!
- Add get_server_logs MCP tool: accepts count (1-500) and optional filter params
- 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer
- 262 tests passing, clippy clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:38:19 +00:00
2026-02-24 13:48:25 +00:00
let recent = log_buffer ::global ( ) . get_recent ( lines_count , filter , severity . as_ref ( ) ) ;
let joined = recent . join ( " \n " ) ;
// Clamp to lines_count actual lines in case any entry contains embedded newlines.
let all_lines : Vec < & str > = joined . lines ( ) . collect ( ) ;
2026-02-24 12:49:29 +00:00
let start = all_lines . len ( ) . saturating_sub ( lines_count ) ;
Ok ( all_lines [ start .. ] . join ( " \n " ) )
feat(story-93): expose server logs to agents via get_server_logs MCP tool
- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API
- Add slog! macro: drop-in for eprintln! that also captures to ring buffer
- Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog!
- Add get_server_logs MCP tool: accepts count (1-500) and optional filter params
- 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer
- 262 tests passing, clippy clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:38:19 +00:00
}
2026-02-23 21:38:45 +00:00
/// MCP tool called by Claude Code via `--permission-prompt-tool`.
///
/// Forwards the permission request through the shared channel to the active
/// WebSocket session, which presents a dialog to the user. Blocks until the
/// user approves or denies (with a 5-minute timeout).
async fn tool_prompt_permission ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let tool_name = args
. get ( " tool_name " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " unknown " )
. to_string ( ) ;
let tool_input = args
. get ( " input " )
. cloned ( )
. unwrap_or ( json! ( { } ) ) ;
let request_id = uuid ::Uuid ::new_v4 ( ) . to_string ( ) ;
let ( response_tx , response_rx ) = tokio ::sync ::oneshot ::channel ( ) ;
2026-02-24 14:06:00 +00:00
let tool_input_copy = tool_input . clone ( ) ;
2026-02-23 21:38:45 +00:00
ctx . perm_tx
. send ( crate ::http ::context ::PermissionForward {
request_id : request_id . clone ( ) ,
tool_name : tool_name . clone ( ) ,
tool_input ,
response_tx ,
} )
. map_err ( | _ | " No active WebSocket session to receive permission request " . to_string ( ) ) ? ;
let approved = tokio ::time ::timeout (
std ::time ::Duration ::from_secs ( 300 ) ,
response_rx ,
)
. await
2026-02-24 13:48:25 +00:00
. map_err ( | _ | {
let msg = format! ( " Permission request for ' {tool_name} ' timed out after 5 minutes " ) ;
slog_warn! ( " [permission] {msg} " ) ;
msg
} ) ?
2026-02-23 21:38:45 +00:00
. map_err ( | _ | " Permission response channel closed unexpectedly " . to_string ( ) ) ? ;
if approved {
2026-02-24 14:06:00 +00:00
Ok ( json! ( { " updatedInput " : tool_input_copy } ) . to_string ( ) )
2026-02-23 21:38:45 +00:00
} else {
2026-02-24 13:48:25 +00:00
slog_warn! ( " [permission] User denied permission for '{tool_name}' " ) ;
2026-02-24 11:51:00 +00:00
Ok ( json! ( {
" behavior " : " deny " ,
" message " : format ! ( " User denied permission for '{tool_name}' " )
} )
. to_string ( ) )
2026-02-23 21:38:45 +00:00
}
}
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
#[ cfg(test) ]
mod tests {
use super ::* ;
use crate ::http ::context ::AppContext ;
2026-02-20 15:02:34 +00:00
use crate ::store ::StoreOps ;
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
// ── Unit tests ────────────────────────────────────────────────
#[ test ]
fn parse_test_cases_empty ( ) {
let result = parse_test_cases ( None ) . unwrap ( ) ;
assert! ( result . is_empty ( ) ) ;
}
#[ test ]
fn parse_test_cases_valid ( ) {
let input = json! ( [
{ " name " : " test1 " , " status " : " pass " } ,
{ " name " : " test2 " , " status " : " fail " , " details " : " assertion failed " }
] ) ;
let result = parse_test_cases ( Some ( & input ) ) . unwrap ( ) ;
assert_eq! ( result . len ( ) , 2 ) ;
assert_eq! ( result [ 0 ] . status , TestStatus ::Pass ) ;
assert_eq! ( result [ 1 ] . status , TestStatus ::Fail ) ;
assert_eq! ( result [ 1 ] . details , Some ( " assertion failed " . to_string ( ) ) ) ;
}
#[ test ]
fn parse_test_cases_invalid_status ( ) {
let input = json! ( [ { " name " : " t " , " status " : " maybe " } ] ) ;
assert! ( parse_test_cases ( Some ( & input ) ) . is_err ( ) ) ;
}
#[ test ]
fn json_rpc_response_serializes_success ( ) {
let resp = JsonRpcResponse ::success ( Some ( json! ( 1 ) ) , json! ( { " ok " : true } ) ) ;
let s = serde_json ::to_string ( & resp ) . unwrap ( ) ;
assert! ( s . contains ( " \" result \" " ) ) ;
assert! ( ! s . contains ( " \" error \" " ) ) ;
}
#[ test ]
fn json_rpc_response_serializes_error ( ) {
let resp = JsonRpcResponse ::error ( Some ( json! ( 1 ) ) , - 32600 , " bad " . into ( ) ) ;
let s = serde_json ::to_string ( & resp ) . unwrap ( ) ;
assert! ( s . contains ( " \" error \" " ) ) ;
assert! ( ! s . contains ( " \" result \" " ) ) ;
}
// ── Protocol handler integration tests ────────────────────────
#[ test ]
fn initialize_returns_capabilities ( ) {
let resp = handle_initialize (
Some ( json! ( 1 ) ) ,
& json! ( { " protocolVersion " : " 2025-03-26 " , " capabilities " : { } , " clientInfo " : { " name " : " test " , " version " : " 1.0 " } } ) ,
) ;
let result = resp . result . unwrap ( ) ;
assert_eq! ( result [ " protocolVersion " ] , " 2025-03-26 " ) ;
assert! ( result [ " capabilities " ] [ " tools " ] . is_object ( ) ) ;
assert_eq! ( result [ " serverInfo " ] [ " name " ] , " story-kit " ) ;
}
#[ 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 " ) ) ;
2026-02-20 13:16:04 +00:00
assert! ( names . contains ( & " wait_for_agent " ) ) ;
2026-02-20 14:09:52 +00:00
assert! ( names . contains ( & " create_worktree " ) ) ;
assert! ( names . contains ( & " list_worktrees " ) ) ;
assert! ( names . contains ( & " remove_worktree " ) ) ;
2026-02-20 14:42:41 +00:00
assert! ( names . contains ( & " get_editor_command " ) ) ;
2026-02-23 15:00:10 +00:00
assert! ( ! names . contains ( & " report_completion " ) ) ;
2026-02-20 15:09:39 +00:00
assert! ( names . contains ( & " accept_story " ) ) ;
2026-02-20 15:31:13 +00:00
assert! ( names . contains ( & " check_criterion " ) ) ;
2026-02-23 20:27:09 +00:00
assert! ( names . contains ( & " create_spike " ) ) ;
2026-02-20 16:34:32 +00:00
assert! ( names . contains ( & " create_bug " ) ) ;
assert! ( names . contains ( & " list_bugs " ) ) ;
assert! ( names . contains ( & " close_bug " ) ) ;
2026-02-20 17:36:35 +00:00
assert! ( names . contains ( & " merge_agent_work " ) ) ;
assert! ( names . contains ( & " move_story_to_merge " ) ) ;
2026-02-20 17:44:06 +00:00
assert! ( names . contains ( & " request_qa " ) ) ;
feat(story-93): expose server logs to agents via get_server_logs MCP tool
- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API
- Add slog! macro: drop-in for eprintln! that also captures to ring buffer
- Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog!
- Add get_server_logs MCP tool: accepts count (1-500) and optional filter params
- 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer
- 262 tests passing, clippy clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:38:19 +00:00
assert! ( names . contains ( & " get_server_logs " ) ) ;
2026-02-23 21:38:45 +00:00
assert! ( names . contains ( & " prompt_permission " ) ) ;
assert_eq! ( tools . len ( ) , 28 ) ;
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
}
#[ 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 " ) ;
}
}
fn test_ctx ( dir : & std ::path ::Path ) -> AppContext {
AppContext ::new_test ( dir . to_path_buf ( ) )
}
#[ test ]
fn tool_validate_stories_empty_project ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_validate_stories ( & ctx ) . unwrap ( ) ;
let parsed : Vec < Value > = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert! ( parsed . is_empty ( ) ) ;
}
#[ test ]
fn tool_create_story_and_list_upcoming ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
2026-02-20 19:39:19 +00:00
// No git repo needed: spike 61 — create_story just writes the file;
// the filesystem watcher handles the commit asynchronously.
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_create_story (
2026-02-20 15:31:13 +00:00
& json! ( { " name " : " Test Story " , " acceptance_criteria " : [ " AC1 " , " AC2 " ] } ) ,
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
& ctx ,
)
. unwrap ( ) ;
assert! ( result . contains ( " Created story: " ) ) ;
// List should return it
let list = tool_list_upcoming ( & ctx ) . unwrap ( ) ;
let parsed : Vec < Value > = serde_json ::from_str ( & list ) . unwrap ( ) ;
assert_eq! ( parsed . len ( ) , 1 ) ;
assert_eq! ( parsed [ 0 ] [ " name " ] , " Test Story " ) ;
}
#[ test ]
fn tool_create_story_rejects_empty_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_create_story ( & json! ( { " name " : " !!! " } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " alphanumeric " ) ) ;
}
#[ test ]
fn tool_create_story_missing_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_create_story ( & json! ( { } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " Missing required argument " ) ) ;
}
#[ test ]
fn tool_get_story_todos_missing_file ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_get_story_todos ( & json! ( { " story_id " : " 99_nonexistent " } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " not found " ) ) ;
}
#[ test ]
fn tool_get_story_todos_returns_unchecked ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
2026-02-20 17:24:26 +00:00
let current_dir = tmp . path ( ) . join ( " .story_kit " ) . join ( " work " ) . join ( " 2_current " ) ;
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
fs ::create_dir_all ( & current_dir ) . unwrap ( ) ;
fs ::write (
current_dir . join ( " 1_test.md " ) ,
2026-02-23 19:12:05 +00:00
" --- \n name: Test \n --- \n ## AC \n - [ ] First \n - [x] Done \n - [ ] Second \n " ,
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
)
. unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_get_story_todos ( & json! ( { " story_id " : " 1_test " } ) , & ctx ) . unwrap ( ) ;
let parsed : Value = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert_eq! ( parsed [ " todos " ] . as_array ( ) . unwrap ( ) . len ( ) , 2 ) ;
assert_eq! ( parsed [ " story_name " ] , " Test " ) ;
}
#[ test ]
fn tool_record_tests_and_ensure_acceptance ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
// Record passing tests
let result = tool_record_tests (
& json! ( {
" story_id " : " 1_test " ,
" unit " : [ { " name " : " u1 " , " status " : " pass " } ] ,
" integration " : [ { " name " : " i1 " , " status " : " pass " } ]
} ) ,
& ctx ,
)
. unwrap ( ) ;
assert! ( result . contains ( " recorded " ) ) ;
// Should be acceptable
let result = tool_ensure_acceptance ( & json! ( { " story_id " : " 1_test " } ) , & ctx ) . unwrap ( ) ;
assert! ( result . contains ( " All gates pass " ) ) ;
}
#[ test ]
fn tool_ensure_acceptance_blocks_on_failures ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
tool_record_tests (
& json! ( {
" story_id " : " 1_test " ,
" unit " : [ { " name " : " u1 " , " status " : " fail " } ] ,
" integration " : [ ]
} ) ,
& ctx ,
)
. unwrap ( ) ;
let result = tool_ensure_acceptance ( & json! ( { " story_id " : " 1_test " } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " blocked " ) ) ;
}
#[ test ]
fn tool_list_agents_empty ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_list_agents ( & ctx ) . unwrap ( ) ;
let parsed : Vec < Value > = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert! ( parsed . is_empty ( ) ) ;
}
#[ 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 } ) ) ;
let http_resp = to_sse_response ( resp ) ;
assert_eq! (
http_resp . headers ( ) . get ( " content-type " ) . unwrap ( ) ,
" text/event-stream "
) ;
}
#[ test ]
fn wants_sse_detects_accept_header ( ) {
// Can't easily construct a Request in tests without TestClient,
// so test the logic indirectly via to_sse_response format
let resp = JsonRpcResponse ::success ( Some ( json! ( 1 ) ) , json! ( " ok " ) ) ;
let json_resp = to_json_response ( resp ) ;
assert_eq! (
json_resp . headers ( ) . get ( " content-type " ) . unwrap ( ) ,
" application/json "
) ;
}
2026-02-20 13:16:04 +00:00
#[ test ]
fn wait_for_agent_tool_in_list ( ) {
let resp = handle_tools_list ( Some ( json! ( 1 ) ) ) ;
let tools = resp . result . unwrap ( ) [ " tools " ] . as_array ( ) . unwrap ( ) . clone ( ) ;
let wait_tool = tools . iter ( ) . find ( | t | t [ " name " ] = = " wait_for_agent " ) ;
assert! ( wait_tool . is_some ( ) , " wait_for_agent missing from tools list " ) ;
let t = wait_tool . unwrap ( ) ;
assert! ( t [ " description " ] . as_str ( ) . unwrap ( ) . contains ( " block " ) | | t [ " description " ] . as_str ( ) . unwrap ( ) . contains ( " Block " ) ) ;
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 ( & " story_id " ) ) ;
assert! ( req_names . contains ( & " agent_name " ) ) ;
}
#[ tokio::test ]
async fn wait_for_agent_tool_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_wait_for_agent ( & json! ( { " agent_name " : " bot " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ tokio::test ]
async fn wait_for_agent_tool_missing_agent_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_wait_for_agent ( & json! ( { " story_id " : " 1_test " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " agent_name " ) ) ;
}
#[ tokio::test ]
async fn wait_for_agent_tool_nonexistent_agent_returns_error ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result =
tool_wait_for_agent ( & json! ( { " story_id " : " 99_nope " , " agent_name " : " bot " , " timeout_ms " : 50 } ) , & ctx )
. await ;
// No agent registered — should error
assert! ( result . is_err ( ) ) ;
}
#[ tokio::test ]
async fn wait_for_agent_tool_returns_completed_agent ( ) {
use crate ::agents ::AgentStatus ;
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
ctx . agents
. inject_test_agent ( " 41_story " , " worker " , AgentStatus ::Completed ) ;
let result = tool_wait_for_agent (
& json! ( { " story_id " : " 41_story " , " agent_name " : " worker " } ) ,
& ctx ,
)
. await
. unwrap ( ) ;
let parsed : Value = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert_eq! ( parsed [ " status " ] , " completed " ) ;
assert_eq! ( parsed [ " story_id " ] , " 41_story " ) ;
assert_eq! ( parsed [ " agent_name " ] , " worker " ) ;
// commits key present (may be null since no real worktree)
assert! ( parsed . get ( " commits " ) . is_some ( ) ) ;
2026-02-20 15:02:34 +00:00
// completion key present (null for agents that didn't call report_completion)
assert! ( parsed . get ( " completion " ) . is_some ( ) ) ;
}
2026-02-20 14:42:41 +00:00
// ── 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 ( ) {
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 " ) ) ;
}
2026-02-20 16:34:32 +00:00
// ── Bug lifecycle tool tests ──────────────────────────────────
fn setup_git_repo_in ( dir : & std ::path ::Path ) {
std ::process ::Command ::new ( " git " )
. args ( [ " init " ] )
. current_dir ( dir )
. output ( )
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " config " , " user.email " , " test@test.com " ] )
. current_dir ( dir )
. output ( )
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " config " , " user.name " , " Test " ] )
. current_dir ( dir )
. output ( )
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " commit " , " --allow-empty " , " -m " , " init " ] )
. current_dir ( dir )
. output ( )
. unwrap ( ) ;
}
#[ test ]
fn create_bug_in_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 " ] = = " create_bug " ) ;
assert! ( tool . is_some ( ) , " create_bug 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 ( & " name " ) ) ;
assert! ( req_names . contains ( & " description " ) ) ;
assert! ( req_names . contains ( & " steps_to_reproduce " ) ) ;
assert! ( req_names . contains ( & " actual_result " ) ) ;
assert! ( req_names . contains ( & " expected_result " ) ) ;
}
#[ test ]
fn list_bugs_in_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 " ] = = " list_bugs " ) ;
assert! ( tool . is_some ( ) , " list_bugs missing from tools list " ) ;
}
#[ test ]
fn close_bug_in_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 " ] = = " close_bug " ) ;
assert! ( tool . is_some ( ) , " close_bug missing from tools list " ) ;
let t = tool . unwrap ( ) ;
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 ( & " bug_id " ) ) ;
}
#[ test ]
fn tool_create_bug_missing_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_create_bug (
& json! ( {
" description " : " d " ,
" steps_to_reproduce " : " s " ,
" actual_result " : " a " ,
" expected_result " : " e "
} ) ,
& ctx ,
) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " name " ) ) ;
}
#[ test ]
fn tool_create_bug_missing_description ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_create_bug (
& json! ( {
" name " : " Bug " ,
" steps_to_reproduce " : " s " ,
" actual_result " : " a " ,
" expected_result " : " e "
} ) ,
& ctx ,
) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " description " ) ) ;
}
#[ test ]
fn tool_create_bug_creates_file_and_commits ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
setup_git_repo_in ( tmp . path ( ) ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_create_bug (
& json! ( {
" name " : " Login Crash " ,
" description " : " The app crashes on login. " ,
" steps_to_reproduce " : " 1. Open app \n 2. Click login " ,
" actual_result " : " 500 error " ,
" expected_result " : " Successful login "
} ) ,
& ctx ,
)
. unwrap ( ) ;
2026-02-20 17:24:26 +00:00
assert! ( result . contains ( " 1_bug_login_crash " ) ) ;
2026-02-20 16:34:32 +00:00
let bug_file = tmp
. path ( )
2026-02-20 17:24:26 +00:00
. join ( " .story_kit/work/1_upcoming/1_bug_login_crash.md " ) ;
2026-02-20 16:34:32 +00:00
assert! ( bug_file . exists ( ) ) ;
}
#[ test ]
fn tool_list_bugs_empty ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_list_bugs ( & ctx ) . unwrap ( ) ;
let parsed : Vec < Value > = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert! ( parsed . is_empty ( ) ) ;
}
#[ test ]
fn tool_list_bugs_returns_open_bugs ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
2026-02-20 17:24:26 +00:00
let upcoming_dir = tmp . path ( ) . join ( " .story_kit/work/1_upcoming " ) ;
std ::fs ::create_dir_all ( & upcoming_dir ) . unwrap ( ) ;
2026-02-20 16:34:32 +00:00
std ::fs ::write (
2026-02-20 17:24:26 +00:00
upcoming_dir . join ( " 1_bug_crash.md " ) ,
2026-02-20 16:34:32 +00:00
" # Bug 1: App Crash \n " ,
)
. unwrap ( ) ;
std ::fs ::write (
2026-02-20 17:24:26 +00:00
upcoming_dir . join ( " 2_bug_typo.md " ) ,
2026-02-20 16:34:32 +00:00
" # Bug 2: Typo in Header \n " ,
)
. unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_list_bugs ( & ctx ) . unwrap ( ) ;
let parsed : Vec < Value > = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert_eq! ( parsed . len ( ) , 2 ) ;
2026-02-20 17:24:26 +00:00
assert_eq! ( parsed [ 0 ] [ " bug_id " ] , " 1_bug_crash " ) ;
2026-02-20 16:34:32 +00:00
assert_eq! ( parsed [ 0 ] [ " name " ] , " App Crash " ) ;
2026-02-20 17:24:26 +00:00
assert_eq! ( parsed [ 1 ] [ " bug_id " ] , " 2_bug_typo " ) ;
2026-02-20 16:34:32 +00:00
assert_eq! ( parsed [ 1 ] [ " name " ] , " Typo in Header " ) ;
}
#[ test ]
fn tool_close_bug_missing_bug_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_close_bug ( & json! ( { } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " bug_id " ) ) ;
}
#[ test ]
fn tool_close_bug_moves_to_archive ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
setup_git_repo_in ( tmp . path ( ) ) ;
2026-02-20 17:24:26 +00:00
let upcoming_dir = tmp . path ( ) . join ( " .story_kit/work/1_upcoming " ) ;
std ::fs ::create_dir_all ( & upcoming_dir ) . unwrap ( ) ;
let bug_file = upcoming_dir . join ( " 1_bug_crash.md " ) ;
2026-02-20 16:34:32 +00:00
std ::fs ::write ( & bug_file , " # Bug 1: Crash \n " ) . unwrap ( ) ;
// Stage the file so it's tracked
std ::process ::Command ::new ( " git " )
. args ( [ " add " , " . " ] )
. current_dir ( tmp . path ( ) )
. output ( )
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " commit " , " -m " , " add bug " ] )
. current_dir ( tmp . path ( ) )
. output ( )
. unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
2026-02-20 17:24:26 +00:00
let result = tool_close_bug ( & json! ( { " bug_id " : " 1_bug_crash " } ) , & ctx ) . unwrap ( ) ;
assert! ( result . contains ( " 1_bug_crash " ) ) ;
2026-02-20 16:34:32 +00:00
assert! ( ! bug_file . exists ( ) ) ;
2026-02-24 17:01:57 +00:00
assert! ( tmp . path ( ) . join ( " .story_kit/work/5_done/1_bug_crash.md " ) . exists ( ) ) ;
2026-02-20 16:34:32 +00:00
}
2026-02-20 17:36:35 +00:00
2026-02-23 20:27:09 +00:00
// ── Spike lifecycle tool tests ─────────────────────────────────────────
#[ test ]
fn create_spike_in_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 " ] = = " create_spike " ) ;
assert! ( tool . is_some ( ) , " create_spike 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 ( & " name " ) ) ;
// description is optional
assert! ( ! req_names . contains ( & " description " ) ) ;
}
#[ test ]
fn tool_create_spike_missing_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_create_spike ( & json! ( { } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " name " ) ) ;
}
#[ test ]
fn tool_create_spike_rejects_empty_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_create_spike ( & json! ( { " name " : " !!! " } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " alphanumeric " ) ) ;
}
#[ test ]
fn tool_create_spike_creates_file ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_create_spike (
& json! ( { " name " : " Compare Encoders " , " description " : " Which encoder is fastest? " } ) ,
& ctx ,
)
. unwrap ( ) ;
assert! ( result . contains ( " 1_spike_compare_encoders " ) ) ;
let spike_file = tmp
. path ( )
. join ( " .story_kit/work/1_upcoming/1_spike_compare_encoders.md " ) ;
assert! ( spike_file . exists ( ) ) ;
let contents = std ::fs ::read_to_string ( & spike_file ) . unwrap ( ) ;
assert! ( contents . starts_with ( " --- \n name: \" Compare Encoders \" \n --- " ) ) ;
assert! ( contents . contains ( " Which encoder is fastest? " ) ) ;
}
#[ test ]
fn tool_create_spike_creates_file_without_description ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_create_spike ( & json! ( { " name " : " My Spike " } ) , & ctx ) . unwrap ( ) ;
assert! ( result . contains ( " 1_spike_my_spike " ) ) ;
let spike_file = tmp . path ( ) . join ( " .story_kit/work/1_upcoming/1_spike_my_spike.md " ) ;
assert! ( spike_file . exists ( ) ) ;
let contents = std ::fs ::read_to_string ( & spike_file ) . unwrap ( ) ;
assert! ( contents . starts_with ( " --- \n name: \" My Spike \" \n --- " ) ) ;
assert! ( contents . contains ( " ## Question \n \n - TBD \n " ) ) ;
}
2026-02-20 17:36:35 +00:00
// ── Mergemaster tool tests ─────────────────────────────────────────────
#[ test ]
fn merge_agent_work_in_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 " ] = = " merge_agent_work " ) ;
assert! ( tool . is_some ( ) , " merge_agent_work 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 ( & " story_id " ) ) ;
// agent_name is optional
assert! ( ! req_names . contains ( & " agent_name " ) ) ;
}
#[ test ]
fn move_story_to_merge_in_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 " ] = = " move_story_to_merge " ) ;
assert! ( tool . is_some ( ) , " move_story_to_merge 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 ( & " story_id " ) ) ;
2026-02-23 12:12:20 +00:00
// agent_name is optional
assert! ( ! req_names . contains ( & " agent_name " ) ) ;
2026-02-20 17:36:35 +00:00
}
#[ tokio::test ]
async fn tool_merge_agent_work_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_merge_agent_work ( & json! ( { } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
2026-02-23 12:12:20 +00:00
#[ tokio::test ]
async fn tool_move_story_to_merge_missing_story_id ( ) {
2026-02-20 17:36:35 +00:00
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
2026-02-23 12:12:20 +00:00
let result = tool_move_story_to_merge ( & json! ( { } ) , & ctx ) . await ;
2026-02-20 17:36:35 +00:00
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
2026-02-23 12:12:20 +00:00
#[ tokio::test ]
async fn tool_move_story_to_merge_moves_file ( ) {
2026-02-20 17:36:35 +00:00
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
setup_git_repo_in ( tmp . path ( ) ) ;
let current_dir = tmp . path ( ) . join ( " .story_kit/work/2_current " ) ;
std ::fs ::create_dir_all ( & current_dir ) . unwrap ( ) ;
let story_file = current_dir . join ( " 24_story_test.md " ) ;
2026-02-23 19:12:05 +00:00
std ::fs ::write ( & story_file , " --- \n name: Test \n --- \n " ) . unwrap ( ) ;
2026-02-20 17:36:35 +00:00
std ::process ::Command ::new ( " git " )
. args ( [ " add " , " . " ] )
. current_dir ( tmp . path ( ) )
. output ( )
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " commit " , " -m " , " add story " ] )
. current_dir ( tmp . path ( ) )
. output ( )
. unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
2026-02-23 12:12:20 +00:00
// The agent start will fail in test (no worktree/config), but the file move should succeed
let result = tool_move_story_to_merge ( & json! ( { " story_id " : " 24_story_test " } ) , & ctx ) . await ;
// File should have been moved regardless of agent start outcome
2026-02-20 17:36:35 +00:00
assert! ( ! story_file . exists ( ) , " 2_current file should be gone " ) ;
assert! (
tmp . path ( ) . join ( " .story_kit/work/4_merge/24_story_test.md " ) . exists ( ) ,
" 4_merge file should exist "
) ;
2026-02-23 12:12:20 +00:00
// Result is either Ok (agent started) or Err (agent failed - acceptable in tests)
let _ = result ;
2026-02-20 17:36:35 +00:00
}
#[ tokio::test ]
async fn tool_merge_agent_work_returns_coherent_report ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
setup_git_repo_in ( tmp . path ( ) ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
// Try to merge a non-existent branch — should return a report (not panic)
let result = tool_merge_agent_work (
& json! ( { " story_id " : " 99_nonexistent " , " agent_name " : " coder-1 " } ) ,
& ctx ,
)
. await
. unwrap ( ) ;
let parsed : Value = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert_eq! ( parsed [ " story_id " ] , " 99_nonexistent " ) ;
assert_eq! ( parsed [ " agent_name " ] , " coder-1 " ) ;
assert! ( parsed . get ( " success " ) . is_some ( ) ) ;
assert! ( parsed . get ( " had_conflicts " ) . is_some ( ) ) ;
assert! ( parsed . get ( " gates_passed " ) . is_some ( ) ) ;
assert! ( parsed . get ( " gate_output " ) . is_some ( ) ) ;
assert! ( parsed . get ( " message " ) . is_some ( ) ) ;
}
2026-02-24 00:30:54 +00:00
// ── HTTP handler tests (TestClient) ───────────────────────────
fn test_mcp_app ( ctx : std ::sync ::Arc < AppContext > ) -> impl poem ::Endpoint {
use poem ::EndpointExt ;
poem ::Route ::new ( )
. at ( " /mcp " , poem ::post ( mcp_post_handler ) . get ( mcp_get_handler ) )
. data ( ctx )
}
async fn read_body_json ( resp : poem ::test ::TestResponse ) -> Value {
let body = resp . 0. into_body ( ) . into_string ( ) . await . unwrap ( ) ;
serde_json ::from_str ( & body ) . unwrap ( )
}
async fn post_json_mcp < E : poem ::Endpoint > (
cli : & poem ::test ::TestClient < E > ,
payload : & str ,
) -> Value {
let resp = cli
. post ( " /mcp " )
. header ( " content-type " , " application/json " )
. body ( payload . to_string ( ) )
. send ( )
. await ;
read_body_json ( resp ) . await
}
#[ tokio::test ]
async fn mcp_get_handler_returns_405 ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let resp = cli . get ( " /mcp " ) . send ( ) . await ;
assert_eq! ( resp . 0. status ( ) , poem ::http ::StatusCode ::METHOD_NOT_ALLOWED ) ;
}
#[ tokio::test ]
async fn mcp_post_invalid_content_type_returns_error ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let resp = cli
. post ( " /mcp " )
. header ( " content-type " , " text/plain " )
. body ( " {} " )
. send ( )
. await ;
let body = read_body_json ( resp ) . await ;
assert! ( body . get ( " error " ) . is_some ( ) , " expected error field: {body} " ) ;
}
#[ tokio::test ]
async fn mcp_post_invalid_json_returns_parse_error ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let resp = cli
. post ( " /mcp " )
. header ( " content-type " , " application/json " )
. body ( " not-valid-json " )
. send ( )
. await ;
let body = read_body_json ( resp ) . await ;
assert! ( body . get ( " error " ) . is_some ( ) , " expected error field: {body} " ) ;
}
#[ tokio::test ]
async fn mcp_post_wrong_jsonrpc_version_returns_error ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let body = post_json_mcp (
& cli ,
r # "{"jsonrpc":"1.0","id":1,"method":"initialize","params":{}}"# ,
)
. await ;
assert! (
body [ " error " ] [ " message " ] . as_str ( ) . unwrap_or ( " " ) . contains ( " version " ) ,
" expected version error: {body} "
) ;
}
#[ tokio::test ]
async fn mcp_post_notification_with_null_id_returns_accepted ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let resp = cli
. post ( " /mcp " )
. header ( " content-type " , " application/json " )
. body ( r # "{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"# )
. send ( )
. await ;
assert_eq! ( resp . 0. status ( ) , poem ::http ::StatusCode ::ACCEPTED ) ;
}
#[ tokio::test ]
async fn mcp_post_notification_with_explicit_null_id_returns_accepted ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let resp = cli
. post ( " /mcp " )
. header ( " content-type " , " application/json " )
. body (
r # "{"jsonrpc":"2.0","id":null,"method":"notifications/initialized","params":{}}"# ,
)
. send ( )
. await ;
assert_eq! ( resp . 0. status ( ) , poem ::http ::StatusCode ::ACCEPTED ) ;
}
#[ tokio::test ]
async fn mcp_post_missing_id_non_notification_returns_error ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let body = post_json_mcp (
& cli ,
r # "{"jsonrpc":"2.0","method":"initialize","params":{}}"# ,
)
. await ;
assert! ( body . get ( " error " ) . is_some ( ) , " expected error: {body} " ) ;
}
#[ tokio::test ]
async fn mcp_post_unknown_method_returns_error ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let body = post_json_mcp (
& cli ,
r # "{"jsonrpc":"2.0","id":1,"method":"bogus/method","params":{}}"# ,
)
. await ;
assert! (
body [ " error " ] [ " message " ] . as_str ( ) . unwrap_or ( " " ) . contains ( " Unknown method " ) ,
" expected unknown method error: {body} "
) ;
}
#[ tokio::test ]
async fn mcp_post_initialize_returns_capabilities ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let body = post_json_mcp (
& cli ,
r # "{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"# ,
)
. await ;
assert_eq! ( body [ " result " ] [ " protocolVersion " ] , " 2025-03-26 " ) ;
assert_eq! ( body [ " result " ] [ " serverInfo " ] [ " name " ] , " story-kit " ) ;
}
#[ tokio::test ]
async fn mcp_post_tools_list_returns_tools ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let body = post_json_mcp (
& cli ,
r # "{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"# ,
)
. await ;
assert! ( body [ " result " ] [ " tools " ] . is_array ( ) ) ;
}
#[ tokio::test ]
async fn mcp_post_sse_returns_event_stream_content_type ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let resp = cli
. post ( " /mcp " )
. header ( " content-type " , " application/json " )
. header ( " accept " , " text/event-stream " )
. body ( r # "{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"# )
. send ( )
. await ;
assert_eq! (
resp . 0. headers ( ) . get ( " content-type " ) . unwrap ( ) ,
" text/event-stream "
) ;
}
#[ tokio::test ]
async fn mcp_post_sse_get_agent_output_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let resp = cli
. post ( " /mcp " )
. header ( " content-type " , " application/json " )
. header ( " accept " , " text/event-stream " )
. body ( r # "{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{}}}"# )
. send ( )
. await ;
assert_eq! (
resp . 0. headers ( ) . get ( " content-type " ) . unwrap ( ) ,
" text/event-stream " ,
" expected SSE content-type "
) ;
}
#[ tokio::test ]
async fn mcp_post_sse_get_agent_output_missing_agent_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let resp = cli
. post ( " /mcp " )
. header ( " content-type " , " application/json " )
. header ( " accept " , " text/event-stream " )
. body ( r # "{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"1_test"}}}"# )
. send ( )
. await ;
assert_eq! (
resp . 0. headers ( ) . get ( " content-type " ) . unwrap ( ) ,
" text/event-stream " ,
" expected SSE content-type "
) ;
}
#[ tokio::test ]
async fn mcp_post_sse_get_agent_output_no_agent_returns_sse_error ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = std ::sync ::Arc ::new ( test_ctx ( tmp . path ( ) ) ) ;
let cli = poem ::test ::TestClient ::new ( test_mcp_app ( ctx ) ) ;
let resp = cli
. post ( " /mcp " )
. header ( " content-type " , " application/json " )
. header ( " accept " , " text/event-stream " )
. body ( r # "{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"99_nope","agent_name":"bot"}}}"# )
. send ( )
. await ;
assert_eq! (
resp . 0. headers ( ) . get ( " content-type " ) . unwrap ( ) ,
" text/event-stream "
) ;
let body = resp . 0. into_body ( ) . into_string ( ) . await . unwrap ( ) ;
assert! ( body . contains ( " data: " ) , " expected SSE data prefix: {body} " ) ;
}
// ── tool_get_server_logs tests ────────────────────────────────
#[ test ]
fn tool_get_server_logs_no_args_returns_string ( ) {
let result = tool_get_server_logs ( & json! ( { } ) ) . unwrap ( ) ;
// Returns recent log lines (possibly empty in tests) — just verify no panic
let _ = result ;
}
#[ test ]
fn tool_get_server_logs_with_filter_returns_matching_lines ( ) {
let result =
tool_get_server_logs ( & json! ( { " filter " : " xyz_unlikely_match_999 " } ) ) . unwrap ( ) ;
assert_eq! ( result , " " , " filter with no matches should return empty string " ) ;
}
#[ test ]
fn tool_get_server_logs_with_line_limit ( ) {
let result = tool_get_server_logs ( & json! ( { " lines " : 5 } ) ) . unwrap ( ) ;
assert! ( result . lines ( ) . count ( ) < = 5 ) ;
}
#[ test ]
fn tool_get_server_logs_max_cap_is_1000 ( ) {
// Lines > 1000 are capped — just verify it returns without error
let result = tool_get_server_logs ( & json! ( { " lines " : 9999 } ) ) . unwrap ( ) ;
let _ = result ;
}
// ── tool_list_worktrees tests ─────────────────────────────────
#[ 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 < Value > = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert! ( parsed . is_empty ( ) ) ;
}
// ── tool_accept_story tests ───────────────────────────────────
#[ test ]
fn tool_accept_story_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_accept_story ( & json! ( { } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ test ]
fn tool_accept_story_nonexistent_story_returns_error ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
setup_git_repo_in ( tmp . path ( ) ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
// No story file in current/ — should fail
let result = tool_accept_story ( & json! ( { " story_id " : " 99_nonexistent " } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
}
// ── tool_check_criterion tests ────────────────────────────────
#[ test ]
fn tool_check_criterion_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_check_criterion ( & json! ( { " criterion_index " : 0 } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ test ]
fn tool_check_criterion_missing_criterion_index ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_check_criterion ( & json! ( { " story_id " : " 1_test " } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " criterion_index " ) ) ;
}
#[ test ]
fn tool_check_criterion_marks_unchecked_item ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
setup_git_repo_in ( tmp . path ( ) ) ;
let current_dir = tmp . path ( ) . join ( " .story_kit " ) . join ( " work " ) . join ( " 2_current " ) ;
fs ::create_dir_all ( & current_dir ) . unwrap ( ) ;
fs ::write (
current_dir . join ( " 1_test.md " ) ,
" --- \n name: Test \n --- \n ## AC \n - [ ] First criterion \n - [x] Already done \n " ,
)
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " add " , " . " ] )
. current_dir ( tmp . path ( ) )
. output ( )
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " commit " , " -m " , " add story " ] )
. current_dir ( tmp . path ( ) )
. output ( )
. unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_check_criterion (
& json! ( { " story_id " : " 1_test " , " criterion_index " : 0 } ) ,
& ctx ,
) ;
assert! ( result . is_ok ( ) , " Expected ok: {result:?} " ) ;
assert! ( result . unwrap ( ) . contains ( " Criterion 0 checked " ) ) ;
}
// ── tool_get_agent_config tests ───────────────────────────────
#[ test ]
fn tool_get_agent_config_no_project_toml_returns_default_agent ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
// No project.toml → default config with one fallback agent
let result = tool_get_agent_config ( & ctx ) . unwrap ( ) ;
let parsed : Vec < Value > = serde_json ::from_str ( & result ) . unwrap ( ) ;
// Default config contains one agent entry with default values
assert_eq! ( parsed . len ( ) , 1 , " default config should have one fallback agent " ) ;
assert! ( parsed [ 0 ] . get ( " name " ) . is_some ( ) ) ;
assert! ( parsed [ 0 ] . get ( " role " ) . is_some ( ) ) ;
}
// ── tool_get_agent_output_poll tests ─────────────────────────
#[ tokio::test ]
async fn tool_get_agent_output_poll_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_get_agent_output_poll ( & json! ( { " agent_name " : " bot " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ tokio::test ]
async fn tool_get_agent_output_poll_missing_agent_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result =
tool_get_agent_output_poll ( & json! ( { " story_id " : " 1_test " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " agent_name " ) ) ;
}
#[ tokio::test ]
async fn tool_get_agent_output_poll_no_agent_falls_back_to_empty_log ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
// No agent registered, no log file → returns empty response from log fallback
let result = tool_get_agent_output_poll (
& json! ( { " story_id " : " 99_nope " , " agent_name " : " bot " } ) ,
& ctx ,
)
. await
. unwrap ( ) ;
let parsed : Value = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert_eq! ( parsed [ " done " ] , true ) ;
assert_eq! ( parsed [ " event_count " ] , 0 ) ;
assert! (
parsed [ " message " ] . as_str ( ) . unwrap_or ( " " ) . contains ( " No agent " ) ,
" expected 'No agent' message: {parsed} "
) ;
}
#[ tokio::test ]
async fn tool_get_agent_output_poll_with_running_agent_returns_empty_events ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
// Inject a running agent — no events broadcast yet
ctx . agents
. inject_test_agent ( " 10_story " , " worker " , crate ::agents ::AgentStatus ::Running ) ;
let result = tool_get_agent_output_poll (
& json! ( { " story_id " : " 10_story " , " agent_name " : " worker " } ) ,
& ctx ,
)
. await
. unwrap ( ) ;
let parsed : Value = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert_eq! ( parsed [ " done " ] , false ) ;
assert_eq! ( parsed [ " event_count " ] , 0 ) ;
assert! ( parsed [ " events " ] . is_array ( ) ) ;
}
// ── Missing-arg tests for async tools ────────────────────────
#[ tokio::test ]
async fn tool_stop_agent_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_stop_agent ( & json! ( { " agent_name " : " bot " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ tokio::test ]
async fn tool_stop_agent_missing_agent_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_stop_agent ( & json! ( { " story_id " : " 1_test " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " agent_name " ) ) ;
}
#[ tokio::test ]
async fn tool_start_agent_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_start_agent ( & json! ( { } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
2026-02-25 09:30:15 +00:00
#[ tokio::test ]
async fn tool_start_agent_no_agent_name_no_coder_returns_clear_error ( ) {
// Config has only a supervisor — start_agent without agent_name should
// refuse rather than silently assigning supervisor.
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let sk = tmp . path ( ) . join ( " .story_kit " ) ;
std ::fs ::create_dir_all ( & sk ) . unwrap ( ) ;
std ::fs ::write (
sk . join ( " project.toml " ) ,
r #"
[ [ agent ] ]
name = " supervisor "
stage = " other "
" #,
)
. unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_start_agent ( & json! ( { " story_id " : " 42_my_story " } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
let err = result . unwrap_err ( ) ;
assert! (
err . contains ( " coder " ) ,
" error should mention 'coder', got: {err} "
) ;
}
#[ tokio::test ]
async fn tool_start_agent_no_agent_name_picks_coder_not_supervisor ( ) {
// Config has supervisor first, then coder-1. Without agent_name the
// coder should be selected, not supervisor. The call will fail due to
// missing git repo / worktree, but the error must NOT be about
// "No coder agent configured".
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let sk = tmp . path ( ) . join ( " .story_kit " ) ;
std ::fs ::create_dir_all ( & sk ) . unwrap ( ) ;
std ::fs ::write (
sk . join ( " project.toml " ) ,
r #"
[ [ agent ] ]
name = " supervisor "
stage = " other "
[ [ agent ] ]
name = " coder-1 "
stage = " coder "
" #,
)
. unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_start_agent ( & json! ( { " story_id " : " 42_my_story " } ) , & ctx ) . await ;
// May succeed or fail for infrastructure reasons (no git repo), but
// must NOT fail with "No coder agent configured".
if let Err ( err ) = result {
assert! (
! err . contains ( " No coder agent configured " ) ,
" should not fail on agent selection, got: {err} "
) ;
// Should also not complain about supervisor being absent.
assert! (
! err . contains ( " supervisor " ) ,
" should not select supervisor, got: {err} "
) ;
}
}
2026-02-24 00:30:54 +00:00
#[ 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 " ) ) ;
}
#[ tokio::test ]
async fn tool_request_qa_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_request_qa ( & json! ( { } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
// ── parse_test_cases additional coverage ─────────────────────
#[ test ]
fn parse_test_cases_null_value_returns_empty ( ) {
let null_val = json! ( null ) ;
let result = parse_test_cases ( Some ( & null_val ) ) . unwrap ( ) ;
assert! ( result . is_empty ( ) ) ;
}
#[ test ]
fn parse_test_cases_non_array_returns_error ( ) {
let obj = json! ( { " invalid " : " input " } ) ;
let result = parse_test_cases ( Some ( & obj ) ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " Expected array " ) ) ;
}
#[ test ]
fn parse_test_cases_missing_name_returns_error ( ) {
let input = json! ( [ { " status " : " pass " } ] ) ;
let result = parse_test_cases ( Some ( & input ) ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " name " ) ) ;
}
#[ test ]
fn parse_test_cases_missing_status_returns_error ( ) {
let input = json! ( [ { " name " : " test1 " } ] ) ;
let result = parse_test_cases ( Some ( & input ) ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " status " ) ) ;
}
// ── json_rpc_error_response direct test ──────────────────────
#[ test ]
fn json_rpc_error_response_builds_json_response ( ) {
let resp = json_rpc_error_response ( Some ( json! ( 42 ) ) , - 32600 , " test error " . into ( ) ) ;
assert_eq! ( resp . status ( ) , poem ::http ::StatusCode ::OK ) ;
assert_eq! (
resp . headers ( ) . get ( " content-type " ) . unwrap ( ) ,
" application/json "
) ;
}
// ── request_qa in tools list ──────────────────────────────────
#[ test ]
fn request_qa_in_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 " ] = = " request_qa " ) ;
assert! ( tool . is_some ( ) , " request_qa missing from tools list " ) ;
let t = tool . unwrap ( ) ;
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 ( & " story_id " ) ) ;
// agent_name is optional
assert! ( ! req_names . contains ( & " agent_name " ) ) ;
}
// ── tool_validate_stories with file content ───────────────────
#[ test ]
fn tool_validate_stories_with_valid_story ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let current_dir = tmp . path ( ) . join ( " .story_kit " ) . join ( " work " ) . join ( " 2_current " ) ;
fs ::create_dir_all ( & current_dir ) . unwrap ( ) ;
fs ::write (
current_dir . join ( " 1_test.md " ) ,
" --- \n name: \" Valid Story \" \n --- \n ## AC \n - [ ] First \n " ,
)
. unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_validate_stories ( & ctx ) . unwrap ( ) ;
let parsed : Vec < Value > = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert_eq! ( parsed . len ( ) , 1 ) ;
assert_eq! ( parsed [ 0 ] [ " valid " ] , true ) ;
}
#[ test ]
fn tool_validate_stories_with_invalid_front_matter ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let current_dir = tmp . path ( ) . join ( " .story_kit " ) . join ( " work " ) . join ( " 2_current " ) ;
fs ::create_dir_all ( & current_dir ) . unwrap ( ) ;
fs ::write (
current_dir . join ( " 1_test.md " ) ,
" ## No front matter at all \n " ,
)
. unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_validate_stories ( & ctx ) . unwrap ( ) ;
let parsed : Vec < Value > = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert! ( ! parsed . is_empty ( ) ) ;
assert_eq! ( parsed [ 0 ] [ " valid " ] , false ) ;
}
// ── tool_record_tests and tool_ensure_acceptance edge cases ──
#[ test ]
fn tool_record_tests_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_record_tests (
& json! ( { " unit " : [ ] , " integration " : [ ] } ) ,
& ctx ,
) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ test ]
fn tool_record_tests_invalid_unit_type_returns_error ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_record_tests (
& json! ( {
" story_id " : " 1_test " ,
" unit " : " not_an_array " ,
" integration " : [ ]
} ) ,
& ctx ,
) ;
assert! ( result . is_err ( ) ) ;
}
#[ test ]
fn tool_ensure_acceptance_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_ensure_acceptance ( & json! ( { } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
2026-02-24 11:51:00 +00:00
#[ tokio::test ]
2026-02-24 14:06:00 +00:00
async fn tool_prompt_permission_approved_returns_updated_input_json ( ) {
2026-02-24 11:51:00 +00:00
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
// Spawn a task that immediately sends approval through the channel.
let perm_rx = ctx . perm_rx . clone ( ) ;
tokio ::spawn ( async move {
let mut rx = perm_rx . lock ( ) . await ;
if let Some ( forward ) = rx . recv ( ) . await {
let _ = forward . response_tx . send ( true ) ;
}
} ) ;
let result = tool_prompt_permission (
& json! ( { " tool_name " : " Bash " , " input " : { " command " : " echo hello " } } ) ,
& ctx ,
)
. await
. expect ( " should succeed on approval " ) ;
let parsed : Value = serde_json ::from_str ( & result ) . expect ( " result should be valid JSON " ) ;
2026-02-24 14:06:00 +00:00
assert_eq! (
parsed [ " updatedInput " ] [ " command " ] , " echo hello " ,
" approved must return updatedInput with original tool input "
) ;
2026-02-24 11:51:00 +00:00
}
#[ tokio::test ]
async fn tool_prompt_permission_denied_returns_deny_json ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
// Spawn a task that immediately sends denial through the channel.
let perm_rx = ctx . perm_rx . clone ( ) ;
tokio ::spawn ( async move {
let mut rx = perm_rx . lock ( ) . await ;
if let Some ( forward ) = rx . recv ( ) . await {
let _ = forward . response_tx . send ( false ) ;
}
} ) ;
let result = tool_prompt_permission (
& json! ( { " tool_name " : " Write " , " input " : { } } ) ,
& ctx ,
)
. await
. expect ( " denial must return Ok, not Err " ) ;
let parsed : Value = serde_json ::from_str ( & result ) . expect ( " result should be valid JSON " ) ;
assert_eq! ( parsed [ " behavior " ] , " deny " , " denied must return behavior:deny " ) ;
assert! ( parsed [ " message " ] . is_string ( ) , " deny must include a message " ) ;
}
2026-02-25 09:50:31 +00:00
// ── record_tests / ensure_acceptance persistence tests ───────
#[ test ]
fn record_tests_persists_to_story_file ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let current = tmp . path ( ) . join ( " .story_kit/work/2_current " ) ;
fs ::create_dir_all ( & current ) . unwrap ( ) ;
fs ::write ( current . join ( " 1_story_persist.md " ) , " --- \n name: Persist \n --- \n # Story \n " ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
tool_record_tests (
& json! ( {
" story_id " : " 1_story_persist " ,
" unit " : [ { " name " : " u1 " , " status " : " pass " } ] ,
" integration " : [ ]
} ) ,
& ctx ,
)
. unwrap ( ) ;
let contents = fs ::read_to_string ( current . join ( " 1_story_persist.md " ) ) . unwrap ( ) ;
assert! ( contents . contains ( " ## Test Results " ) , " file should have Test Results section " ) ;
assert! ( contents . contains ( " story-kit-test-results: " ) , " file should have JSON marker " ) ;
assert! ( contents . contains ( " u1 " ) , " file should contain test name " ) ;
}
#[ test ]
fn ensure_acceptance_reads_from_file_when_not_in_memory ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let current = tmp . path ( ) . join ( " .story_kit/work/2_current " ) ;
fs ::create_dir_all ( & current ) . unwrap ( ) ;
// Write a story file with a pre-populated Test Results section (simulating a restart)
let story_content = " --- \n name: Persist \n --- \n # Story \n \n ## Test Results \n \n <!-- story-kit-test-results: { \" unit \" :[{ \" name \" : \" u1 \" , \" status \" : \" pass \" , \" details \" :null}], \" integration \" :[{ \" name \" : \" i1 \" , \" status \" : \" pass \" , \" details \" :null}]} --> \n " ;
fs ::write ( current . join ( " 2_story_file_only.md " ) , story_content ) . unwrap ( ) ;
// Use a fresh context (empty in-memory state, simulating a restart)
let ctx = test_ctx ( tmp . path ( ) ) ;
// ensure_acceptance should read from file and succeed
let result = tool_ensure_acceptance ( & json! ( { " story_id " : " 2_story_file_only " } ) , & ctx ) ;
assert! ( result . is_ok ( ) , " should accept based on file data, got: {:?} " , result ) ;
assert! ( result . unwrap ( ) . contains ( " All gates pass " ) ) ;
}
#[ test ]
fn ensure_acceptance_file_with_failures_still_blocks ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let current = tmp . path ( ) . join ( " .story_kit/work/2_current " ) ;
fs ::create_dir_all ( & current ) . unwrap ( ) ;
let story_content = " --- \n name: Fail \n --- \n # Story \n \n ## Test Results \n \n <!-- story-kit-test-results: { \" unit \" :[{ \" name \" : \" u1 \" , \" status \" : \" fail \" , \" details \" : \" error \" }], \" integration \" :[]} --> \n " ;
fs ::write ( current . join ( " 3_story_fail.md " ) , story_content ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_ensure_acceptance ( & json! ( { " story_id " : " 3_story_fail " } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " blocked " ) ) ;
}
#[ test ]
fn read_coverage_percent_from_json_parses_llvm_cov_format ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let cov_dir = tmp . path ( ) . join ( " .story_kit/coverage " ) ;
fs ::create_dir_all ( & cov_dir ) . unwrap ( ) ;
let json_content = r # "{"data":[{"totals":{"lines":{"count":100,"covered":78,"percent":78.0}}}]}"# ;
fs ::write ( cov_dir . join ( " server.json " ) , json_content ) . unwrap ( ) ;
let pct = read_coverage_percent_from_json ( tmp . path ( ) ) ;
assert_eq! ( pct , Some ( 78.0 ) ) ;
}
#[ test ]
fn read_coverage_percent_from_json_returns_none_when_absent ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let pct = read_coverage_percent_from_json ( tmp . path ( ) ) ;
assert! ( pct . is_none ( ) ) ;
}
Accept spike 2: MCP HTTP endpoint for workflow and agent tools
Adds POST /mcp endpoint speaking MCP Streamable HTTP (JSON-RPC 2.0)
with 12 tools for workflow management and agent orchestration.
Supports both JSON and SSE response modes. Includes real-time agent
output streaming over SSE, Content-Type validation, and 15 integration
tests (134 total).
Tools: create_story, validate_stories, list_upcoming, get_story_todos,
record_tests, ensure_acceptance, start_agent, stop_agent, list_agents,
get_agent_config, reload_agent_config, get_agent_output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:34:03 +00:00
}