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 |
|
| `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
@@ -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(¶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