huskies: merge 1117 story MCP tool for adopt: expose new project --adopt as an MCP call
This commit is contained in:
@@ -20,6 +20,7 @@ const GATEWAY_TOOLS: &[&str] = &[
|
|||||||
"gateway_status",
|
"gateway_status",
|
||||||
"gateway_health",
|
"gateway_health",
|
||||||
"init_project",
|
"init_project",
|
||||||
|
"adopt_project",
|
||||||
"aggregate_pipeline_status",
|
"aggregate_pipeline_status",
|
||||||
"agents.list",
|
"agents.list",
|
||||||
// Handled at the gateway so the Matrix bot's perm_rx listener is used
|
// Handled at the gateway so the Matrix bot's perm_rx listener is used
|
||||||
@@ -82,6 +83,28 @@ pub(crate) fn gateway_tool_definitions() -> Vec<Value> {
|
|||||||
"required": ["path"]
|
"required": ["path"]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "adopt_project",
|
||||||
|
"description": "Wrap a Docker container around an existing host checkout — the same as `new project <name> --adopt <path>`. No git clone or git init is performed; the directory is bind-mounted at /workspace. Launches the appropriate stack-specific image, generates an SSH keypair, and registers the project in projects.toml. Returns the SSH connection command and detected stack.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Short project name (letters, digits, hyphens, underscores). Must be unique across registered projects."
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Absolute host filesystem path to the existing checkout to adopt. Must be an existing directory."
|
||||||
|
},
|
||||||
|
"stack": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional: override stack detection (e.g. 'rust', 'node', 'python'). Auto-detected from directory contents when omitted."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name", "path"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
json!({
|
json!({
|
||||||
"name": "aggregate_pipeline_status",
|
"name": "aggregate_pipeline_status",
|
||||||
"description": "Fetch pipeline status from ALL registered projects in parallel and return an aggregated report. For each project: stage counts (backlog/current/qa/merge/done) and a list of blocked or failing items with triage detail. Unreachable projects are included with an error state rather than failing the whole call.",
|
"description": "Fetch pipeline status from ALL registered projects in parallel and return an aggregated report. For each project: stage counts (backlog/current/qa/merge/done) and a list of blocked or failing items with triage detail. Unreachable projects are included with an error state rather than failing the whole call.",
|
||||||
@@ -358,6 +381,7 @@ async fn handle_gateway_tool(
|
|||||||
"gateway_status" => handle_gateway_status_tool(state, id).await,
|
"gateway_status" => handle_gateway_status_tool(state, id).await,
|
||||||
"gateway_health" => handle_gateway_health_tool(state, id).await,
|
"gateway_health" => handle_gateway_health_tool(state, id).await,
|
||||||
"init_project" => handle_init_project_tool(params, state, id).await,
|
"init_project" => handle_init_project_tool(params, state, id).await,
|
||||||
|
"adopt_project" => handle_adopt_project_tool(params, state, id).await,
|
||||||
"aggregate_pipeline_status" => handle_aggregate_pipeline_status_tool(state, id).await,
|
"aggregate_pipeline_status" => handle_aggregate_pipeline_status_tool(state, id).await,
|
||||||
"agents.list" => handle_agents_list_tool(id),
|
"agents.list" => handle_agents_list_tool(id),
|
||||||
"prompt_permission" => handle_prompt_permission_tool(params, state, id).await,
|
"prompt_permission" => handle_prompt_permission_tool(params, state, id).await,
|
||||||
@@ -525,6 +549,81 @@ async fn handle_init_project_tool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle the `adopt_project` gateway tool.
|
||||||
|
///
|
||||||
|
/// Wraps a Docker container around an existing host checkout — the MCP
|
||||||
|
/// equivalent of the `new project <name> --adopt <path>` chat command.
|
||||||
|
/// Validates that `path` exists and is a directory before delegating to
|
||||||
|
/// `handle_new_project`, which performs stack detection, container launch,
|
||||||
|
/// SSH keypair generation, and project registration.
|
||||||
|
async fn handle_adopt_project_tool(
|
||||||
|
params: &Value,
|
||||||
|
state: &GatewayState,
|
||||||
|
id: Option<Value>,
|
||||||
|
) -> JsonRpcResponse {
|
||||||
|
use crate::chat::transport::matrix::new_project::handle_new_project;
|
||||||
|
|
||||||
|
let args = params.get("arguments").unwrap_or(params);
|
||||||
|
let name = args
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let path_str = args
|
||||||
|
.get("path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let stack = args.get("stack").and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
return JsonRpcResponse::error(id, -32602, "missing required parameter: name".into());
|
||||||
|
}
|
||||||
|
if path_str.is_empty() {
|
||||||
|
return JsonRpcResponse::error(id, -32602, "missing required parameter: path".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = std::path::Path::new(path_str);
|
||||||
|
if !path.exists() {
|
||||||
|
return JsonRpcResponse::error(
|
||||||
|
id,
|
||||||
|
-32602,
|
||||||
|
format!(
|
||||||
|
"Adopt path `{path_str}` does not exist — specify the path to an existing checkout."
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !path.is_dir() {
|
||||||
|
return JsonRpcResponse::error(
|
||||||
|
id,
|
||||||
|
-32602,
|
||||||
|
format!("Adopt path `{path_str}` is not a directory."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = handle_new_project(
|
||||||
|
name,
|
||||||
|
stack,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(path_str),
|
||||||
|
&state.projects,
|
||||||
|
&state.config_dir,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
JsonRpcResponse::success(
|
||||||
|
id,
|
||||||
|
json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": result
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_aggregate_pipeline_status_tool(
|
async fn handle_aggregate_pipeline_status_tool(
|
||||||
state: &GatewayState,
|
state: &GatewayState,
|
||||||
id: Option<Value>,
|
id: Option<Value>,
|
||||||
@@ -686,3 +785,123 @@ async fn handle_pipeline_get(state: &GatewayState, id: Option<Value>) -> JsonRpc
|
|||||||
|
|
||||||
JsonRpcResponse::success(id, json!({ "active": active, "projects": results }))
|
JsonRpcResponse::success(id, json!({ "active": active, "projects": results }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::service::gateway::config::{GatewayConfig, ProjectEntry};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn make_test_state(config_dir: &std::path::Path) -> Arc<GatewayState> {
|
||||||
|
let mut projects = BTreeMap::new();
|
||||||
|
projects.insert(
|
||||||
|
"test-project".to_string(),
|
||||||
|
ProjectEntry::with_url("http://127.0.0.1:3001"),
|
||||||
|
);
|
||||||
|
let config = GatewayConfig {
|
||||||
|
projects,
|
||||||
|
sled_tokens: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
Arc::new(GatewayState::new(config, config_dir.to_path_buf(), 3000).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adopt_project_tool_missing_name_returns_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = make_test_state(dir.path());
|
||||||
|
let params = json!({ "arguments": { "path": "/some/path" } });
|
||||||
|
let resp = handle_adopt_project_tool(¶ms, &state, Some(json!(1))).await;
|
||||||
|
assert!(resp.error.is_some(), "expected error for missing name");
|
||||||
|
let msg = resp.error.unwrap().message;
|
||||||
|
assert!(msg.contains("name"), "expected 'name' in error, got: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adopt_project_tool_missing_path_returns_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = make_test_state(dir.path());
|
||||||
|
let params = json!({ "arguments": { "name": "myapp" } });
|
||||||
|
let resp = handle_adopt_project_tool(¶ms, &state, Some(json!(1))).await;
|
||||||
|
assert!(resp.error.is_some(), "expected error for missing path");
|
||||||
|
let msg = resp.error.unwrap().message;
|
||||||
|
assert!(msg.contains("path"), "expected 'path' in error, got: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adopt_project_tool_nonexistent_path_returns_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = make_test_state(dir.path());
|
||||||
|
let params = json!({ "arguments": { "name": "myapp", "path": "/nonexistent/xyz/abc123" } });
|
||||||
|
let resp = handle_adopt_project_tool(¶ms, &state, Some(json!(1))).await;
|
||||||
|
assert!(resp.error.is_some(), "expected error for nonexistent path");
|
||||||
|
let msg = resp.error.unwrap().message;
|
||||||
|
assert!(
|
||||||
|
msg.contains("does not exist"),
|
||||||
|
"expected 'does not exist' in error, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adopt_project_tool_file_path_returns_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file = dir.path().join("not_a_dir.txt");
|
||||||
|
std::fs::write(&file, "content").unwrap();
|
||||||
|
let state = make_test_state(dir.path());
|
||||||
|
let params = json!({ "arguments": { "name": "myapp", "path": file.to_str().unwrap() } });
|
||||||
|
let resp = handle_adopt_project_tool(¶ms, &state, Some(json!(1))).await;
|
||||||
|
assert!(resp.error.is_some(), "expected error for file path");
|
||||||
|
let msg = resp.error.unwrap().message;
|
||||||
|
assert!(
|
||||||
|
msg.contains("not a directory"),
|
||||||
|
"expected 'not a directory' in error, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The MCP entry point produces the same validation outcome as the chat-routed call.
|
||||||
|
///
|
||||||
|
/// Both paths ultimately run the same checks: path-doesn't-exist and
|
||||||
|
/// path-is-file are tested here to verify the MCP layer is consistent
|
||||||
|
/// with `handle_new_project` in `new_project.rs`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adopt_project_tool_matches_chat_routed_call() {
|
||||||
|
use crate::chat::transport::matrix::new_project::handle_new_project;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file = dir.path().join("a_file.txt");
|
||||||
|
std::fs::write(&file, "not a dir").unwrap();
|
||||||
|
let file_path = file.to_str().unwrap();
|
||||||
|
|
||||||
|
// Chat-routed: handle_new_project returns a text string with the error.
|
||||||
|
let store = Arc::new(RwLock::new(BTreeMap::new()));
|
||||||
|
let chat_result = handle_new_project(
|
||||||
|
"myapp",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(file_path),
|
||||||
|
&store,
|
||||||
|
dir.path(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
chat_result.contains("not a directory"),
|
||||||
|
"chat path should report 'not a directory', got: {chat_result}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// MCP-routed: handle_adopt_project_tool returns a JSON-RPC error.
|
||||||
|
let state = make_test_state(dir.path());
|
||||||
|
let params = json!({ "arguments": { "name": "myapp2", "path": file_path } });
|
||||||
|
let mcp_resp = handle_adopt_project_tool(¶ms, &state, Some(json!(1))).await;
|
||||||
|
assert!(mcp_resp.error.is_some(), "MCP path should return an error");
|
||||||
|
let mcp_msg = mcp_resp.error.unwrap().message;
|
||||||
|
assert!(
|
||||||
|
mcp_msg.contains("not a directory"),
|
||||||
|
"MCP path should report 'not a directory', got: {mcp_msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user