diff --git a/.story_kit/stories/archived/40_mcp_server_obeys_storykit_port.md b/.story_kit/stories/archived/40_mcp_server_obeys_storykit_port.md new file mode 100644 index 0000000..bb7ced3 --- /dev/null +++ b/.story_kit/stories/archived/40_mcp_server_obeys_storykit_port.md @@ -0,0 +1,14 @@ +--- +name: MCP Server Obeys STORYKIT_PORT +test_plan: approved +--- + +## User Story + +As a developer running the server on a non-default port, I want agent worktrees to automatically discover the correct MCP server URL, so that spawned agents can use MCP tools without manual .mcp.json edits. + +## Acceptance Criteria + +- [x] Agent worktrees inherit the correct port from the running server (via STORYKIT_PORT env var or default 3001) +- [x] The .mcp.json in agent worktrees points to the actual server port, not a hardcoded value +- [x] Existing behaviour (default port 3001) continues to work when STORYKIT_PORT is not set diff --git a/server/src/agents.rs b/server/src/agents.rs index 23bce61..bd821b0 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -111,12 +111,14 @@ fn agent_info_from_entry(story_id: &str, agent: &StoryAgent) -> AgentInfo { /// Manages concurrent story agents, each in its own worktree. pub struct AgentPool { agents: Arc>>, + port: u16, } impl AgentPool { - pub fn new() -> Self { + pub fn new(port: u16) -> Self { Self { agents: Arc::new(Mutex::new(HashMap::new())), + port, } } @@ -188,7 +190,7 @@ impl AgentPool { }); // Create worktree - let wt_info = worktree::create_worktree(project_root, story_id, &config).await?; + let wt_info = worktree::create_worktree(project_root, story_id, &config, self.port).await?; // Update with worktree info { @@ -696,7 +698,7 @@ mod tests { #[tokio::test] async fn wait_for_agent_returns_immediately_if_completed() { - let pool = AgentPool::new(); + let pool = AgentPool::new(3001); pool.inject_test_agent("s1", "bot", AgentStatus::Completed); let info = pool.wait_for_agent("s1", "bot", 1000).await.unwrap(); @@ -707,7 +709,7 @@ mod tests { #[tokio::test] async fn wait_for_agent_returns_immediately_if_failed() { - let pool = AgentPool::new(); + let pool = AgentPool::new(3001); pool.inject_test_agent("s2", "bot", AgentStatus::Failed); let info = pool.wait_for_agent("s2", "bot", 1000).await.unwrap(); @@ -716,7 +718,7 @@ mod tests { #[tokio::test] async fn wait_for_agent_completes_on_done_event() { - let pool = AgentPool::new(); + let pool = AgentPool::new(3001); let tx = pool.inject_test_agent("s3", "bot", AgentStatus::Running); // Send Done event after a short delay @@ -741,7 +743,7 @@ mod tests { #[tokio::test] async fn wait_for_agent_times_out() { - let pool = AgentPool::new(); + let pool = AgentPool::new(3001); pool.inject_test_agent("s4", "bot", AgentStatus::Running); let result = pool.wait_for_agent("s4", "bot", 50).await; @@ -752,14 +754,14 @@ mod tests { #[tokio::test] async fn wait_for_agent_errors_for_nonexistent() { - let pool = AgentPool::new(); + let pool = AgentPool::new(3001); let result = pool.wait_for_agent("no_story", "no_bot", 100).await; assert!(result.is_err()); } #[tokio::test] async fn wait_for_agent_completes_on_stopped_status_event() { - let pool = AgentPool::new(); + let pool = AgentPool::new(3001); let tx = pool.inject_test_agent("s5", "bot", AgentStatus::Running); let tx_clone = tx.clone(); diff --git a/server/src/http/context.rs b/server/src/http/context.rs index 069269d..26d805b 100644 --- a/server/src/http/context.rs +++ b/server/src/http/context.rs @@ -23,7 +23,7 @@ impl AppContext { state: Arc::new(state), store: Arc::new(JsonFileStore::new(store_path).unwrap()), workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())), - agents: Arc::new(AgentPool::new()), + agents: Arc::new(AgentPool::new(3001)), } } } diff --git a/server/src/main.rs b/server/src/main.rs index f622916..b5e4570 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -50,7 +50,8 @@ async fn main() -> Result<(), std::io::Error> { JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?, ); let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default())); - let agents = Arc::new(AgentPool::new()); + let port = resolve_port(); + let agents = Arc::new(AgentPool::new(port)); let ctx = AppContext { state: app_state, @@ -60,8 +61,6 @@ async fn main() -> Result<(), std::io::Error> { }; let app = build_routes(ctx); - - let port = resolve_port(); let addr = format!("127.0.0.1:{port}"); println!( diff --git a/server/src/worktree.rs b/server/src/worktree.rs index 75c7f6f..8af37b3 100644 --- a/server/src/worktree.rs +++ b/server/src/worktree.rs @@ -2,6 +2,15 @@ use crate::config::ProjectConfig; use std::path::{Path, PathBuf}; use std::process::Command; +/// Write a `.mcp.json` file in the given directory pointing to the MCP server +/// at the given port. +pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> { + let content = format!( + "{{\n \"mcpServers\": {{\n \"story-kit\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" + ); + std::fs::write(dir.join(".mcp.json"), content).map_err(|e| format!("Write .mcp.json: {e}")) +} + #[derive(Debug, Clone)] #[allow(dead_code)] pub struct WorktreeInfo { @@ -46,12 +55,14 @@ fn detect_base_branch(project_root: &Path) -> String { /// /// - Creates the worktree at `{project_root}-story-{story_id}` (sibling directory) /// on branch `feature/story-{story_id}`. +/// - Writes `.mcp.json` in the worktree pointing to the MCP server at `port`. /// - Runs setup commands from the config for each component. /// - If the worktree/branch already exists, reuses rather than errors. pub async fn create_worktree( project_root: &Path, story_id: &str, config: &ProjectConfig, + port: u16, ) -> Result { let wt_path = worktree_path(project_root, story_id); let branch = branch_name(story_id); @@ -60,6 +71,7 @@ pub async fn create_worktree( // Already exists — reuse if wt_path.exists() { + write_mcp_json(&wt_path, port)?; run_setup_commands(&wt_path, config).await?; return Ok(WorktreeInfo { path: wt_path, @@ -75,6 +87,7 @@ pub async fn create_worktree( .await .map_err(|e| format!("spawn_blocking: {e}"))??; + write_mcp_json(&wt_path, port)?; run_setup_commands(&wt_path, config).await?; Ok(WorktreeInfo { @@ -205,6 +218,28 @@ async fn run_teardown_commands(wt_path: &Path, config: &ProjectConfig) -> Result Ok(()) } +async fn run_shell_command(cmd: &str, cwd: &Path) -> Result<(), String> { + let cmd = cmd.to_string(); + let cwd = cwd.to_path_buf(); + + tokio::task::spawn_blocking(move || { + eprintln!("[worktree] Running: {cmd} in {}", cwd.display()); + let output = Command::new("sh") + .args(["-c", &cmd]) + .current_dir(&cwd) + .output() + .map_err(|e| format!("Run '{cmd}': {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Command '{cmd}' failed: {stderr}")); + } + Ok(()) + }) + .await + .map_err(|e| format!("spawn_blocking: {e}"))? +} + #[cfg(test)] mod tests { use super::*; @@ -225,6 +260,22 @@ mod tests { .expect("git commit"); } + #[test] + fn write_mcp_json_uses_given_port() { + let tmp = TempDir::new().unwrap(); + write_mcp_json(tmp.path(), 4242).unwrap(); + let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap(); + assert!(content.contains("http://localhost:4242/mcp")); + } + + #[test] + fn write_mcp_json_default_port() { + let tmp = TempDir::new().unwrap(); + write_mcp_json(tmp.path(), 3001).unwrap(); + let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap(); + assert!(content.contains("http://localhost:3001/mcp")); + } + #[test] fn create_worktree_after_stale_reference() { let tmp = TempDir::new().unwrap(); @@ -255,25 +306,3 @@ mod tests { assert!(wt_path.exists()); } } - -async fn run_shell_command(cmd: &str, cwd: &Path) -> Result<(), String> { - let cmd = cmd.to_string(); - let cwd = cwd.to_path_buf(); - - tokio::task::spawn_blocking(move || { - eprintln!("[worktree] Running: {cmd} in {}", cwd.display()); - let output = Command::new("sh") - .args(["-c", &cmd]) - .current_dir(&cwd) - .output() - .map_err(|e| format!("Run '{cmd}': {e}"))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("Command '{cmd}' failed: {stderr}")); - } - Ok(()) - }) - .await - .map_err(|e| format!("spawn_blocking: {e}"))? -}