Files
huskies/server/src/http/gateway/mcp.rs
T

1692 lines
66 KiB
Rust
Raw Normal View History

2026-04-28 10:56:09 +00:00
//! 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;
2026-05-12 14:57:53 +00:00
use poem::web::sse::{Event, SSE};
use poem::{Body, IntoResponse, Request, Response};
2026-04-28 10:56:09 +00:00
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::sync::Arc;
2026-05-12 14:57:53 +00:00
use std::time::Duration;
2026-04-28 10:56:09 +00:00
// ── MCP tool definitions ─────────────────────────────────────────────────────
/// Gateway-specific MCP tools exposed alongside the proxied tools.
const GATEWAY_TOOLS: &[&str] = &[
"switch_project",
"gateway_status",
"gateway_health",
"list_projects",
2026-04-28 10:56:09 +00:00
"init_project",
"adopt_project",
2026-04-28 10:56:09 +00:00
"aggregate_pipeline_status",
2026-04-28 13:36:45 +00:00
"agents.list",
// Handled at the gateway so the Matrix bot's permission listener is used
2026-05-12 22:46:55 +00:00
// 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",
2026-04-28 10:56:09 +00:00
];
/// Gateway tool definitions.
pub(crate) fn gateway_tool_definitions() -> Vec<Value> {
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": {}
}
}),
2026-04-28 10:56:09 +00:00
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"]
}
}),
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"]
}
}),
2026-04-28 10:56:09 +00:00
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": {}
}
}),
2026-04-28 13:36:45 +00:00
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')."
}
}
}
}),
2026-04-28 10:56:09 +00:00
]
}
// ── 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<GatewayState>>,
) -> 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()));
}
2026-05-12 14:57:53 +00:00
// 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;
}
}
}
2026-04-28 10:56:09 +00:00
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)),
},
2026-04-28 12:03:16 +00:00
"pipeline.get" => to_json_response(handle_pipeline_get(&state, rpc.id).await),
2026-04-28 10:56:09 +00:00
"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,
}
2026-04-28 10:56:09 +00:00
}
}
_ => proxy_and_respond(&state, &bytes, rpc.id).await,
}
}
/// Proxy a request to the active project and format the response.
2026-05-12 23:11:34 +00:00
///
/// Prefers the live sled-uplink WebSocket when one is attached (story 899
/// AC 2); falls back to the legacy HTTP proxy otherwise.
2026-04-28 10:56:09 +00:00
async fn proxy_and_respond(state: &GatewayState, bytes: &[u8], id: Option<Value>) -> Response {
2026-05-12 23:11:34 +00:00
match state.proxy_active_mcp(bytes).await {
2026-04-28 10:56:09 +00:00
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<Value>,
) -> 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}"),
)),
}
}
2026-05-12 14:57:53 +00:00
/// 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.
2026-05-12 17:49:44 +00:00
#[allow(clippy::string_slice)] // pos from buf.find('\n'); '\n' is ASCII so pos and pos+1 are valid boundaries
2026-05-12 14:57:53 +00:00
async fn proxy_and_respond_sse(state: &GatewayState, bytes: &[u8], id: Option<Value>) -> 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<Value>, 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()
}
2026-04-28 10:56:09 +00:00
/// 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<Value>) -> 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.
2026-05-12 23:11:34 +00:00
///
/// Routes via the sled-uplink WS when one is attached (story 899 AC 2);
/// falls back to HTTP otherwise.
2026-04-28 10:56:09 +00:00
async fn handle_tools_list(
state: &GatewayState,
id: Option<Value>,
) -> Result<JsonRpcResponse, String> {
2026-05-12 23:11:34 +00:00
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}"))?;
2026-04-28 10:56:09 +00:00
let mut tools: Vec<Value> = 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);
2026-04-28 10:56:09 +00:00
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."
})
});
}
}
}
2026-04-28 10:56:09 +00:00
// ── Gateway tool dispatch ────────────────────────────────────────────────────
/// Dispatch a gateway-specific tool call.
async fn handle_gateway_tool(
tool_name: &str,
params: &Value,
state: &GatewayState,
id: Option<Value>,
) -> 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,
2026-04-28 10:56:09 +00:00
"init_project" => handle_init_project_tool(params, state, id).await,
"adopt_project" => handle_adopt_project_tool(params, state, id).await,
2026-04-28 10:56:09 +00:00
"aggregate_pipeline_status" => handle_aggregate_pipeline_status_tool(state, id).await,
2026-04-28 13:36:45 +00:00
"agents.list" => handle_agents_list_tool(id),
2026-05-12 22:46:55 +00:00
"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),
2026-04-28 10:56:09 +00:00
_ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")),
}
}
async fn handle_switch_project_tool(
params: &Value,
state: &GatewayState,
id: Option<Value>,
) -> 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<Value>) -> 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"
}
}
2026-04-28 10:56:09 +00:00
async fn handle_gateway_health_tool(state: &GatewayState, id: Option<Value>) -> JsonRpcResponse {
let mut results = BTreeMap::new();
2026-05-12 23:11:34 +00:00
// 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<String>)> = state
2026-04-28 10:56:09 +00:00
.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;
2026-05-12 23:11:34 +00:00
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()
2026-04-28 10:56:09 +00:00
};
let relay = relay_status(&event_entries, name, now_secs);
results.insert(name.clone(), format!("{status} relay={relay}"));
2026-04-28 10:56:09 +00:00
}
2026-05-12 23:11:34 +00:00
drop(sled_conns);
2026-04-28 10:56:09 +00:00
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::<Vec<_>>()
.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<Value>) -> 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 }] }))
}
2026-04-28 10:56:09 +00:00
async fn handle_init_project_tool(
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("");
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 <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),
false,
&state.projects,
&state.config_dir,
)
.await;
JsonRpcResponse::success(
id,
json!({
"content": [{
"type": "text",
"text": result
}]
}),
)
}
2026-04-28 10:56:09 +00:00
async fn handle_aggregate_pipeline_status_tool(
state: &GatewayState,
id: Option<Value>,
) -> JsonRpcResponse {
let project_urls: BTreeMap<String, String> = state
.projects
.read()
.await
.iter()
2026-05-12 23:11:34 +00:00
.filter_map(|(name, entry)| entry.url.as_ref().map(|u| (name.clone(), u.clone())))
2026-04-28 10:56:09 +00:00
.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,
}),
)
}
2026-04-28 12:03:16 +00:00
2026-05-12 22:46:55 +00:00
/// 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).
2026-05-12 22:46:55 +00:00
async fn handle_prompt_permission_tool(
params: &Value,
state: &GatewayState,
id: Option<Value>,
) -> 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() {
2026-05-12 22:46:55 +00:00
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}]}))
}
2026-04-28 13:36:45 +00:00
/// Handle the `agents.list` gateway tool — returns all alive build agents from the CRDT.
fn handle_agents_list_tool(id: Option<Value>) -> 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<Value>,
) -> 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<Value>,
) -> 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<String> = 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<Value>,
) -> 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<Value>) -> 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<Value>) -> 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<Value>) -> 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 }] }))
}
2026-05-12 21:04:33 +00:00
/// 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 } } }`.
2026-04-28 12:03:16 +00:00
async fn handle_pipeline_get(state: &GatewayState, id: Option<Value>) -> JsonRpcResponse {
let project_urls: BTreeMap<String, String> = state
.projects
.read()
.await
.iter()
2026-05-12 23:11:34 +00:00
.filter_map(|(n, e)| e.url.as_ref().map(|u| (n.clone(), u.clone())))
2026-04-28 12:03:16 +00:00
.collect();
2026-05-12 21:04:33 +00:00
let results = gateway::io::fetch_all_project_pipeline_items(&project_urls, &state.client).await;
2026-04-28 12:03:16 +00:00
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<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(),
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(&params, &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(&params, &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(&params, &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(&params, &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(&params, &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(&params, &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(&params, &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(&params, &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(&params, &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"}));
}
}