Accept story 40: MCP Server Obeys STORYKIT_PORT
Agent worktrees now get a .mcp.json written with the correct port from the running server. AgentPool receives the port at construction and passes it through to create_worktree, which writes .mcp.json on both new creation and reuse. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -111,12 +111,14 @@ fn agent_info_from_entry(story_id: &str, agent: &StoryAgent) -> AgentInfo {
|
|||||||
/// Manages concurrent story agents, each in its own worktree.
|
/// Manages concurrent story agents, each in its own worktree.
|
||||||
pub struct AgentPool {
|
pub struct AgentPool {
|
||||||
agents: Arc<Mutex<HashMap<String, StoryAgent>>>,
|
agents: Arc<Mutex<HashMap<String, StoryAgent>>>,
|
||||||
|
port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentPool {
|
impl AgentPool {
|
||||||
pub fn new() -> Self {
|
pub fn new(port: u16) -> Self {
|
||||||
Self {
|
Self {
|
||||||
agents: Arc::new(Mutex::new(HashMap::new())),
|
agents: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
port,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +190,7 @@ impl AgentPool {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create worktree
|
// 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
|
// Update with worktree info
|
||||||
{
|
{
|
||||||
@@ -696,7 +698,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn wait_for_agent_returns_immediately_if_completed() {
|
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);
|
pool.inject_test_agent("s1", "bot", AgentStatus::Completed);
|
||||||
|
|
||||||
let info = pool.wait_for_agent("s1", "bot", 1000).await.unwrap();
|
let info = pool.wait_for_agent("s1", "bot", 1000).await.unwrap();
|
||||||
@@ -707,7 +709,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn wait_for_agent_returns_immediately_if_failed() {
|
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);
|
pool.inject_test_agent("s2", "bot", AgentStatus::Failed);
|
||||||
|
|
||||||
let info = pool.wait_for_agent("s2", "bot", 1000).await.unwrap();
|
let info = pool.wait_for_agent("s2", "bot", 1000).await.unwrap();
|
||||||
@@ -716,7 +718,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn wait_for_agent_completes_on_done_event() {
|
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);
|
let tx = pool.inject_test_agent("s3", "bot", AgentStatus::Running);
|
||||||
|
|
||||||
// Send Done event after a short delay
|
// Send Done event after a short delay
|
||||||
@@ -741,7 +743,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn wait_for_agent_times_out() {
|
async fn wait_for_agent_times_out() {
|
||||||
let pool = AgentPool::new();
|
let pool = AgentPool::new(3001);
|
||||||
pool.inject_test_agent("s4", "bot", AgentStatus::Running);
|
pool.inject_test_agent("s4", "bot", AgentStatus::Running);
|
||||||
|
|
||||||
let result = pool.wait_for_agent("s4", "bot", 50).await;
|
let result = pool.wait_for_agent("s4", "bot", 50).await;
|
||||||
@@ -752,14 +754,14 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn wait_for_agent_errors_for_nonexistent() {
|
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;
|
let result = pool.wait_for_agent("no_story", "no_bot", 100).await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn wait_for_agent_completes_on_stopped_status_event() {
|
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 = pool.inject_test_agent("s5", "bot", AgentStatus::Running);
|
||||||
|
|
||||||
let tx_clone = tx.clone();
|
let tx_clone = tx.clone();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ impl AppContext {
|
|||||||
state: Arc::new(state),
|
state: Arc::new(state),
|
||||||
store: Arc::new(JsonFileStore::new(store_path).unwrap()),
|
store: Arc::new(JsonFileStore::new(store_path).unwrap()),
|
||||||
workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())),
|
workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())),
|
||||||
agents: Arc::new(AgentPool::new()),
|
agents: Arc::new(AgentPool::new(3001)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
||||||
);
|
);
|
||||||
let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default()));
|
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 {
|
let ctx = AppContext {
|
||||||
state: app_state,
|
state: app_state,
|
||||||
@@ -60,8 +61,6 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let app = build_routes(ctx);
|
let app = build_routes(ctx);
|
||||||
|
|
||||||
let port = resolve_port();
|
|
||||||
let addr = format!("127.0.0.1:{port}");
|
let addr = format!("127.0.0.1:{port}");
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ use crate::config::ProjectConfig;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct WorktreeInfo {
|
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)
|
/// - Creates the worktree at `{project_root}-story-{story_id}` (sibling directory)
|
||||||
/// on branch `feature/story-{story_id}`.
|
/// 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.
|
/// - Runs setup commands from the config for each component.
|
||||||
/// - If the worktree/branch already exists, reuses rather than errors.
|
/// - If the worktree/branch already exists, reuses rather than errors.
|
||||||
pub async fn create_worktree(
|
pub async fn create_worktree(
|
||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
story_id: &str,
|
story_id: &str,
|
||||||
config: &ProjectConfig,
|
config: &ProjectConfig,
|
||||||
|
port: u16,
|
||||||
) -> Result<WorktreeInfo, String> {
|
) -> Result<WorktreeInfo, String> {
|
||||||
let wt_path = worktree_path(project_root, story_id);
|
let wt_path = worktree_path(project_root, story_id);
|
||||||
let branch = branch_name(story_id);
|
let branch = branch_name(story_id);
|
||||||
@@ -60,6 +71,7 @@ pub async fn create_worktree(
|
|||||||
|
|
||||||
// Already exists — reuse
|
// Already exists — reuse
|
||||||
if wt_path.exists() {
|
if wt_path.exists() {
|
||||||
|
write_mcp_json(&wt_path, port)?;
|
||||||
run_setup_commands(&wt_path, config).await?;
|
run_setup_commands(&wt_path, config).await?;
|
||||||
return Ok(WorktreeInfo {
|
return Ok(WorktreeInfo {
|
||||||
path: wt_path,
|
path: wt_path,
|
||||||
@@ -75,6 +87,7 @@ pub async fn create_worktree(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("spawn_blocking: {e}"))??;
|
.map_err(|e| format!("spawn_blocking: {e}"))??;
|
||||||
|
|
||||||
|
write_mcp_json(&wt_path, port)?;
|
||||||
run_setup_commands(&wt_path, config).await?;
|
run_setup_commands(&wt_path, config).await?;
|
||||||
|
|
||||||
Ok(WorktreeInfo {
|
Ok(WorktreeInfo {
|
||||||
@@ -205,6 +218,28 @@ async fn run_teardown_commands(wt_path: &Path, config: &ProjectConfig) -> Result
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -225,6 +260,22 @@ mod tests {
|
|||||||
.expect("git commit");
|
.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]
|
#[test]
|
||||||
fn create_worktree_after_stale_reference() {
|
fn create_worktree_after_stale_reference() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
@@ -255,25 +306,3 @@ mod tests {
|
|||||||
assert!(wt_path.exists());
|
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}"))?
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user