diff --git a/server/src/http/gateway/mcp.rs b/server/src/http/gateway/mcp.rs index a1604643..a5291487 100644 --- a/server/src/http/gateway/mcp.rs +++ b/server/src/http/gateway/mcp.rs @@ -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 { "required": ["path"] } }), + json!({ + "name": "adopt_project", + "description": "Wrap a Docker container around an existing host checkout — the same as `new project --adopt `. 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 --adopt ` 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, +) -> 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, @@ -686,3 +785,123 @@ async fn handle_pipeline_get(state: &GatewayState, id: Option) -> 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 { + 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}" + ); + } +}