1692 lines
66 KiB
Rust
1692 lines
66 KiB
Rust
//! 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<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": {}
|
|
}
|
|
}),
|
|
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"]
|
|
}
|
|
}),
|
|
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<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()));
|
|
}
|
|
|
|
// 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<Value>) -> 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<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}"),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// 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<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()
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// 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<Value>,
|
|
) -> Result<JsonRpcResponse, String> {
|
|
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<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);
|
|
|
|
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<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,
|
|
"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<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"
|
|
}
|
|
}
|
|
|
|
async fn handle_gateway_health_tool(state: &GatewayState, id: Option<Value>) -> 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<String>)> = 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::<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 }] }))
|
|
}
|
|
|
|
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
|
|
}]
|
|
}),
|
|
)
|
|
}
|
|
|
|
async fn handle_aggregate_pipeline_status_tool(
|
|
state: &GatewayState,
|
|
id: Option<Value>,
|
|
) -> JsonRpcResponse {
|
|
let project_urls: BTreeMap<String, String> = 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<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() {
|
|
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<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 }] }))
|
|
}
|
|
|
|
/// 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<Value>) -> JsonRpcResponse {
|
|
let project_urls: BTreeMap<String, String> = 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<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(¶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"}));
|
|
}
|
|
}
|