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:
@@ -101,7 +101,7 @@ struct TodoListResponse {
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct UpcomingStory {
|
||||
pub struct UpcomingStory {
|
||||
pub story_id: String,
|
||||
pub name: Option<String>,
|
||||
pub error: Option<String>,
|
||||
@@ -125,7 +125,7 @@ struct CreateStoryResponse {
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct StoryValidationResult {
|
||||
pub struct StoryValidationResult {
|
||||
pub story_id: String,
|
||||
pub valid: bool,
|
||||
pub error: Option<String>,
|
||||
@@ -136,7 +136,7 @@ struct ValidateStoriesResponse {
|
||||
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 upcoming_dir = root.join(".story_kit").join("stories").join("upcoming");
|
||||
|
||||
@@ -556,61 +556,13 @@ impl WorkflowApi {
|
||||
payload: Json<CreateStoryPayload>,
|
||||
) -> OpenApiResult<Json<CreateStoryResponse>> {
|
||||
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<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 {
|
||||
let slug: String = name
|
||||
.chars()
|
||||
@@ -704,7 +721,7 @@ fn next_story_number(root: &std::path::Path) -> Result<u32, String> {
|
||||
Ok(max_num + 1)
|
||||
}
|
||||
|
||||
fn validate_story_dirs(
|
||||
pub fn validate_story_dirs(
|
||||
root: &std::path::Path,
|
||||
) -> Result<Vec<StoryValidationResult>, String> {
|
||||
let base = root.join(".story_kit").join("stories");
|
||||
|
||||
Reference in New Issue
Block a user