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_health",
|
||||
"init_project",
|
||||
"adopt_project",
|
||||
"aggregate_pipeline_status",
|
||||
"agents.list",
|
||||
// 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"]
|
||||
}
|
||||
}),
|
||||
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!({
|
||||
"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.",
|
||||
@@ -358,6 +381,7 @@ async fn handle_gateway_tool(
|
||||
"gateway_status" => handle_gateway_status_tool(state, id).await,
|
||||
"gateway_health" => handle_gateway_health_tool(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,
|
||||
"agents.list" => handle_agents_list_tool(id),
|
||||
"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(
|
||||
state: &GatewayState,
|
||||
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 }))
|
||||
}
|
||||
|
||||
// ── 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