huskies: merge 598_story_expose_huskies_init_as_a_gateway_mcp_tool

This commit is contained in:
dave
2026-04-22 21:33:44 +00:00
parent f2d9926c4c
commit b3da321a3b
2 changed files with 402 additions and 2 deletions
+3
View File
@@ -136,6 +136,9 @@ The gateway presents a unified MCP surface to the chat agent. All tool calls are
| `switch_project` | Change the active project | | `switch_project` | Change the active project |
| `gateway_status` | Show active project and list all registered projects | | `gateway_status` | Show active project and list all registered projects |
| `gateway_health` | Health check all containers | | `gateway_health` | Health check all containers |
| `init_project` | Scaffold a new `.huskies/` project at a given path — prefer this over asking the user to run `huskies init` on the CLI |
**Initialising a new project via MCP (preferred):** Instead of asking the user to run `huskies init <path>` in a terminal, call `init_project` with the `path` argument. Optionally pass `name` and `url` to register the project in `projects.toml` immediately. After that, start a huskies server at the path and use `switch_project` to make it active before calling `wizard_status`.
### Example: multi-project Docker Compose ### Example: multi-project Docker Compose
+399 -2
View File
@@ -217,7 +217,7 @@ struct JsonRpcResponse {
error: Option<JsonRpcError>, error: Option<JsonRpcError>,
} }
#[derive(Serialize)] #[derive(Debug, Serialize)]
struct JsonRpcError { struct JsonRpcError {
code: i64, code: i64,
message: String, message: String,
@@ -252,7 +252,12 @@ fn to_json_response(resp: JsonRpcResponse) -> Response {
} }
/// Gateway-specific MCP tools exposed alongside the proxied tools. /// Gateway-specific MCP tools exposed alongside the proxied tools.
const GATEWAY_TOOLS: &[&str] = &["switch_project", "gateway_status", "gateway_health"]; const GATEWAY_TOOLS: &[&str] = &[
"switch_project",
"gateway_status",
"gateway_health",
"init_project",
];
/// Main MCP POST handler for the gateway. Intercepts gateway-specific tools and /// Main MCP POST handler for the gateway. Intercepts gateway-specific tools and
/// proxies everything else to the active project's container. /// proxies everything else to the active project's container.
@@ -412,6 +417,28 @@ fn gateway_tool_definitions() -> Vec<Value> {
"properties": {} "properties": {}
} }
}), }),
json!({
"name": "init_project",
"description": "Initialize a new huskies project at the given path by scaffolding .huskies/ and related files — the same as running `huskies init <path>`. Prefer this tool over asking the user to run the CLI. If `name` and `url` are supplied the project is also registered in projects.toml so switch_project can reach it immediately.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute filesystem path to the project directory to initialise. The directory is created if it does not exist."
},
"name": {
"type": "string",
"description": "Optional: short name to register the project under in projects.toml (e.g. 'my-app'). Requires `url`."
},
"url": {
"type": "string",
"description": "Optional: base URL of the huskies container that will serve this project (e.g. 'http://my-app:3001'). Required when `name` is given."
}
},
"required": ["path"]
}
}),
] ]
} }
@@ -490,6 +517,7 @@ async fn handle_gateway_tool(
"switch_project" => handle_switch_project(params, state, id).await, "switch_project" => handle_switch_project(params, state, id).await,
"gateway_status" => handle_gateway_status(state, id).await, "gateway_status" => handle_gateway_status(state, id).await,
"gateway_health" => handle_gateway_health(state, id).await, "gateway_health" => handle_gateway_health(state, id).await,
"init_project" => handle_init_project(params, state, id).await,
_ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")), _ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")),
} }
} }
@@ -631,6 +659,132 @@ async fn handle_gateway_health(state: &GatewayState, id: Option<Value>) -> JsonR
) )
} }
/// Initialise a new huskies project at the given filesystem path.
///
/// Performs the same scaffolding as `huskies init <path>`: creates `.huskies/`,
/// default config files, pipeline directories, and the wizard state. If `name`
/// and `url` are both provided the new project is also registered in
/// `projects.toml` so `switch_project` can reach it immediately.
///
/// Returns an error when the path already contains a `.huskies/` directory.
/// After success the tool response tells the caller what to do next to make
/// `wizard_*` MCP tools work against the new project.
async fn handle_init_project(
params: &Value,
state: &GatewayState,
id: Option<Value>,
) -> JsonRpcResponse {
let args = params.get("arguments").unwrap_or(params);
let path_str = args
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if path_str.is_empty() {
return JsonRpcResponse::error(id, -32602, "missing required parameter: path".to_string());
}
let project_path = std::path::Path::new(path_str);
// Guard: already a huskies project.
if project_path.join(".huskies").exists() {
return JsonRpcResponse::error(
id,
-32602,
format!(
"path '{}' is already a huskies project (.huskies/ exists). \
Use wizard_status to check setup progress.",
project_path.display()
),
);
}
// Create directory if it does not yet exist.
if !project_path.exists()
&& let Err(e) = std::fs::create_dir_all(project_path)
{
return JsonRpcResponse::error(
id,
-32603,
format!(
"failed to create directory '{}': {e}",
project_path.display()
),
);
}
// Scaffold .huskies/ — same logic as `huskies init`.
// Port 3001 is written into .mcp.json only when the file is absent; if it
// already exists it is never overwritten (the value is environment-specific).
if let Err(e) = crate::io::fs::scaffold::scaffold_story_kit(project_path, 3001) {
return JsonRpcResponse::error(id, -32603, format!("scaffold failed: {e}"));
}
// Initialise wizard state so wizard_status returns a valid response
// immediately after the project server is started.
crate::io::wizard::WizardState::init_if_missing(project_path);
// Optionally register the project in projects.toml.
let name = args.get("name").and_then(|v| v.as_str()).map(str::trim);
let url = args.get("url").and_then(|v| v.as_str()).map(str::trim);
let registered_name: Option<String> = match (name, url) {
(Some(n), Some(u)) if !n.is_empty() && !u.is_empty() => {
let mut projects = state.projects.write().await;
if projects.contains_key(n) {
return JsonRpcResponse::error(
id,
-32602,
format!(
"project '{n}' is already registered. \
Choose a different name or use switch_project."
),
);
}
projects.insert(n.to_string(), ProjectEntry { url: u.to_string() });
save_config(&projects, &state.config_dir).await;
crate::slog!("[gateway] init_project: registered '{n}' ({u})");
Some(n.to_string())
}
_ => None,
};
let next_steps = if let Some(ref n) = registered_name {
format!(
"Project registered as '{n}' in projects.toml.\n\
Next steps:\n\
1. Start a huskies server at '{path_str}' \
(e.g. `huskies {path_str}` or via Docker).\n\
2. Call switch_project with name='{n}' to make it active.\n\
3. Call wizard_status to begin the setup wizard."
)
} else {
format!(
"Next steps:\n\
1. Start a huskies server at '{path_str}' \
(e.g. `huskies {path_str}` or via Docker).\n\
2. Register the project: call init_project again with name and url \
parameters, or add it to projects.toml manually.\n\
3. Call switch_project and then wizard_status to begin the setup wizard.\n\n\
Note: wizard_* MCP tools require a running huskies server for the project."
)
};
JsonRpcResponse::success(
id,
json!({
"content": [{
"type": "text",
"text": format!(
"Successfully initialised huskies project at '{path_str}'.\n\n{next_steps}"
)
}]
}),
)
}
// ── Agent join handlers ─────────────────────────────────────────────── // ── Agent join handlers ───────────────────────────────────────────────
/// `GET /gateway/mode` — returns `{"mode":"gateway"}` so clients can detect gateway mode. /// `GET /gateway/mode` — returns `{"mode":"gateway"}` so clients can detect gateway mode.
@@ -2304,4 +2458,247 @@ enabled = false
// build_gateway_route will panic if any route is registered more than once. // build_gateway_route will panic if any route is registered more than once.
let _route = build_gateway_route(state); let _route = build_gateway_route(state);
} }
// ── init_project tool tests ──────────────────────────────────────────
#[tokio::test]
async fn init_project_scaffolds_huskies_dir() {
let dir = tempfile::tempdir().unwrap();
let state = make_test_state();
let params = json!({ "arguments": { "path": dir.path().to_str().unwrap() } });
let resp = handle_init_project(&params, &state, Some(json!(1))).await;
assert!(
resp.result.is_some(),
"init_project should succeed: {:?}",
resp.error
);
assert!(
dir.path().join(".huskies").exists(),
".huskies/ should be created"
);
assert!(dir.path().join(".huskies/project.toml").exists());
assert!(dir.path().join(".huskies/agents.toml").exists());
assert!(dir.path().join("script/test").exists());
}
#[tokio::test]
async fn init_project_creates_wizard_state() {
let dir = tempfile::tempdir().unwrap();
let state = make_test_state();
let params = json!({ "arguments": { "path": dir.path().to_str().unwrap() } });
handle_init_project(&params, &state, None).await;
let wizard_state_path = dir.path().join(".huskies/wizard_state.json");
assert!(
wizard_state_path.exists(),
"wizard_state.json should be created"
);
let content = std::fs::read_to_string(&wizard_state_path).unwrap();
let v: Value =
serde_json::from_str(&content).expect("wizard_state.json should be valid JSON");
assert!(
v.get("steps").is_some(),
"wizard state should have a 'steps' field"
);
assert!(
v.get("completed").is_some(),
"wizard state should have a 'completed' field"
);
}
#[tokio::test]
async fn init_project_already_initialised_returns_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let state = make_test_state();
let params = json!({ "arguments": { "path": dir.path().to_str().unwrap() } });
let resp = handle_init_project(&params, &state, None).await;
assert!(
resp.error.is_some(),
"should return error for already-initialised project"
);
let msg = &resp.error.unwrap().message;
assert!(msg.contains(".huskies/"), "error should mention .huskies/");
}
#[tokio::test]
async fn init_project_missing_path_returns_error() {
let state = make_test_state();
let params = json!({ "arguments": {} });
let resp = handle_init_project(&params, &state, None).await;
assert!(resp.error.is_some());
}
#[tokio::test]
async fn init_project_registers_in_projects_toml_when_name_and_url_given() {
let dir = tempfile::tempdir().unwrap();
let config_dir = tempfile::tempdir().unwrap();
let mut projects = BTreeMap::new();
projects.insert(
"existing".into(),
ProjectEntry {
url: "http://existing:3001".into(),
},
);
let config = GatewayConfig { projects };
let state =
Arc::new(GatewayState::new(config, config_dir.path().to_path_buf(), 3000).unwrap());
let params = json!({
"arguments": {
"path": dir.path().to_str().unwrap(),
"name": "new-project",
"url": "http://new-project:3002"
}
});
let resp = handle_init_project(&params, &state, Some(json!(1))).await;
assert!(resp.result.is_some(), "should succeed: {:?}", resp.error);
// Project should be registered.
let projects = state.projects.read().await;
assert!(
projects.contains_key("new-project"),
"new-project should be in projects map"
);
assert_eq!(projects["new-project"].url, "http://new-project:3002");
}
#[tokio::test]
async fn init_project_duplicate_name_returns_error() {
let dir = tempfile::tempdir().unwrap();
let mut projects = BTreeMap::new();
projects.insert(
"taken".into(),
ProjectEntry {
url: "http://taken:3001".into(),
},
);
let config = GatewayConfig { projects };
let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap());
let params = json!({
"arguments": {
"path": dir.path().to_str().unwrap(),
"name": "taken",
"url": "http://new:3002"
}
});
let resp = handle_init_project(&params, &state, None).await;
assert!(resp.error.is_some(), "duplicate name should return error");
}
/// Integration test: call init_project then call wizard_status via the MCP
/// proxy and confirm a valid wizard state response is returned.
///
/// A lightweight mock HTTP server is started to stand in for the project
/// container, returning a pre-canned wizard_status result.
#[tokio::test]
async fn init_project_then_wizard_status_integration() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// Start a mock project MCP server on an ephemeral port.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mock_port = listener.local_addr().unwrap().port();
let mock_url = format!("http://127.0.0.1:{mock_port}");
// Spawn the mock: accept one connection and return a wizard_status response.
tokio::spawn(async move {
if let Ok((mut stream, _)) = listener.accept().await {
let mut buf = vec![0u8; 4096];
let _ = stream.read(&mut buf).await;
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{
"type": "text",
"text": "{\"steps\":[{\"id\":\"scaffold\",\"title\":\"Scaffold\",\"status\":\"confirmed\"}],\"completed\":false}"
}]
}
});
let body_bytes = serde_json::to_vec(&body).unwrap();
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body_bytes.len()
);
let _ = stream.write_all(header.as_bytes()).await;
let _ = stream.write_all(&body_bytes).await;
}
});
// Give the mock a moment to start.
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
// Create gateway state pointing at the mock server.
let mut projects = BTreeMap::new();
projects.insert("mock-project".into(), ProjectEntry { url: mock_url });
let config = GatewayConfig { projects };
let config_dir = tempfile::tempdir().unwrap();
let state =
Arc::new(GatewayState::new(config, config_dir.path().to_path_buf(), 3000).unwrap());
// 1. Call init_project.
let project_dir = tempfile::tempdir().unwrap();
let params = json!({
"arguments": { "path": project_dir.path().to_str().unwrap() }
});
let resp = handle_init_project(&params, &state, Some(json!(1))).await;
assert!(
resp.result.is_some(),
"init_project should succeed: {:?}",
resp.error
);
// Verify scaffolding.
assert!(
project_dir.path().join(".huskies").exists(),
".huskies/ must be created"
);
let wizard_path = project_dir.path().join(".huskies/wizard_state.json");
assert!(wizard_path.exists(), "wizard_state.json must be created");
// 2. Call wizard_status via the MCP proxy (proxied to our mock server).
let proxy_body = serde_json::to_vec(&json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": { "name": "wizard_status", "arguments": {} }
}))
.unwrap();
let proxy_resp = proxy_mcp_call(&state, &proxy_body).await;
assert!(
proxy_resp.is_ok(),
"proxy call should succeed: {:?}",
proxy_resp.err()
);
// 3. Confirm the response contains wizard state data.
let resp_json: Value = serde_json::from_slice(&proxy_resp.unwrap()).unwrap();
let result = resp_json.get("result");
assert!(result.is_some(), "response should have a result field");
let text = result
.and_then(|r| r.get("content"))
.and_then(|c| c.get(0))
.and_then(|c| c.get("text"))
.and_then(|t| t.as_str())
.unwrap_or("");
let wizard: Value =
serde_json::from_str(text).expect("text should be valid wizard state JSON");
assert!(
wizard.get("steps").is_some(),
"wizard state should have a 'steps' field"
);
}
#[test]
fn gateway_tool_definitions_includes_init_project() {
let defs = gateway_tool_definitions();
let names: Vec<&str> = defs
.iter()
.filter_map(|d| d.get("name").and_then(|n| n.as_str()))
.collect();
assert!(
names.contains(&"init_project"),
"init_project should be in gateway tool definitions"
);
}
} }