huskies: merge 598_story_expose_huskies_init_as_a_gateway_mcp_tool
This commit is contained in:
@@ -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 |
|
||||
| `gateway_status` | Show active project and list all registered projects |
|
||||
| `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
|
||||
|
||||
|
||||
+399
-2
@@ -217,7 +217,7 @@ struct JsonRpcResponse {
|
||||
error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JsonRpcError {
|
||||
code: i64,
|
||||
message: String,
|
||||
@@ -252,7 +252,12 @@ fn to_json_response(resp: JsonRpcResponse) -> Response {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// proxies everything else to the active project's container.
|
||||
@@ -412,6 +417,28 @@ fn gateway_tool_definitions() -> Vec<Value> {
|
||||
"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,
|
||||
"gateway_status" => handle_gateway_status(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}")),
|
||||
}
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
/// `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.
|
||||
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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user