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

689 lines
26 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",
"init_project",
"aggregate_pipeline_status",
2026-04-28 13:36:45 +00:00
"agents.list",
2026-05-12 22:46:55 +00:00
// Handled at the gateway so the Matrix bot's perm_rx listener is used
// rather than the container's (which has no interactive session attached).
"prompt_permission",
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": "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": "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": {}
}
}),
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 {
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.
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}"),
)),
}
}
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();
let mut all_tools = gateway_tool_definitions();
all_tools.append(&mut tools);
Ok(JsonRpcResponse::success(id, json!({ "tools": all_tools })))
}
// ── 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,
"init_project" => handle_init_project_tool(params, state, id).await,
"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,
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),
}
}
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();
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
};
results.insert(name.clone(), status);
}
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")
)
}]
}),
)
}
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())
}
}
}
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`/`perm_rx` 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 interactive session holds perm_rx (i.e. no Matrix bot
// listener is running — try_lock succeeds when nobody else holds the lock).
if state.perm_rx.try_lock().is_ok() {
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,
}),
)
}
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 }))
}