From 45f1234a063f628587ff4f194b526c0923deb67a Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Feb 2026 19:34:03 +0000 Subject: [PATCH] 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 --- .mcp.json | 8 + .story_kit/README.md | 6 + .../archive/spike-2-mcp-workflow-tools.md | 115 ++ server/src/http/mcp.rs | 1053 +++++++++++++++++ server/src/http/mod.rs | 7 +- server/src/http/workflow.rs | 135 ++- 6 files changed, 1264 insertions(+), 60 deletions(-) create mode 100644 .mcp.json create mode 100644 .story_kit/spikes/archive/spike-2-mcp-workflow-tools.md create mode 100644 server/src/http/mcp.rs diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..a36a88a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "story-kit": { + "type": "http", + "url": "http://localhost:3001/mcp" + } + } +} diff --git a/.story_kit/README.md b/.story_kit/README.md index 8a4be95..61e8eb6 100644 --- a/.story_kit/README.md +++ b/.story_kit/README.md @@ -17,6 +17,12 @@ Instead of ephemeral chat prompts ("Fix this", "Add that"), we work through pers --- +## 1.5 MCP Tools + +Agents have programmatic access to the workflow via MCP tools served at `POST /mcp`. The project `.mcp.json` registers this endpoint automatically so Claude Code sessions and spawned agents can call tools like `create_story`, `validate_stories`, `list_upcoming`, `get_story_todos`, `record_tests`, and `ensure_acceptance` without parsing English instructions. + +--- + ## 2. Directory Structure When initializing a new project under this workflow, create the following structure immediately: diff --git a/.story_kit/spikes/archive/spike-2-mcp-workflow-tools.md b/.story_kit/spikes/archive/spike-2-mcp-workflow-tools.md new file mode 100644 index 0000000..14786dc --- /dev/null +++ b/.story_kit/spikes/archive/spike-2-mcp-workflow-tools.md @@ -0,0 +1,115 @@ +--- +name: MCP Server for Workflow API +--- + +# Spike 1: MCP Server for Workflow API + +## Question + +Can we expose the Story Kit workflow API as MCP tools so that agents call enforced endpoints instead of manipulating files directly? + +## Hypothesis + +A thin stdio MCP server that proxies to the existing Rust HTTP API will let Claude Code agents use `create_story`, `validate_stories`, `record_tests`, and `ensure_acceptance` as native tools — with zero changes to the existing server. + +## Timebox + +2 hours + +## Investigation Plan + +1. Understand the MCP stdio protocol (JSON-RPC over stdin/stdout) +2. Identify which workflow endpoints should become MCP tools +3. Determine the best language/approach for the MCP server (Rust binary vs Node script vs Rust integrated into existing server) +4. Prototype a minimal MCP server with one tool (`create_story`) and test it with `claude mcp add` +5. Verify spawned agents (via `claude -p`) inherit MCP tools +6. Evaluate whether we can restrict agents from writing to `.story_kit/stories/` directly + +## Findings + +### 1. MCP stdio protocol is simple +JSON-RPC 2.0 over stdin/stdout. Three-phase: initialize handshake → tools/list → tools/call. A minimal server needs to handle ~3 message types. No HTTP, no sockets. + +### 2. The `rmcp` Rust crate makes this trivial +The official Rust SDK (`rmcp` 0.3) provides `#[tool]` and `#[tool_router]` macros that eliminate boilerplate. A tool is just an async function with typed parameters: + +```rust +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CreateStoryRequest { + #[schemars(description = "Human-readable story name")] + pub name: String, + #[schemars(description = "User story text")] + pub user_story: Option, + #[schemars(description = "List of acceptance criteria")] + pub acceptance_criteria: Option>, +} + +#[tool(description = "Create a new story with correct front matter in upcoming/")] +async fn create_story( + &self, + Parameters(req): Parameters, +) -> Result { + let resp = self.client.post(&format!("{}/workflow/stories/create", self.api_url)) + .json(&req).send().await...; + Ok(CallToolResult::success(vec![Content::text(resp.story_id)])) +} +``` + +Dependencies needed: `rmcp` (server, transport-io), `schemars`, `reqwest`, `tokio`, `serde`. We already use most of these in the existing server. + +### 3. Architecture: separate binary, same workspace +Best approach is a new binary crate (`story-kit-mcp`) in the workspace that: +- Reads the API URL from env or CLI arg (default `http://localhost:3000/api`) +- Proxies each MCP tool call to the corresponding HTTP endpoint +- Returns the API response as tool output + +This keeps the MCP layer thin and the enforcement logic in the existing server. No code duplication — the MCP binary is just a translation layer. + +### 4. Which endpoints become tools + +| MCP Tool | HTTP Endpoint | Why | +|---|---|---| +| `create_story` | POST /workflow/stories/create | Enforce front matter | +| `validate_stories` | GET /workflow/stories/validate | Check all stories | +| `record_tests` | POST /workflow/tests/record | Record test results | +| `ensure_acceptance` | POST /workflow/acceptance/ensure | Gate story acceptance | +| `collect_coverage` | POST /workflow/coverage/collect | Run + record coverage | +| `get_story_todos` | GET /workflow/todos | See remaining work | +| `list_upcoming` | GET /workflow/upcoming | See backlog | + +### 5. Configuration via `.mcp.json` (project-scoped) +```json +{ + "mcpServers": { + "story-kit": { + "type": "stdio", + "command": "./target/release/story-kit-mcp", + "args": ["--api-url", "http://localhost:${STORYKIT_PORT:-3000}/api"] + } + } +} +``` + +This gets checked into the repo. Every Claude Code session and every spawned agent inherits it automatically. + +### 6. Agent restrictions +Claude Code's `.claude/settings.local.json` can restrict which tools agents have access to. We could: +- Give agents the MCP tools (`story-kit:create_story`, etc.) +- Restrict or remove Write access to `.story_kit/stories/` paths +- This forces agents through the API for all workflow actions + +Caveat: tool restrictions are advisory in `settings.local.json` — agents with Bash access could still `echo > file`. Full enforcement requires removing Bash or scoping it (which is story 35's problem). + +### 7. Effort estimate +The MCP binary itself is ~200-300 lines of Rust. One afternoon of work. Most of the time would be testing the integration with agent spawning and worktrees. + +## Recommendation + +**Proceed with a story.** The spike confirms this is straightforward and high-value. The `rmcp` crate handles the protocol complexity, and our existing HTTP API already does the enforcement. The MCP server is just plumbing. + +Suggested story scope: +1. New `story-kit-mcp` binary crate in the workspace +2. Expose the 7 tools listed above +3. Add `.mcp.json` to the project +4. Update agent spawn to ensure MCP tools are available in worktrees +5. Test: spawn agent, verify it uses MCP tools instead of file writes diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs new file mode 100644 index 0000000..742a712 --- /dev/null +++ b/server/src/http/mcp.rs @@ -0,0 +1,1053 @@ +use crate::config::ProjectConfig; +use crate::http::context::AppContext; +use crate::http::workflow::{create_story_file, load_upcoming_stories, validate_story_dirs}; +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, + method: String, + #[serde(default)] + params: Value, +} + +#[derive(Serialize)] +struct JsonRpcResponse { + jsonrpc: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +struct JsonRpcError { + code: i64, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, +} + +impl JsonRpcResponse { + fn success(id: Option, result: Value) -> Self { + Self { + jsonrpc: "2.0", + id, + result: Some(result), + error: None, + } + } + + fn error(id: Option, 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>) -> 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, 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, + 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(¬ification) { + 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(¬ification) { + 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, 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) -> 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" + } + }, + "required": ["name"] + } + }, + { + "name": "validate_stories", + "description": "Validate front matter on all current and upcoming story files.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "list_upcoming", + "description": "List all upcoming stories with their names and any parsing errors.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "get_story_todos", + "description": "Get unchecked acceptance criteria (todos) for a story file in current/.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + } + }, + "required": ["story_id"] + } + }, + { + "name": "record_tests", + "description": "Record test results for a story. Only one failing test at a time is allowed.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier" + }, + "unit": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "status": { "type": "string", "enum": ["pass", "fail"] }, + "details": { "type": "string" } + }, + "required": ["name", "status"] + }, + "description": "Unit test results" + }, + "integration": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "status": { "type": "string", "enum": ["pass", "fail"] }, + "details": { "type": "string" } + }, + "required": ["name", "status"] + }, + "description": "Integration test results" + } + }, + "required": ["story_id", "unit", "integration"] + } + }, + { + "name": "ensure_acceptance", + "description": "Check whether a story can be accepted. Returns acceptance status with reasons if blocked.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier" + } + }, + "required": ["story_id"] + } + }, + { + "name": "start_agent", + "description": "Start an agent for a story. Creates a worktree, runs setup, and spawns the agent process.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (e.g. '28_my_story')" + }, + "agent_name": { + "type": "string", + "description": "Agent name from project.toml config. If omitted, uses the first configured agent." + } + }, + "required": ["story_id"] + } + }, + { + "name": "stop_agent", + "description": "Stop a running agent and clean up its worktree.", + "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"] + } + } + ] + }), + ) +} + +// ── Tool dispatch ───────────────────────────────────────────────── + +async fn handle_tools_call( + id: Option, + params: &Value, + ctx: &AppContext, +) -> JsonRpcResponse { + let tool_name = params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let args = params.get("arguments").cloned().unwrap_or(json!({})); + + let result = match tool_name { + // Workflow tools + "create_story" => 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), + "get_agent_output" => Err("get_agent_output requires Accept: text/event-stream for SSE streaming".into()), + _ => Err(format!("Unknown tool: {tool_name}")), + }; + + match result { + Ok(content) => JsonRpcResponse::success( + id, + json!({ + "content": [{ "type": "text", "text": content }] + }), + ), + Err(msg) => JsonRpcResponse::success( + id, + json!({ + "content": [{ "type": "text", "text": msg }], + "isError": true + }), + ), + } +} + +// ── Tool implementations ────────────────────────────────────────── + +fn tool_create_story(args: &Value, ctx: &AppContext) -> Result { + 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> = args + .get("acceptance_criteria") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + let root = ctx.state.get_project_root()?; + let story_id = create_story_file( + &root, + name, + user_story, + acceptance_criteria.as_deref(), + )?; + + Ok(format!("Created story: {story_id}")) +} + +fn tool_validate_stories(ctx: &AppContext) -> Result { + 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::>())) + .map_err(|e| format!("Serialization error: {e}")) +} + +fn tool_list_upcoming(ctx: &AppContext) -> Result { + 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::>())) + .map_err(|e| format!("Serialization error: {e}")) +} + +fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let root = ctx.state.get_project_root()?; + let current_dir = root.join(".story_kit").join("stories").join("current"); + 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 { + 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)?; + + Ok("Test results recorded.".to_string()) +} + +fn tool_ensure_acceptance(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let workflow = ctx + .workflow + .lock() + .map_err(|e| format!("Lock error: {e}"))?; + + let empty_results = Default::default(); + let results = workflow.results.get(story_id).unwrap_or(&empty_results); + 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 { + 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 + .start_agent(&project_root, story_id, agent_name) + .await?; + + 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}")) +} + +async fn tool_stop_agent(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let 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 { + let agents = ctx.agents.list_agents()?; + serde_json::to_string_pretty(&json!(agents + .iter() + .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::>())) + .map_err(|e| format!("Serialization error: {e}")) +} + +fn tool_get_agent_config(ctx: &AppContext) -> Result { + 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::>())) + .map_err(|e| format!("Serialization error: {e}")) +} + +// ── Helpers ─────────────────────────────────────────────────────── + +fn parse_test_cases(value: Option<&Value>) -> Result, 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() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::context::AppContext; + + // ── 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")); + assert_eq!(tools.len(), 12); + } + + #[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 = serde_json::from_str(&result).unwrap(); + assert!(parsed.is_empty()); + } + + #[test] + fn tool_create_story_and_list_upcoming() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + + // Create a story + let result = tool_create_story( + &json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"]}), + &ctx, + ) + .unwrap(); + assert!(result.contains("Created story:")); + + // List should return it + let list = tool_list_upcoming(&ctx).unwrap(); + let parsed: Vec = 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(); + let current_dir = tmp.path().join(".story_kit").join("stories").join("current"); + fs::create_dir_all(¤t_dir).unwrap(); + fs::write( + current_dir.join("1_test.md"), + "---\nname: Test\ntest_plan: approved\n---\n## AC\n- [ ] First\n- [x] Done\n- [ ] Second\n", + ) + .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 = 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" + ); + } +} diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 46aeef0..0dfa1c8 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -6,6 +6,7 @@ pub mod chat; pub mod context; pub mod health; pub mod io; +pub mod mcp; pub mod model; pub mod workflow; @@ -19,7 +20,7 @@ use context::AppContext; use io::IoApi; use model::ModelApi; use poem::EndpointExt; -use poem::{Route, get}; +use poem::{Route, get, post}; use poem_openapi::OpenApiService; use project::ProjectApi; use std::sync::Arc; @@ -38,6 +39,10 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint { "/agents/:story_id/:agent_name/stream", get(agents_sse::agent_stream), ) + .at( + "/mcp", + post(mcp::mcp_post_handler).get(mcp::mcp_get_handler), + ) .at("/health", get(health::health)) .at("/assets/*path", get(assets::embedded_asset)) .at("/", get(assets::embedded_index)) diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index 93712bc..85a80ba 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -101,7 +101,7 @@ struct TodoListResponse { } #[derive(Object)] -struct UpcomingStory { +pub struct UpcomingStory { pub story_id: String, pub name: Option, pub error: Option, @@ -125,7 +125,7 @@ struct CreateStoryResponse { } #[derive(Object)] -struct StoryValidationResult { +pub struct StoryValidationResult { pub story_id: String, pub valid: bool, pub error: Option, @@ -136,7 +136,7 @@ struct ValidateStoriesResponse { pub stories: Vec, } -fn load_upcoming_stories(ctx: &AppContext) -> Result, String> { +pub fn load_upcoming_stories(ctx: &AppContext) -> Result, String> { let root = ctx.state.get_project_root()?; let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming"); @@ -556,61 +556,13 @@ impl WorkflowApi { payload: Json, ) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; - let story_number = next_story_number(&root).map_err(bad_request)?; - let slug = slugify_name(&payload.0.name); - - if slug.is_empty() { - return Err(bad_request("Name must contain at least one alphanumeric character.".to_string())); - } - - let filename = format!("{story_number}_{slug}.md"); - let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming"); - fs::create_dir_all(&upcoming_dir) - .map_err(|e| bad_request(format!("Failed to create upcoming directory: {e}")))?; - - let filepath = upcoming_dir.join(&filename); - if filepath.exists() { - return Err(bad_request(format!("Story file already exists: {filename}"))); - } - - let story_id = filepath - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or_default() - .to_string(); - - // Build file content - let mut content = String::new(); - content.push_str("---\n"); - content.push_str(&format!("name: {}\n", payload.0.name)); - content.push_str("test_plan: pending\n"); - content.push_str("---\n\n"); - content.push_str(&format!("# Story {story_number}: {}\n\n", payload.0.name)); - - content.push_str("## User Story\n\n"); - if let Some(ref us) = payload.0.user_story { - content.push_str(us); - content.push('\n'); - } else { - content.push_str("As a ..., I want ..., so that ...\n"); - } - content.push('\n'); - - content.push_str("## Acceptance Criteria\n\n"); - if let Some(ref criteria) = payload.0.acceptance_criteria { - for criterion in criteria { - content.push_str(&format!("- [ ] {criterion}\n")); - } - } else { - content.push_str("- [ ] TODO\n"); - } - content.push('\n'); - - content.push_str("## Out of Scope\n\n"); - content.push_str("- TBD\n"); - - fs::write(&filepath, &content) - .map_err(|e| bad_request(format!("Failed to write story file: {e}")))?; + let story_id = create_story_file( + &root, + &payload.0.name, + payload.0.user_story.as_deref(), + payload.0.acceptance_criteria.as_deref(), + ) + .map_err(bad_request)?; Ok(Json(CreateStoryResponse { story_id })) } @@ -644,6 +596,71 @@ impl WorkflowApi { } } +/// Shared create-story logic used by both the OpenApi and MCP handlers. +pub fn create_story_file( + root: &std::path::Path, + name: &str, + user_story: Option<&str>, + acceptance_criteria: Option<&[String]>, +) -> Result { + let story_number = next_story_number(root)?; + let slug = slugify_name(name); + + if slug.is_empty() { + return Err("Name must contain at least one alphanumeric character.".to_string()); + } + + let filename = format!("{story_number}_{slug}.md"); + let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming"); + fs::create_dir_all(&upcoming_dir) + .map_err(|e| format!("Failed to create upcoming directory: {e}"))?; + + let filepath = upcoming_dir.join(&filename); + if filepath.exists() { + return Err(format!("Story file already exists: {filename}")); + } + + let story_id = filepath + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string(); + + let mut content = String::new(); + content.push_str("---\n"); + content.push_str(&format!("name: {name}\n")); + content.push_str("test_plan: pending\n"); + content.push_str("---\n\n"); + content.push_str(&format!("# Story {story_number}: {name}\n\n")); + + content.push_str("## User Story\n\n"); + if let Some(us) = user_story { + content.push_str(us); + content.push('\n'); + } else { + content.push_str("As a ..., I want ..., so that ...\n"); + } + content.push('\n'); + + content.push_str("## Acceptance Criteria\n\n"); + if let Some(criteria) = acceptance_criteria { + for criterion in criteria { + content.push_str(&format!("- [ ] {criterion}\n")); + } + } else { + content.push_str("- [ ] TODO\n"); + } + content.push('\n'); + + content.push_str("## Out of Scope\n\n"); + content.push_str("- TBD\n"); + + fs::write(&filepath, &content) + .map_err(|e| format!("Failed to write story file: {e}"))?; + + Ok(story_id) +} + fn slugify_name(name: &str) -> String { let slug: String = name .chars() @@ -704,7 +721,7 @@ fn next_story_number(root: &std::path::Path) -> Result { Ok(max_num + 1) } -fn validate_story_dirs( +pub fn validate_story_dirs( root: &std::path::Path, ) -> Result, String> { let base = root.join(".story_kit").join("stories");