//! MCP JSON-RPC POST/GET handlers and gateway tool dispatch. use super::jsonrpc::{JsonRpcRequest, JsonRpcResponse, to_json_response}; use crate::service::gateway::{self, GatewayState}; use poem::handler; use poem::http::StatusCode; use poem::web::Data; use poem::web::sse::{Event, SSE}; use poem::{Body, IntoResponse, Request, Response}; use serde_json::{Value, json}; use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; // ── MCP tool definitions ───────────────────────────────────────────────────── /// Gateway-specific MCP tools exposed alongside the proxied tools. const GATEWAY_TOOLS: &[&str] = &[ "switch_project", "gateway_status", "gateway_health", "list_projects", "init_project", "adopt_project", "aggregate_pipeline_status", "agents.list", // Handled at the gateway so the Matrix bot's permission listener is used // rather than the container's (which has no interactive session attached). "prompt_permission", // One-shot container rebuild: build fresh image, swap container, preserve state. "project_rebuild", // On-demand host + per-container disk/load/CPU/mem snapshot (story 1207). "fleet_resources", // Read sled identity pins vs. live signed identity, and TOFU re-pin. "fleet_identity", // Gateway process pid/build/version/uptime/configured model (story 1209). "gateway_info", // Bounce the gateway process itself, never touching project containers (story 1209). "restart_gateway", // Tail/grep the gateway's own in-process log, distinct from sled get_server_logs (story 1209). "gateway_logs", ]; /// Gateway tool definitions. pub(crate) fn gateway_tool_definitions() -> Vec { vec![ json!({ "name": "switch_project", "description": "Switch the active project. All subsequent MCP tool calls will be proxied to this project's container.", "inputSchema": { "type": "object", "properties": { "project": { "type": "string", "description": "Name of the project to switch to (must exist in projects.toml)" } }, "required": ["project"] } }), json!({ "name": "gateway_status", "description": "Show pipeline status for the active project by proxying the get_pipeline_status tool call.", "inputSchema": { "type": "object", "properties": {} } }), json!({ "name": "gateway_health", "description": "Health check aggregation across all registered projects. Returns the health status of every project container.", "inputSchema": { "type": "object", "properties": {} } }), json!({ "name": "list_projects", "description": "List every registered gateway project with its name, url, ssh_port (if set), host_path (if set), and an adopted/built-in marker. The active project is prefixed with *. No liveness checks are performed.", "inputSchema": { "type": "object", "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 `. 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"] } }), 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.", "inputSchema": { "type": "object", "properties": {} } }), json!({ "name": "agents.list", "description": "List all alive build agents currently registered with this gateway. Returns an array of agent objects with id, label, address, registered_at, last_seen, and assigned_project fields.", "inputSchema": { "type": "object", "properties": {} } }), json!({ "name": "project_rebuild", "description": "Rebuild a project's Docker image from its Dockerfile.fragment, swap the container, and preserve all CRDT and pipeline state. In-flight coder/merge work is drained before the swap; if not drainable within the timeout the command refuses. On success returns the new image hash and container ID.", "inputSchema": { "type": "object", "properties": { "name": { "type": "string", "description": "Name of the project to rebuild (must exist in projects.toml with host_path set)." }, "drain_timeout_secs": { "type": "integer", "description": "Seconds to wait for active agents to stop before rebuilding (default: 60). Pass 0 to skip the drain check." }, "force": { "type": "boolean", "description": "If true, skip the drain check and rebuild immediately even if agents are running." } }, "required": ["name"] } }), json!({ "name": "fleet_resources", "description": "On-demand host and per-container resource snapshot: host disk free/total, load averages, core count, and memory; per-container CPU%/mem sourced via `docker stats`; and per-project `target/`+`.huskies/worktrees/` directory sizes sourced via `docker exec ... find` (bounded, skips node_modules/.git, cached briefly per container+path). Flags disk/load conditions past thresholds first so problems lead the response.", "inputSchema": { "type": "object", "properties": { "disk_warn_gb": { "type": "integer", "description": "Host free-disk warn threshold in GB (default: 50)." }, "disk_critical_gb": { "type": "integer", "description": "Host free-disk critical threshold in GB (default: 20)." }, "load_warn_per_core": { "type": "number", "description": "1-minute load average per core above which a warn flag fires (default: 1.0)." }, "load_critical_per_core": { "type": "number", "description": "1-minute load average per core above which a critical flag fires (default: 2.0)." } } } }), json!({ "name": "fleet_identity", "description": "Read mode (default): for every registered sled, report project, url, connected, the recorded pin (expected_node_id), the live signature-verified node_id from a signed challenge-response (never the unsigned /identity display field), and whether they match. Repin mode: capture a sled's live verified identity via TOFU and persist it as the new pin, refusing when the signature is missing or does not verify.", "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["read", "repin"], "description": "\"read\" (default) reports every sled's pin vs. live identity. \"repin\" re-pins one sled via TOFU; requires `project`." }, "project": { "type": "string", "description": "Required when action is \"repin\": the project/sled name to re-pin." } } } }), json!({ "name": "gateway_info", "description": "Return the running gateway process's pid, build hash, version, uptime in seconds, and configured Claude Code model (from the gateway's own bot.toml).", "inputSchema": { "type": "object", "properties": {} } }), json!({ "name": "restart_gateway", "description": "Safely bounce the gateway process itself (flush persisted state, then exit so Docker's restart policy relaunches the container). Never touches or removes any registered project's container — use project_rebuild for that.", "inputSchema": { "type": "object", "properties": {} } }), json!({ "name": "gateway_logs", "description": "Tail/grep the gateway process's own in-process log (matrix bot, sled-uplink, poller activity) — distinct from get_server_logs, which (when proxied through the gateway) returns the active project's own log instead.", "inputSchema": { "type": "object", "properties": { "lines": { "type": "integer", "description": "Number of lines to return (default 100, max 1000)." }, "filter": { "type": "string", "description": "Optional substring filter applied to each log line (e.g. 'matrix', 'uplink', 'poller')." } } } }), ] } // ── MCP POST handler ───────────────────────────────────────────────────────── /// Main MCP POST handler for the gateway. Intercepts gateway-specific tools and /// proxies everything else to the active project's container. #[handler] pub async fn gateway_mcp_post_handler( req: &Request, body: Body, state: Data<&Arc>, ) -> Response { let content_type = req.header("content-type").unwrap_or(""); if !content_type.is_empty() && !content_type.contains("application/json") { return to_json_response(JsonRpcResponse::error( None, -32700, "Unsupported Content-Type; expected application/json".into(), )); } let bytes = match body.into_bytes().await { Ok(b) => b, Err(_) => { return to_json_response(JsonRpcResponse::error(None, -32700, "Parse error".into())); } }; let rpc: JsonRpcRequest = match serde_json::from_slice(&bytes) { Ok(r) => r, Err(_) => { return to_json_response(JsonRpcResponse::error(None, -32700, "Parse error".into())); } }; if rpc.jsonrpc != "2.0" { return to_json_response(JsonRpcResponse::error( rpc.id, -32600, "Invalid JSON-RPC version".into(), )); } if rpc.id.is_none() || rpc.id.as_ref() == Some(&Value::Null) { if rpc.method.starts_with("notifications/") { return Response::builder() .status(StatusCode::ACCEPTED) .body(Body::empty()); } return to_json_response(JsonRpcResponse::error(None, -32600, "Missing id".into())); } // SSE proxy: tools/call with Accept: text/event-stream + progressToken for // non-gateway tools is forwarded to the sled's SSE endpoint so progress // notifications flow through to the gateway client unchanged. if rpc.method == "tools/call" { let accepts_sse = req .header("accept") .map(|h| h.contains("text/event-stream")) .unwrap_or(false); let has_progress_token = rpc .params .get("_meta") .and_then(|m| m.get("progressToken")) .is_some(); if accepts_sse && has_progress_token { let tool_name = rpc .params .get("name") .and_then(|v| v.as_str()) .unwrap_or(""); if !GATEWAY_TOOLS.contains(&tool_name) { return proxy_and_respond_sse(&state, &bytes, rpc.id).await; } } } match rpc.method.as_str() { "initialize" => to_json_response(handle_initialize(rpc.id)), "tools/list" => match handle_tools_list(&state, rpc.id.clone()).await { Ok(resp) => to_json_response(resp), Err(e) => to_json_response(JsonRpcResponse::error(rpc.id, -32603, e)), }, "pipeline.get" => to_json_response(handle_pipeline_get(&state, rpc.id).await), "tools/call" => { let tool_name = rpc .params .get("name") .and_then(|v| v.as_str()) .unwrap_or(""); if GATEWAY_TOOLS.contains(&tool_name) { to_json_response( handle_gateway_tool(tool_name, &rpc.params, &state, rpc.id.clone()).await, ) } else { // Story 1208 AC 1: an explicit `project` argument on any // proxied tool call targets that project directly, without // requiring a prior `switch_project`. let explicit_project = rpc .params .get("arguments") .and_then(|a| a.get("project")) .and_then(|v| v.as_str()) .filter(|p| !p.is_empty()); match explicit_project { Some(project) => { proxy_and_respond_for_project(&state, project, &bytes, rpc.id).await } None => proxy_and_respond(&state, &bytes, rpc.id).await, } } } _ => proxy_and_respond(&state, &bytes, rpc.id).await, } } /// Proxy a request to the active project and format the response. /// /// Prefers the live sled-uplink WebSocket when one is attached (story 899 /// AC 2); falls back to the legacy HTTP proxy otherwise. async fn proxy_and_respond(state: &GatewayState, bytes: &[u8], id: Option) -> Response { match state.proxy_active_mcp(bytes).await { Ok(resp_body) => Response::builder() .status(StatusCode::OK) .header("Content-Type", "application/json") .body(Body::from(resp_body)), Err(e) => to_json_response(JsonRpcResponse::error( id, -32603, format!("proxy error: {e}"), )), } } /// Proxy a request to an explicitly named project (story 1208 AC 1) rather /// than whatever project is currently active, so a single ops/LLM session /// can address any registered project per-call without a prior /// `switch_project`. Returns a JSON-RPC error when `project` is not a /// registered project name. async fn proxy_and_respond_for_project( state: &GatewayState, project: &str, bytes: &[u8], id: Option, ) -> Response { { let projects = state.projects.read().await; if let Err(e) = gateway::config::validate_project_exists(&projects, project) { return to_json_response(JsonRpcResponse::error(id, -32602, e)); } } match state.proxy_mcp_for_project(project, bytes).await { Ok(resp_body) => Response::builder() .status(StatusCode::OK) .header("Content-Type", "application/json") .body(Body::from(resp_body)), Err(e) => to_json_response(JsonRpcResponse::error( id, -32603, format!("proxy error: {e}"), )), } } /// Stream an MCP tool call to the active sled as SSE, re-emitting each `data:` /// event from the sled to the originating gateway client without buffering. /// /// On sled disconnect mid-stream a JSON-RPC error event is emitted so the /// client does not hang forever. #[allow(clippy::string_slice)] // pos from buf.find('\n'); '\n' is ASCII so pos and pos+1 are valid boundaries async fn proxy_and_respond_sse(state: &GatewayState, bytes: &[u8], id: Option) -> Response { let url = match state.active_url().await { Ok(u) => u, Err(e) => return sse_error_response(id, -32603, e.to_string()), }; let resp = match gateway::io::proxy_mcp_call_sse(&state.client, &url, bytes).await { Ok(r) => r, Err(e) => return sse_error_response(id, -32603, format!("proxy error: {e}")), }; let id_for_error = id; let stream = async_stream::stream! { use futures::StreamExt as _; let mut buf = String::new(); let byte_stream = resp.bytes_stream(); tokio::pin!(byte_stream); while let Some(chunk) = byte_stream.next().await { match chunk { Ok(bytes) => { if let Ok(text) = std::str::from_utf8(&bytes) { buf.push_str(text); // Emit a gateway SSE event for each complete `data:` line. while let Some(pos) = buf.find('\n') { let line = buf[..pos].trim_end_matches('\r').to_string(); buf = buf[pos + 1..].to_string(); if let Some(data) = line.strip_prefix("data: ") { yield Event::message(data.to_string()); } } } } Err(e) => { let err = JsonRpcResponse::error( id_for_error.clone(), -32603, format!("upstream disconnected: {e}"), ); let data = serde_json::to_string(&err).unwrap_or_default(); yield Event::message(data); break; } } } }; SSE::new(stream) .keep_alive(Duration::from_secs(15)) .into_response() } /// Build a minimal SSE response containing a single JSON-RPC error event. fn sse_error_response(id: Option, code: i64, msg: String) -> Response { let err = JsonRpcResponse::error(id, code, msg); let data = serde_json::to_string(&err).unwrap_or_default(); let stream = async_stream::stream! { yield Event::message(data); }; SSE::new(stream).into_response() } /// GET handler — method not allowed. #[handler] pub async fn gateway_mcp_get_handler() -> Response { Response::builder() .status(StatusCode::METHOD_NOT_ALLOWED) .body(Body::empty()) } // ── Protocol handlers ──────────────────────────────────────────────────────── fn handle_initialize(id: Option) -> JsonRpcResponse { JsonRpcResponse::success( id, json!({ "protocolVersion": "2025-03-26", "capabilities": { "tools": {} }, "serverInfo": { "name": "huskies-gateway", "version": "1.0.0" } }), ) } /// Fetch tools/list from the active project and merge in gateway tools. /// /// Routes via the sled-uplink WS when one is attached (story 899 AC 2); /// falls back to HTTP otherwise. async fn handle_tools_list( state: &GatewayState, id: Option, ) -> Result { let rpc_body = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }); let bytes = serde_json::to_vec(&rpc_body).map_err(|e| e.to_string())?; let resp_bytes = state.proxy_active_mcp(&bytes).await?; let resp_json: Value = serde_json::from_slice(&resp_bytes).map_err(|e| format!("invalid tools/list JSON: {e}"))?; let mut tools: Vec = resp_json .get("result") .and_then(|r| r.get("tools")) .and_then(|t| t.as_array()) .cloned() .unwrap_or_default(); inject_project_arg_schema(&mut tools); let mut all_tools = gateway_tool_definitions(); all_tools.append(&mut tools); Ok(JsonRpcResponse::success(id, json!({ "tools": all_tools }))) } /// Advertise the optional per-call `project` argument (story 1208 AC 1) on /// every proxied tool's `inputSchema.properties`, so MCP clients that /// validate call arguments against the declared schema don't reject it. /// Leaves any tool without an object `inputSchema.properties` untouched. fn inject_project_arg_schema(tools: &mut [Value]) { for tool in tools.iter_mut() { if let Some(props) = tool .get_mut("inputSchema") .and_then(|s| s.get_mut("properties")) .and_then(|p| p.as_object_mut()) { props.entry("project".to_string()).or_insert_with(|| { json!({ "type": "string", "description": "Optional: name of a registered gateway project to target instead of the currently active one (see list_projects). Omit to use the active project." }) }); } } } // ── Gateway tool dispatch ──────────────────────────────────────────────────── /// Dispatch a gateway-specific tool call. async fn handle_gateway_tool( tool_name: &str, params: &Value, state: &GatewayState, id: Option, ) -> JsonRpcResponse { match tool_name { "switch_project" => handle_switch_project_tool(params, state, id).await, "gateway_status" => handle_gateway_status_tool(state, id).await, "gateway_health" => handle_gateway_health_tool(state, id).await, "list_projects" => handle_list_projects_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, "project_rebuild" => handle_project_rebuild_tool(params, state, id).await, "fleet_resources" => handle_fleet_resources_tool(params, state, id).await, "fleet_identity" => handle_fleet_identity_tool(params, state, id).await, "gateway_info" => handle_gateway_info_tool(state, id).await, "restart_gateway" => handle_restart_gateway_tool(state, id).await, "gateway_logs" => handle_gateway_logs_tool(params, id), _ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")), } } async fn handle_switch_project_tool( params: &Value, state: &GatewayState, id: Option, ) -> JsonRpcResponse { let project = params .get("arguments") .and_then(|a| a.get("project")) .or_else(|| params.get("project")) .and_then(|v| v.as_str()) .unwrap_or(""); match gateway::switch_project(state, project).await { Ok(url) => JsonRpcResponse::success( id, json!({ "content": [{ "type": "text", "text": format!("Switched to project '{project}' ({url})") }] }), ), Err(e) => JsonRpcResponse::error(id, -32602, e.to_string()), } } async fn handle_gateway_status_tool(state: &GatewayState, id: Option) -> JsonRpcResponse { let active = state.active_project.read().await.clone(); let url = match state.active_url().await { Ok(u) => u, Err(e) => return JsonRpcResponse::error(id.clone(), -32603, e.to_string()), }; match gateway::io::fetch_pipeline_status_for_project(&state.client, &url).await { Ok(upstream) => { let pipeline = upstream.get("result").cloned().unwrap_or(json!(null)); JsonRpcResponse::success( id, json!({ "content": [{ "type": "text", "text": format!( "Pipeline status for '{active}':\n{}", serde_json::to_string_pretty(&pipeline).unwrap_or_default() ) }] }), ) } Err(e) => JsonRpcResponse::error(id, -32603, e), } } /// Returns `"ok"`, `"silent"`, or `"never"` for a project based on its most recent /// CRDT event log entry. The gateway appends events using the project name as `sled_id`, /// so filtering by project name identifies all events received from that sled. fn relay_status( entries: &[crate::crdt_state::EventLogEntryRaw], project: &str, now_secs: f64, ) -> &'static str { let latest = entries .iter() .filter(|e| e.sled_id == project) .map(|e| e.timestamp) .fold(f64::NEG_INFINITY, f64::max); if latest == f64::NEG_INFINITY { "never" } else if now_secs - latest <= crate::service::gateway::RELAY_MAX_AGE_SECS { "ok" } else { "silent" } } async fn handle_gateway_health_tool(state: &GatewayState, id: Option) -> JsonRpcResponse { let mut results = BTreeMap::new(); // Build the project list, preferring the WS-uplink heartbeat as the // source of truth for liveness (story 899 AC 3). HTTP polls are used // only as a fallback when no live sled is connected. let project_names: Vec<(String, Option)> = state .projects .read() .await .iter() .map(|(n, e)| (n.clone(), e.url.clone())) .collect(); let event_entries = crate::crdt_state::read_all_event_log_entries(); let now_secs = chrono::Utc::now().timestamp() as f64; let sled_conns = state.sled_connections.read().await; for (name, url_opt) in &project_names { let status = if let Some(conn) = sled_conns.get(name) { if conn.is_alive(crate::service::gateway::HEARTBEAT_MAX_AGE_MS) { "healthy (ws)".to_string() } else { "stale (ws heartbeat overdue)".to_string() } } else if let Some(url) = url_opt { match gateway::io::check_project_health(&state.client, url).await { Ok(true) => "healthy".to_string(), Ok(false) => "unhealthy".to_string(), Err(e) => e, } } else { "no uplink and no url configured".to_string() }; let relay = relay_status(&event_entries, name, now_secs); results.insert(name.clone(), format!("{status} relay={relay}")); } drop(sled_conns); let active = state.active_project.read().await.clone(); JsonRpcResponse::success( id, json!({ "content": [{ "type": "text", "text": format!( "Health check (active: '{active}'):\n{}", results.iter() .map(|(name, status)| format!(" {name}: {status}")) .collect::>() .join("\n") ) }] }), ) } /// Handle the `list_projects` gateway tool. /// /// Returns one row per registered project: name, url, ssh_port (if set), /// host_path (if set), and an `[adopted]`/`[built-in]` marker. Output is /// alphabetised by project name (BTreeMap order). The active project is /// prefixed with `*`. No liveness checks are performed. async fn handle_list_projects_tool(state: &GatewayState, id: Option) -> JsonRpcResponse { let active = state.active_project.read().await.clone(); let projects = state.projects.read().await; if projects.is_empty() { return JsonRpcResponse::success( id, json!({ "content": [{ "type": "text", "text": "No projects registered." }] }), ); } let mut lines = Vec::with_capacity(projects.len() + 1); lines.push(format!("Projects ({} registered):", projects.len())); for (name, entry) in projects.iter() { let marker = if *name == active { "*" } else { " " }; let mut parts = vec![name.clone()]; if let Some(ref url) = entry.url { parts.push(url.clone()); } if let Some(port) = entry.ssh_port { parts.push(format!("ssh:{port}")); } if let Some(ref path) = entry.host_path { parts.push(path.clone()); } let adopted = if entry.host_path.is_some() { "[adopted]" } else { "[built-in]" }; parts.push(adopted.to_string()); lines.push(format!("{marker} {}", parts.join(" "))); } let text = lines.join("\n"); JsonRpcResponse::success(id, json!({ "content": [{ "type": "text", "text": text }] })) } async fn handle_init_project_tool( params: &Value, state: &GatewayState, id: Option, ) -> JsonRpcResponse { let args = params.get("arguments").unwrap_or(params); let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(""); let name = args.get("name").and_then(|v| v.as_str()); let url = args.get("url").and_then(|v| v.as_str()); match gateway::init_project(state, path_str, name, url).await { Ok(registered_name) => { 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}") }] }), ) } Err(e) => { let code = match &e { gateway::Error::Config(_) => -32602, gateway::Error::DuplicateToken(_) => -32602, _ => -32603, }; JsonRpcResponse::error(id, code, e.to_string()) } } } /// 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), false, &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, ) -> JsonRpcResponse { let project_urls: BTreeMap = state .projects .read() .await .iter() .filter_map(|(name, entry)| entry.url.as_ref().map(|u| (name.clone(), u.clone()))) .collect(); let statuses = gateway::io::fetch_all_project_pipeline_statuses(&project_urls, &state.client).await; let active = state.active_project.read().await.clone(); JsonRpcResponse::success( id, json!({ "content": [{ "type": "text", "text": format!( "Aggregate pipeline status (active: '{active}'):\n{}", serde_json::to_string_pretty(&statuses).unwrap_or_default() ) }], "projects": statuses, "active": active, }), ) } /// Handle the `prompt_permission` tool at the gateway level. /// /// Mirrors `tool_prompt_permission` in `http/mcp/diagnostics/permission.rs` but /// uses the gateway's `perm_tx`/`permission_registry` so requests reach the /// Matrix bot that is listening on the gateway, not the proxied container /// (which has no interactive session and would auto-deny immediately). async fn handle_prompt_permission_tool( params: &Value, state: &GatewayState, id: Option, ) -> JsonRpcResponse { use crate::http::context::PermissionDecision; use crate::http::context::PermissionForward; let args = params.get("arguments").unwrap_or(params); let tool_name = args .get("tool_name") .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); let tool_input = args.get("input").cloned().unwrap_or(json!({})); // Auto-approve huskies MCP tools — mirrors the standard server's allowlist. if tool_name.starts_with("mcp__huskies__") { crate::slog!( "[gateway/permission] Auto-approved '{tool_name}' (matches mcp__huskies__* allowlist)" ); let text = json!({"behavior": "allow", "updatedInput": tool_input}).to_string(); return JsonRpcResponse::success(id, json!({"content": [{"type": "text", "text": text}]})); } // Auto-deny when no responder is registered (i.e. no Matrix bot listener // is running). if state.permission_registry.is_empty() { crate::slog!("[gateway/permission] Auto-denied '{tool_name}' (no interactive session)"); let text = json!({ "behavior": "deny", "message": format!("Permission denied for '{tool_name}'. No interactive session active.") }) .to_string(); return JsonRpcResponse::success(id, json!({"content": [{"type": "text", "text": text}]})); } let request_id = uuid::Uuid::new_v4().to_string(); let (response_tx, response_rx) = tokio::sync::oneshot::channel(); if state .perm_tx .send(PermissionForward { request_id, tool_name: tool_name.clone(), tool_input: tool_input.clone(), response_tx, }) .is_err() { crate::slog!("[gateway/permission] Auto-denied '{tool_name}' (perm_tx send failed)"); let text = json!({"behavior": "deny", "message": format!("Permission denied for '{tool_name}'.")}) .to_string(); return JsonRpcResponse::success(id, json!({"content": [{"type": "text", "text": text}]})); } let decision = match tokio::time::timeout(std::time::Duration::from_secs(300), response_rx).await { Ok(Ok(d)) => d, Ok(Err(_)) => { return JsonRpcResponse::error( id, -32603, "Permission response channel closed unexpectedly".into(), ); } Err(_) => { return JsonRpcResponse::error( id, -32603, format!("Permission request for '{tool_name}' timed out after 5 minutes"), ); } }; let text = if matches!( decision, PermissionDecision::Approve | PermissionDecision::AlwaysAllow ) { json!({"behavior": "allow", "updatedInput": tool_input}).to_string() } else { crate::slog_warn!("[gateway/permission] User denied permission for '{tool_name}'"); json!({"behavior": "deny", "message": format!("User denied permission for '{tool_name}'")}) .to_string() }; JsonRpcResponse::success(id, json!({"content": [{"type": "text", "text": text}]})) } /// Handle the `agents.list` gateway tool — returns all alive build agents from the CRDT. fn handle_agents_list_tool(id: Option) -> JsonRpcResponse { let agents = gateway::list_agents(); let agents_json = serde_json::to_value(&agents).unwrap_or(json!([])); JsonRpcResponse::success( id, json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&agents).unwrap_or_default() }], "agents": agents_json, }), ) } /// Handle the `project_rebuild` gateway tool. /// /// Rebuilds a project's Docker image, swaps the container, and preserves all /// CRDT and pipeline state. Delegates to `handle_project_rebuild` in the chat /// transport module so the logic is shared between the chat and MCP entry points. async fn handle_project_rebuild_tool( params: &Value, state: &GatewayState, id: Option, ) -> JsonRpcResponse { use crate::chat::transport::matrix::project_rebuild::handle_project_rebuild; let args = params.get("arguments").unwrap_or(params); let name = args .get("name") .and_then(|v| v.as_str()) .unwrap_or("") .trim(); if name.is_empty() { return JsonRpcResponse::error(id, -32602, "missing required parameter: name".into()); } let drain_timeout_secs = args .get("drain_timeout_secs") .and_then(|v| v.as_u64()) .unwrap_or(60); let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false); let result = handle_project_rebuild( name, drain_timeout_secs, force, &state.projects, &state.config_dir, ) .await; JsonRpcResponse::success( id, json!({ "content": [{ "type": "text", "text": result }] }), ) } /// Handle the `fleet_resources` gateway tool (story 1207). /// /// Collects host disk/load/cpu/mem, per-container CPU%/mem (via `docker /// stats`), and per-project `target/`/`worktrees/` sizes (via `docker exec /// ... find`) — all sourced gateway-side rather than requiring each project's /// sled to self-report. Thresholds for the leading `flags` list are supplied /// as optional arguments (see `gateway_tool_definitions`), defaulting to /// `ResourceThresholds::default()`. async fn handle_fleet_resources_tool( params: &Value, state: &GatewayState, id: Option, ) -> JsonRpcResponse { use crate::service::gateway::resources::ResourceThresholds; let args = params.get("arguments").unwrap_or(params); let defaults = ResourceThresholds::default(); let thresholds = ResourceThresholds { disk_warn_gb: args .get("disk_warn_gb") .and_then(|v| v.as_u64()) .unwrap_or(defaults.disk_warn_gb), disk_critical_gb: args .get("disk_critical_gb") .and_then(|v| v.as_u64()) .unwrap_or(defaults.disk_critical_gb), load_warn_per_core: args .get("load_warn_per_core") .and_then(|v| v.as_f64()) .unwrap_or(defaults.load_warn_per_core), load_critical_per_core: args .get("load_critical_per_core") .and_then(|v| v.as_f64()) .unwrap_or(defaults.load_critical_per_core), }; let project_names: Vec = state.projects.read().await.keys().cloned().collect(); match crate::service::gateway::resources::io::collect_fleet_resources( &state.config_dir, &project_names, &thresholds, ) .await { Ok(resources) => JsonRpcResponse::success( id, json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&resources).unwrap_or_default() }], "resources": serde_json::to_value(&resources).unwrap_or(json!(null)), }), ), Err(e) => JsonRpcResponse::error(id, -32603, format!("fleet_resources failed: {e}")), } } /// Handle the `fleet_identity` gateway tool. /// /// Dispatches on the `action` argument: `"read"` (default) reports every /// sled's pin vs. live signed identity; `"repin"` captures and persists a /// single sled's live verified identity via TOFU. async fn handle_fleet_identity_tool( params: &Value, state: &GatewayState, id: Option, ) -> JsonRpcResponse { let args = params.get("arguments").unwrap_or(params); let action = args .get("action") .and_then(|v| v.as_str()) .unwrap_or("read"); match action { "read" => { let reports = gateway::fleet_identity_read(state).await; let text = gateway::format_identity_reports(&reports); let reports_json = serde_json::to_value(&reports).unwrap_or(json!([])); JsonRpcResponse::success( id, json!({ "content": [{ "type": "text", "text": text }], "reports": reports_json, }), ) } "repin" => { let project = args.get("project").and_then(|v| v.as_str()).unwrap_or(""); if project.is_empty() { return JsonRpcResponse::error( id, -32602, "missing required parameter for action=\"repin\": project".into(), ); } match gateway::fleet_identity_repin(state, project).await { Ok(node_id) => JsonRpcResponse::success( id, json!({ "content": [{ "type": "text", "text": format!( "Re-pinned `{project}` to verified node_id `{node_id}`." ) }] }), ), Err(e) => JsonRpcResponse::error(id, -32602, e.to_string()), } } other => JsonRpcResponse::error( id, -32602, format!("unknown fleet_identity action \"{other}\"; expected \"read\" or \"repin\""), ), } } /// Handle the `gateway_info` gateway tool (story 1209). /// /// Returns the running gateway process's pid, build hash, version, uptime, /// and configured Claude Code model — read from the gateway's own `bot.toml`, /// not any registered project's. async fn handle_gateway_info_tool(state: &GatewayState, id: Option) -> JsonRpcResponse { let build_hash = option_env!("BUILD_GIT_HASH").unwrap_or("unknown"); let fields = gateway::io::read_bot_config_raw(&state.config_dir); let info = json!({ "pid": std::process::id(), "version": env!("CARGO_PKG_VERSION"), "build_hash": build_hash, "uptime_secs": gateway::gateway_uptime_secs(), "configured_model": fields.model, }); JsonRpcResponse::success( id, json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&info).unwrap_or_default() }], "info": info, }), ) } /// Handle the `restart_gateway` gateway tool (story 1209). /// /// Notifies any bot channels that the gateway is going offline for a /// restart, then bounces the gateway process itself: flushes persisted /// state and exits so Docker's restart policy relaunches the container. /// Deliberately touches only the gateway's own state — `state.projects` /// (and therefore any project container) is never read or written here, so /// no project container is ever removed or swapped by this tool. /// /// The actual exit is spawned in the background so the JSON-RPC response /// reaches the caller first; consequently, like `upgrade_and_reexec`, the /// exit itself is not unit-tested (`std::process::exit` would kill the test /// process) — only the notify step and response shape are covered. async fn handle_restart_gateway_tool(state: &GatewayState, id: Option) -> JsonRpcResponse { if let Some(tx) = state.bot_shutdown_tx.lock().await.as_ref() { let _ = tx.send(Some(crate::rebuild::ShutdownReason::Rebuild)); } let config_dir = state.config_dir.clone(); tokio::spawn(async move { // Give the bot task a moment to post its "going offline" message // before the process flushes state and exits. tokio::time::sleep(std::time::Duration::from_millis(500)).await; crate::rebuild::drain_and_exit(&config_dir, "restart_gateway").await }); JsonRpcResponse::success( id, json!({ "content": [{ "type": "text", "text": "Gateway restart triggered. The gateway container will restart momentarily; no project containers are affected." }] }), ) } /// Handle the `gateway_logs` gateway tool (story 1209). /// /// Reads directly from the gateway process's own in-process log ring buffer /// (`log_buffer::global()`) — the same buffer the gateway's own `slog!` /// calls (matrix bot, sled-uplink, poller) write to. This is distinct from /// `get_server_logs`, which is not a gateway tool and therefore proxies to /// the active project's own log buffer instead. fn handle_gateway_logs_tool(params: &Value, id: Option) -> JsonRpcResponse { let args = params.get("arguments").unwrap_or(params); let lines_count = args .get("lines") .and_then(|v| v.as_u64()) .map(|n| n.min(1000) as usize) .unwrap_or(100); let filter = args.get("filter").and_then(|v| v.as_str()); let recent = crate::log_buffer::global().get_recent(lines_count, filter, None); let text = recent.join("\n"); JsonRpcResponse::success(id, json!({ "content": [{ "type": "text", "text": text }] })) } /// Handle the `pipeline.get` read-RPC — returns per-project item lists in the /// shape expected by the gateway web UI: /// `{ "active": "...", "projects": { "name": { "active": [...], "backlog_count": N } } }`. async fn handle_pipeline_get(state: &GatewayState, id: Option) -> JsonRpcResponse { let project_urls: BTreeMap = state .projects .read() .await .iter() .filter_map(|(n, e)| e.url.as_ref().map(|u| (n.clone(), u.clone()))) .collect(); let results = gateway::io::fetch_all_project_pipeline_items(&project_urls, &state.client).await; let active = state.active_project.read().await.clone(); 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(), release_channels: BTreeMap::new(), }; Arc::new(GatewayState::new(config, config_dir.to_path_buf(), 3000).unwrap()) } #[tokio::test] async fn gateway_health_relay_status_distinguishes_active_and_silent_sleds() { crate::crdt_state::init_for_test(); let dir = tempfile::tempdir().unwrap(); let mut projects = BTreeMap::new(); projects.insert( "project-a".to_string(), ProjectEntry::with_url("http://127.0.0.1:9001"), ); projects.insert( "project-b".to_string(), ProjectEntry::with_url("http://127.0.0.1:9002"), ); let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), release_channels: BTreeMap::new(), }; let state = Arc::new(GatewayState::new(config, dir.path().to_path_buf(), 3000).unwrap()); // Fire a recent StageTransition for project-a only. let now_ms = chrono::Utc::now().timestamp_millis() as u64; gateway::broadcast_status_event( &state, "project-a".to_string(), crate::service::events::StoredEvent::StageTransition { story_id: "1_story_test".to_string(), story_name: String::new(), from_stage: "Backlog".to_string(), to_stage: "Current".to_string(), timestamp_ms: now_ms, }, ); let resp = handle_gateway_health_tool(&state, Some(json!(1))).await; assert!(resp.result.is_some(), "expected result, got error"); let text = resp.result.unwrap()["content"][0]["text"] .as_str() .unwrap() .to_string(); assert!( text.contains("relay=ok"), "project-a should report relay=ok; got:\n{text}" ); assert!( text.contains("relay=never") || text.contains("relay=silent"), "project-b should report relay=never or relay=silent; got:\n{text}" ); } #[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), false, &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}" ); } // ── fleet_identity tool (story 1206) ───────────────────────────────────── #[tokio::test] async fn fleet_identity_read_default_action_lists_projects() { let dir = tempfile::tempdir().unwrap(); let state = make_test_state(dir.path()); let params = json!({ "arguments": {} }); let resp = handle_fleet_identity_tool(¶ms, &state, Some(json!(1))).await; assert!(resp.error.is_none(), "expected success: {:?}", resp.error); let text = resp.result.unwrap()["content"][0]["text"] .as_str() .unwrap() .to_string(); assert!(text.contains("test-project")); } #[tokio::test] async fn fleet_identity_repin_missing_project_returns_error() { let dir = tempfile::tempdir().unwrap(); let state = make_test_state(dir.path()); let params = json!({ "arguments": { "action": "repin" } }); let resp = handle_fleet_identity_tool(¶ms, &state, Some(json!(1))).await; assert!(resp.error.is_some(), "expected error for missing project"); let msg = resp.error.unwrap().message; assert!( msg.contains("project"), "expected 'project' in error, got: {msg}" ); } #[tokio::test] async fn fleet_identity_repin_unknown_project_returns_error() { let dir = tempfile::tempdir().unwrap(); let state = make_test_state(dir.path()); let params = json!({ "arguments": { "action": "repin", "project": "nonexistent" } }); let resp = handle_fleet_identity_tool(¶ms, &state, Some(json!(1))).await; assert!(resp.error.is_some(), "expected error for unknown project"); } #[tokio::test] async fn fleet_identity_unknown_action_returns_error() { let dir = tempfile::tempdir().unwrap(); let state = make_test_state(dir.path()); let params = json!({ "arguments": { "action": "bogus" } }); let resp = handle_fleet_identity_tool(¶ms, &state, Some(json!(1))).await; assert!(resp.error.is_some(), "expected error for unknown action"); let msg = resp.error.unwrap().message; assert!(msg.contains("unknown fleet_identity action"), "got: {msg}"); } #[test] fn fleet_identity_is_in_gateway_tools() { assert!(GATEWAY_TOOLS.contains(&"fleet_identity")); } // ── gateway_info / restart_gateway / gateway_logs (story 1209) ────────── #[tokio::test] async fn gateway_info_tool_returns_pid_version_and_uptime() { let dir = tempfile::tempdir().unwrap(); let state = make_test_state(dir.path()); let resp = handle_gateway_info_tool(&state, Some(json!(1))).await; assert!(resp.error.is_none(), "expected success: {:?}", resp.error); let info = &resp.result.unwrap()["info"]; assert_eq!(info["pid"], std::process::id()); assert_eq!(info["version"], env!("CARGO_PKG_VERSION")); assert!(info["uptime_secs"].is_u64()); assert!(info["build_hash"].is_string()); } #[tokio::test] async fn gateway_info_tool_reports_configured_model_from_bot_toml() { let dir = tempfile::tempdir().unwrap(); let huskies_dir = dir.path().join(".huskies"); std::fs::create_dir_all(&huskies_dir).unwrap(); std::fs::write( huskies_dir.join("bot.toml"), "model = \"claude-opus-4-8\"\n", ) .unwrap(); let state = make_test_state(dir.path()); let resp = handle_gateway_info_tool(&state, Some(json!(1))).await; let info = &resp.result.unwrap()["info"]; assert_eq!(info["configured_model"], "claude-opus-4-8"); } #[test] fn restart_gateway_is_registered_as_a_gateway_tool() { // The handler triggers `std::process::exit` on a background task after // a short delay — it is deliberately never invoked from a test (doing // so risks terminating the entire test binary if the spawned task // outlives the test's runtime). Only registration/schema wiring is // verified here, matching the existing lack of coverage for // `upgrade_and_reexec` (same shape, same reason). assert!(GATEWAY_TOOLS.contains(&"restart_gateway")); let defs = gateway_tool_definitions(); assert!( defs.iter().any(|d| d["name"] == "restart_gateway"), "restart_gateway must appear in gateway_tool_definitions()" ); } #[test] fn gateway_logs_is_in_gateway_tools() { assert!(GATEWAY_TOOLS.contains(&"gateway_logs")); } #[test] fn gateway_logs_tool_returns_recent_lines() { crate::slog!("gateway_logs_tool_marker_alpha"); let resp = handle_gateway_logs_tool(&json!({"arguments": {"lines": 500}}), Some(json!(1))); assert!(resp.error.is_none(), "expected success: {:?}", resp.error); let text = resp.result.unwrap()["content"][0]["text"] .as_str() .unwrap() .to_string(); assert!( text.contains("gateway_logs_tool_marker_alpha"), "expected marker line in output: {text}" ); } #[test] fn gateway_logs_tool_applies_filter() { crate::slog!("gateway_logs_tool_marker_beta"); crate::slog!("unrelated_other_line"); let resp = handle_gateway_logs_tool( &json!({"arguments": {"lines": 500, "filter": "gateway_logs_tool_marker_beta"}}), Some(json!(1)), ); let text = resp.result.unwrap()["content"][0]["text"] .as_str() .unwrap() .to_string(); assert!(text.contains("gateway_logs_tool_marker_beta")); assert!(!text.contains("unrelated_other_line")); } // ── explicit per-call `project` targeting (story 1208 AC 1) ───────────── #[tokio::test] async fn proxy_for_unknown_project_returns_invalid_params_error() { let dir = tempfile::tempdir().unwrap(); let state = make_test_state(dir.path()); let resp = proxy_and_respond_for_project(&state, "nonexistent", b"{}", Some(json!(1))).await; let bytes = resp.into_body().into_bytes().await.unwrap(); let parsed: Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(parsed["error"]["code"], -32602); assert!( parsed["error"]["message"] .as_str() .unwrap() .contains("unknown project"), "got: {parsed}" ); } #[tokio::test] async fn proxy_for_known_project_without_live_connection_returns_proxy_error() { let dir = tempfile::tempdir().unwrap(); let state = make_test_state(dir.path()); let resp = proxy_and_respond_for_project(&state, "test-project", b"{}", Some(json!(1))).await; let bytes = resp.into_body().into_bytes().await.unwrap(); let parsed: Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(parsed["error"]["code"], -32603); assert!( parsed["error"]["message"] .as_str() .unwrap() .contains("no live WS uplink"), "got: {parsed}" ); } #[test] fn inject_project_arg_schema_adds_property_to_object_schema() { let mut tools = vec![json!({ "name": "create_story", "inputSchema": {"type": "object", "properties": {"name": {"type": "string"}}} })]; inject_project_arg_schema(&mut tools); assert_eq!( tools[0]["inputSchema"]["properties"]["project"]["type"], "string" ); assert_eq!( tools[0]["inputSchema"]["properties"]["name"]["type"], "string" ); } #[test] fn inject_project_arg_schema_does_not_overwrite_existing_project_property() { let mut tools = vec![json!({ "name": "weird_tool", "inputSchema": {"type": "object", "properties": {"project": {"type": "integer"}}} })]; inject_project_arg_schema(&mut tools); assert_eq!( tools[0]["inputSchema"]["properties"]["project"]["type"], "integer" ); } #[test] fn inject_project_arg_schema_skips_tool_without_properties() { let mut tools = vec![json!({"name": "no_schema"})]; inject_project_arg_schema(&mut tools); assert_eq!(tools[0], json!({"name": "no_schema"})); } }