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>
This commit is contained in:
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"story-kit": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://localhost:3001/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
## 2. Directory Structure
|
||||||
|
|
||||||
When initializing a new project under this workflow, create the following structure immediately:
|
When initializing a new project under this workflow, create the following structure immediately:
|
||||||
|
|||||||
115
.story_kit/spikes/archive/spike-2-mcp-workflow-tools.md
Normal file
115
.story_kit/spikes/archive/spike-2-mcp-workflow-tools.md
Normal file
@@ -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<String>,
|
||||||
|
#[schemars(description = "List of acceptance criteria")]
|
||||||
|
pub acceptance_criteria: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(description = "Create a new story with correct front matter in upcoming/")]
|
||||||
|
async fn create_story(
|
||||||
|
&self,
|
||||||
|
Parameters(req): Parameters<CreateStoryRequest>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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
|
||||||
1053
server/src/http/mcp.rs
Normal file
1053
server/src/http/mcp.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ pub mod chat;
|
|||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod io;
|
pub mod io;
|
||||||
|
pub mod mcp;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ use context::AppContext;
|
|||||||
use io::IoApi;
|
use io::IoApi;
|
||||||
use model::ModelApi;
|
use model::ModelApi;
|
||||||
use poem::EndpointExt;
|
use poem::EndpointExt;
|
||||||
use poem::{Route, get};
|
use poem::{Route, get, post};
|
||||||
use poem_openapi::OpenApiService;
|
use poem_openapi::OpenApiService;
|
||||||
use project::ProjectApi;
|
use project::ProjectApi;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -38,6 +39,10 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
|
|||||||
"/agents/:story_id/:agent_name/stream",
|
"/agents/:story_id/:agent_name/stream",
|
||||||
get(agents_sse::agent_stream),
|
get(agents_sse::agent_stream),
|
||||||
)
|
)
|
||||||
|
.at(
|
||||||
|
"/mcp",
|
||||||
|
post(mcp::mcp_post_handler).get(mcp::mcp_get_handler),
|
||||||
|
)
|
||||||
.at("/health", get(health::health))
|
.at("/health", get(health::health))
|
||||||
.at("/assets/*path", get(assets::embedded_asset))
|
.at("/assets/*path", get(assets::embedded_asset))
|
||||||
.at("/", get(assets::embedded_index))
|
.at("/", get(assets::embedded_index))
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ struct TodoListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Object)]
|
#[derive(Object)]
|
||||||
struct UpcomingStory {
|
pub struct UpcomingStory {
|
||||||
pub story_id: String,
|
pub story_id: String,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
@@ -125,7 +125,7 @@ struct CreateStoryResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Object)]
|
#[derive(Object)]
|
||||||
struct StoryValidationResult {
|
pub struct StoryValidationResult {
|
||||||
pub story_id: String,
|
pub story_id: String,
|
||||||
pub valid: bool,
|
pub valid: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
@@ -136,7 +136,7 @@ struct ValidateStoriesResponse {
|
|||||||
pub stories: Vec<StoryValidationResult>,
|
pub stories: Vec<StoryValidationResult>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
|
pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
|
||||||
let root = ctx.state.get_project_root()?;
|
let root = ctx.state.get_project_root()?;
|
||||||
let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming");
|
let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming");
|
||||||
|
|
||||||
@@ -556,61 +556,13 @@ impl WorkflowApi {
|
|||||||
payload: Json<CreateStoryPayload>,
|
payload: Json<CreateStoryPayload>,
|
||||||
) -> OpenApiResult<Json<CreateStoryResponse>> {
|
) -> OpenApiResult<Json<CreateStoryResponse>> {
|
||||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
let story_number = next_story_number(&root).map_err(bad_request)?;
|
let story_id = create_story_file(
|
||||||
let slug = slugify_name(&payload.0.name);
|
&root,
|
||||||
|
&payload.0.name,
|
||||||
if slug.is_empty() {
|
payload.0.user_story.as_deref(),
|
||||||
return Err(bad_request("Name must contain at least one alphanumeric character.".to_string()));
|
payload.0.acceptance_criteria.as_deref(),
|
||||||
}
|
)
|
||||||
|
.map_err(bad_request)?;
|
||||||
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}")))?;
|
|
||||||
|
|
||||||
Ok(Json(CreateStoryResponse { story_id }))
|
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<String, String> {
|
||||||
|
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 {
|
fn slugify_name(name: &str) -> String {
|
||||||
let slug: String = name
|
let slug: String = name
|
||||||
.chars()
|
.chars()
|
||||||
@@ -704,7 +721,7 @@ fn next_story_number(root: &std::path::Path) -> Result<u32, String> {
|
|||||||
Ok(max_num + 1)
|
Ok(max_num + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_story_dirs(
|
pub fn validate_story_dirs(
|
||||||
root: &std::path::Path,
|
root: &std::path::Path,
|
||||||
) -> Result<Vec<StoryValidationResult>, String> {
|
) -> Result<Vec<StoryValidationResult>, String> {
|
||||||
let base = root.join(".story_kit").join("stories");
|
let base = root.join(".story_kit").join("stories");
|
||||||
|
|||||||
Reference in New Issue
Block a user