Files
huskies/server/src/gateway.rs
T

1157 lines
38 KiB
Rust
Raw Normal View History

//! Multi-project gateway — proxies MCP calls to per-project Docker containers.
//!
//! When `huskies --gateway` is used, the server starts in gateway mode: it reads
//! a `projects.toml` config that maps project names to container URLs, maintains
//! an "active project" selection, and proxies all MCP tool calls to the active
//! project's container. Gateway-specific tools allow switching projects, querying
//! status, and aggregating health checks across all registered projects.
use poem::EndpointExt;
use poem::handler;
use poem::http::StatusCode;
use poem::web::{Data, Json};
use poem::{Body, Request, Response};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::RwLock;
// Re-export active_project type alias for clarity in gateway bot helpers.
type ActiveProject = Arc<RwLock<String>>;
// ── Config ───────────────────────────────────────────────────────────
/// A single project entry in `projects.toml`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProjectEntry {
/// Base URL of the project's huskies container (e.g. `http://localhost:3001`).
pub url: String,
}
/// Top-level `projects.toml` config.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GatewayConfig {
/// Map of project name → container URL.
#[serde(default)]
pub projects: BTreeMap<String, ProjectEntry>,
}
impl GatewayConfig {
/// Load gateway config from a `projects.toml` file.
pub fn load(path: &Path) -> Result<Self, String> {
let contents = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
toml::from_str(&contents).map_err(|e| format!("invalid projects.toml: {e}"))
}
}
// ── Gateway state ────────────────────────────────────────────────────
/// Shared gateway state threaded through HTTP handlers.
#[derive(Clone)]
pub struct GatewayState {
/// The parsed gateway config with all registered projects.
pub config: GatewayConfig,
/// The currently active project name.
pub active_project: Arc<RwLock<String>>,
/// HTTP client for proxying requests to project containers.
pub client: Client,
}
impl GatewayState {
/// Create a new gateway state from a config. The first project in the config
/// becomes the active project by default.
pub fn new(config: GatewayConfig) -> Result<Self, String> {
if config.projects.is_empty() {
return Err("projects.toml must define at least one project".to_string());
}
let first = config.projects.keys().next().unwrap().clone();
Ok(Self {
config,
active_project: Arc::new(RwLock::new(first)),
client: Client::new(),
})
}
/// Get the URL of the currently active project.
async fn active_url(&self) -> Result<String, String> {
let name = self.active_project.read().await.clone();
self.config
.projects
.get(&name)
.map(|p| p.url.clone())
.ok_or_else(|| format!("active project '{name}' not found in config"))
}
}
// ── MCP proxy handler ────────────────────────────────────────────────
/// JSON-RPC request (duplicated here to keep the gateway self-contained).
#[derive(Deserialize)]
struct JsonRpcRequest {
jsonrpc: String,
id: Option<Value>,
method: String,
#[serde(default)]
params: Value,
}
/// JSON-RPC response.
#[derive(Serialize)]
struct JsonRpcResponse {
jsonrpc: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<JsonRpcError>,
}
#[derive(Serialize)]
struct JsonRpcError {
code: i64,
message: String,
}
impl JsonRpcResponse {
fn success(id: Option<Value>, result: Value) -> Self {
Self {
jsonrpc: "2.0",
id,
result: Some(result),
error: None,
}
}
fn error(id: Option<Value>, code: i64, message: String) -> Self {
Self {
jsonrpc: "2.0",
id,
result: None,
error: Some(JsonRpcError { code, message }),
}
}
}
fn to_json_response(resp: JsonRpcResponse) -> Response {
let body = serde_json::to_vec(&resp).unwrap_or_default();
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(Body::from(body))
}
/// Gateway-specific MCP tools exposed alongside the proxied tools.
const GATEWAY_TOOLS: &[&str] = &["switch_project", "gateway_status", "gateway_health"];
/// 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(),
));
}
// Accept notifications silently.
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()));
}
match rpc.method.as_str() {
"initialize" => to_json_response(handle_initialize(rpc.id)),
"tools/list" => {
// Merge gateway tools with proxied tools from the active project.
match merge_tools_list(&state, rpc.id.clone()).await {
Ok(resp) => to_json_response(resp),
Err(e) => to_json_response(JsonRpcResponse::error(rpc.id, -32603, e)),
}
}
"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).await)
} else {
// Proxy to active project's container.
match proxy_mcp_call(&state, &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(
rpc.id,
-32603,
format!("proxy error: {e}"),
)),
}
}
}
_ => {
// Proxy unknown methods too.
match proxy_mcp_call(&state, &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(
rpc.id,
-32603,
format!("proxy error: {e}"),
)),
}
}
}
}
/// GET handler — method not allowed (matches the regular MCP endpoint behavior).
#[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"
}
}),
)
}
/// Gateway tool definitions.
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": {}
}
}),
]
}
/// Fetch tools/list from the active project and merge in gateway tools.
async fn merge_tools_list(
state: &GatewayState,
id: Option<Value>,
) -> Result<JsonRpcResponse, String> {
let url = state.active_url().await?;
let mcp_url = format!("{}/mcp", url.trim_end_matches('/'));
let rpc_body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
});
let resp = state
.client
.post(&mcp_url)
.json(&rpc_body)
.send()
.await
.map_err(|e| format!("failed to reach {mcp_url}: {e}"))?;
let resp_json: Value = resp
.json()
.await
.map_err(|e| format!("invalid JSON from upstream: {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();
// Prepend gateway-specific tools.
let mut all_tools = gateway_tool_definitions();
all_tools.append(&mut tools);
Ok(JsonRpcResponse::success(id, json!({ "tools": all_tools })))
}
/// Proxy a raw MCP request body to the active project's container.
async fn proxy_mcp_call(state: &GatewayState, request_bytes: &[u8]) -> Result<Vec<u8>, String> {
let url = state.active_url().await?;
let mcp_url = format!("{}/mcp", url.trim_end_matches('/'));
let resp = state
.client
.post(&mcp_url)
.header("Content-Type", "application/json")
.body(request_bytes.to_vec())
.send()
.await
.map_err(|e| format!("failed to reach {mcp_url}: {e}"))?;
resp.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| format!("failed to read response from {mcp_url}: {e}"))
}
// ── Gateway-specific tools ───────────────────────────────────────────
/// Dispatch a gateway-specific tool call.
async fn handle_gateway_tool(
tool_name: &str,
params: &Value,
state: &GatewayState,
) -> JsonRpcResponse {
let id = None; // The caller wraps this in a proper response.
match tool_name {
"switch_project" => handle_switch_project(params, state).await,
"gateway_status" => handle_gateway_status(state).await,
"gateway_health" => handle_gateway_health(state).await,
_ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")),
}
}
/// Switch the active project.
async fn handle_switch_project(params: &Value, state: &GatewayState) -> 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("");
if project.is_empty() {
return JsonRpcResponse::error(None, -32602, "missing required parameter: project".into());
}
if !state.config.projects.contains_key(project) {
let available: Vec<&str> = state.config.projects.keys().map(|s| s.as_str()).collect();
return JsonRpcResponse::error(
None,
-32602,
format!(
"unknown project '{project}'. Available: {}",
available.join(", ")
),
);
}
*state.active_project.write().await = project.to_string();
let url = &state.config.projects[project].url;
JsonRpcResponse::success(
None,
json!({
"content": [{
"type": "text",
"text": format!("Switched to project '{project}' ({})", url)
}]
}),
)
}
/// Show pipeline status for the active project by proxying `get_pipeline_status`.
async fn handle_gateway_status(state: &GatewayState) -> JsonRpcResponse {
let active = state.active_project.read().await.clone();
let url = match state.active_url().await {
Ok(u) => u,
Err(e) => return JsonRpcResponse::error(None, -32603, e),
};
let mcp_url = format!("{}/mcp", url.trim_end_matches('/'));
let rpc_body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_pipeline_status",
"arguments": {}
}
});
match state.client.post(&mcp_url).json(&rpc_body).send().await {
Ok(resp) => {
match resp.json::<Value>().await {
Ok(upstream) => {
// Extract the result from the upstream response and wrap it.
let pipeline = upstream.get("result").cloned().unwrap_or(json!(null));
JsonRpcResponse::success(
None,
json!({
"content": [{
"type": "text",
"text": format!(
"Pipeline status for '{active}':\n{}",
serde_json::to_string_pretty(&pipeline).unwrap_or_default()
)
}]
}),
)
}
Err(e) => {
JsonRpcResponse::error(None, -32603, format!("invalid upstream response: {e}"))
}
}
}
Err(e) => JsonRpcResponse::error(None, -32603, format!("failed to reach {mcp_url}: {e}")),
}
}
/// Aggregate health checks across all registered projects.
async fn handle_gateway_health(state: &GatewayState) -> JsonRpcResponse {
let mut results = BTreeMap::new();
for (name, entry) in &state.config.projects {
let health_url = format!("{}/health", entry.url.trim_end_matches('/'));
let status = match state.client.get(&health_url).send().await {
Ok(resp) => {
if resp.status().is_success() {
"healthy".to_string()
} else {
format!("unhealthy (HTTP {})", resp.status().as_u16())
}
}
Err(e) => format!("unreachable: {e}"),
};
results.insert(name.clone(), status);
}
let active = state.active_project.read().await.clone();
JsonRpcResponse::success(
None,
json!({
"content": [{
"type": "text",
"text": format!(
"Health check (active: '{active}'):\n{}",
results.iter()
.map(|(name, status)| format!(" {name}: {status}"))
.collect::<Vec<_>>()
.join("\n")
)
}]
}),
)
}
// ── Health aggregation endpoint ──────────────────────────────────────
/// HTTP GET `/health` handler for the gateway — aggregates health from all projects.
#[handler]
pub async fn gateway_health_handler(state: Data<&Arc<GatewayState>>) -> Response {
let mut all_healthy = true;
let mut statuses = BTreeMap::new();
for (name, entry) in &state.config.projects {
let health_url = format!("{}/health", entry.url.trim_end_matches('/'));
let healthy = match state.client.get(&health_url).send().await {
Ok(resp) => resp.status().is_success(),
Err(_) => false,
};
if !healthy {
all_healthy = false;
}
statuses.insert(name.clone(), if healthy { "ok" } else { "error" });
}
let body = json!({
"status": if all_healthy { "ok" } else { "degraded" },
"projects": statuses,
});
let status = if all_healthy {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
Response::builder()
.status(status)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
}
// ── Gateway Web UI ───────────────────────────────────────────────────
/// Self-contained HTML page for the gateway web UI. Fetches project list from
/// `/api/gateway` and switches projects via `POST /api/gateway/switch`, which
/// internally calls the `switch_project` MCP tool logic.
const GATEWAY_UI_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Huskies Gateway</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 480px;
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
}
.header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.logo { font-size: 1.75rem; }
h1 { font-size: 1.25rem; font-weight: 600; color: #f8fafc; }
.subtitle { font-size: 0.8rem; color: #64748b; margin-top: 0.125rem; }
label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
select {
width: 100%;
padding: 0.625rem 0.875rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
color: #f1f5f9;
font-size: 0.9rem;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.875rem center;
padding-right: 2.5rem;
}
select:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.25); }
.active-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: rgba(99,102,241,0.15);
border: 1px solid rgba(99,102,241,0.4);
border-radius: 999px;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
color: #a5b4fc;
margin-top: 0.875rem;
}
.dot { width: 6px; height: 6px; border-radius: 50%; background: #6366f1; }
.status { margin-top: 1rem; font-size: 0.8rem; color: #64748b; min-height: 1.25rem; }
.status.ok { color: #4ade80; }
.status.err { color: #f87171; }
</style>
</head>
<body>
<div class="card">
<div class="header">
<span class="logo">🐺</span>
<div>
<h1>Huskies Gateway</h1>
<div class="subtitle">Multi-project orchestration</div>
</div>
</div>
<label for="project-select">Active Project</label>
<select id="project-select" onchange="switchProject(this.value)">
<option disabled>Loading…</option>
</select>
<div id="active-label" class="active-badge" style="display:none">
<span class="dot"></span>
<span id="active-name"></span>
</div>
<div id="status" class="status"></div>
</div>
<script>
async function loadState() {
try {
const r = await fetch('/api/gateway');
const data = await r.json();
const sel = document.getElementById('project-select');
sel.innerHTML = '';
for (const p of data.projects) {
const opt = document.createElement('option');
opt.value = p.name;
opt.textContent = p.name + ' — ' + p.url;
if (p.name === data.active) opt.selected = true;
sel.appendChild(opt);
}
document.getElementById('active-name').textContent = data.active;
document.getElementById('active-label').style.display = 'inline-flex';
} catch(e) {
document.getElementById('status').textContent = 'Failed to load state: ' + e;
document.getElementById('status').className = 'status err';
}
}
async function switchProject(name) {
const statusEl = document.getElementById('status');
statusEl.className = 'status';
statusEl.textContent = 'Switching…';
try {
const r = await fetch('/api/gateway/switch', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project: name})
});
const data = await r.json();
if (data.ok) {
document.getElementById('active-name').textContent = name;
statusEl.className = 'status ok';
statusEl.textContent = 'Switched to ' + name;
} else {
statusEl.className = 'status err';
statusEl.textContent = data.error || 'Switch failed';
loadState();
}
} catch(e) {
statusEl.className = 'status err';
statusEl.textContent = 'Error: ' + e;
loadState();
}
}
loadState();
</script>
</body>
</html>
"#;
/// Serve the gateway web UI HTML page at `GET /`.
#[handler]
pub async fn gateway_index_handler() -> Response {
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "text/html; charset=utf-8")
.body(Body::from(GATEWAY_UI_HTML))
}
/// `GET /api/gateway` — returns the list of registered projects and the active project.
#[handler]
pub async fn gateway_api_handler(state: Data<&Arc<GatewayState>>) -> Response {
let active = state.active_project.read().await.clone();
let projects: Vec<Value> = state
.config
.projects
.iter()
.map(|(name, entry)| {
json!({
"name": name,
"url": entry.url,
})
})
.collect();
let body = json!({ "active": active, "projects": projects });
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
}
/// Request body for `POST /api/gateway/switch`.
#[derive(Deserialize)]
struct SwitchRequest {
project: String,
}
/// `POST /api/gateway/switch` — switch the active project by calling the
/// `switch_project` MCP tool logic, then return `{"ok": true}` or `{"ok": false, "error": "..."}`.
#[handler]
pub async fn gateway_switch_handler(
state: Data<&Arc<GatewayState>>,
body: Json<SwitchRequest>,
) -> Response {
let params = json!({ "arguments": { "project": body.project } });
let resp = handle_switch_project(&params, &state).await;
let (ok, error) = if resp.result.is_some() {
(true, None)
} else {
let msg = resp
.error
.as_ref()
.map(|e| e.message.clone())
.unwrap_or_else(|| "unknown error".to_string());
(false, Some(msg))
};
let body_val = if ok {
json!({ "ok": true })
} else {
json!({ "ok": false, "error": error })
};
let status = if ok {
StatusCode::OK
} else {
StatusCode::BAD_REQUEST
};
Response::builder()
.status(status)
.header("Content-Type", "application/json")
.body(Body::from(
serde_json::to_vec(&body_val).unwrap_or_default(),
))
}
// ── Gateway server startup ───────────────────────────────────────────
/// Start the gateway HTTP server. This is the entry point when `--gateway` is used.
pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
let config = GatewayConfig::load(config_path).map_err(std::io::Error::other)?;
let state = GatewayState::new(config).map_err(std::io::Error::other)?;
let state_arc = Arc::new(state);
let active = state_arc.active_project.read().await.clone();
crate::slog!("[gateway] Starting gateway on port {port}, active project: {active}");
crate::slog!(
"[gateway] Registered projects: {}",
state_arc
.config
.projects
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
// Locate the gateway config directory (parent of `projects.toml`).
let config_dir = config_path
.parent()
.unwrap_or(std::path::Path::new("."))
.to_path_buf();
// Write `.mcp.json` so that the gateway's Matrix bot's Claude Code CLI
// connects to this gateway's MCP endpoint (which proxies to the active project).
if let Err(e) = write_gateway_mcp_json(&config_dir, port) {
crate::slog!("[gateway] Warning: could not write .mcp.json: {e}");
}
// Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory.
let gateway_projects: Vec<String> = state_arc.config.projects.keys().cloned().collect();
spawn_gateway_bot(
&config_dir,
Arc::clone(&state_arc.active_project),
gateway_projects,
port,
);
let route = poem::Route::new()
.at("/", poem::get(gateway_index_handler))
.at("/api/gateway", poem::get(gateway_api_handler))
.at("/api/gateway/switch", poem::post(gateway_switch_handler))
.at(
"/mcp",
poem::post(gateway_mcp_post_handler).get(gateway_mcp_get_handler),
)
.at("/health", poem::get(gateway_health_handler))
.data(state_arc);
let host = std::env::var("HUSKIES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let addr = format!("{host}:{port}");
crate::slog!("[gateway] Listening on {addr}");
poem::Server::new(poem::listener::TcpListener::bind(&addr))
.run(route)
.await
}
// ── Matrix bot integration ───────────────────────────────────────────
/// Write (or overwrite) a `.mcp.json` in `config_dir` that points Claude Code
/// CLI at the gateway's own `/mcp` endpoint. This lets the gateway's Matrix
/// bot use gateway-proxied tool calls instead of a project-specific server.
fn write_gateway_mcp_json(config_dir: &Path, port: u16) -> Result<(), std::io::Error> {
let host = std::env::var("HUSKIES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let url = format!("http://{host}:{port}/mcp");
let content = serde_json::json!({
"mcpServers": {
"huskies": {
"type": "http",
"url": url
}
}
});
let path = config_dir.join(".mcp.json");
std::fs::write(&path, serde_json::to_string_pretty(&content).unwrap())?;
crate::slog!("[gateway] Wrote {} pointing to {}", path.display(), url);
Ok(())
}
/// Attempt to spawn the Matrix bot against the gateway config directory.
///
/// Reads `<config_dir>/.huskies/bot.toml`. If absent or disabled the function
/// returns immediately without spawning anything. When the bot is enabled it
/// receives a shared reference to the gateway's active-project `RwLock` so the
/// `switch` command can change the active project without going through HTTP.
fn spawn_gateway_bot(
config_dir: &Path,
active_project: ActiveProject,
gateway_projects: Vec<String>,
port: u16,
) {
use crate::agents::AgentPool;
use tokio::sync::{broadcast, mpsc};
// Create a watcher broadcast channel (no file-system watcher in gateway mode).
let (watcher_tx, _) = broadcast::channel(16);
// Create a dummy permission channel — permission prompts are not forwarded
// across the proxy boundary in this initial implementation.
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
let perm_rx = Arc::new(tokio::sync::Mutex::new(perm_rx));
// Create a shutdown watch channel. Gateway process exit signals Ctrl-C
// via OS signal, not through a watch channel, so we leave this at None
// (no shutdown announcement). The sender is kept alive for the duration.
let (shutdown_tx, shutdown_rx) =
tokio::sync::watch::channel::<Option<crate::rebuild::ShutdownReason>>(None);
// Keep sender alive so the receiver is never prematurely closed.
std::mem::forget(shutdown_tx);
let agents = Arc::new(AgentPool::new(port, watcher_tx.clone()));
crate::chat::transport::matrix::spawn_bot(
config_dir,
watcher_tx,
perm_rx,
agents,
shutdown_rx,
Some(active_project),
gateway_projects,
);
}
// ── Tests ────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_projects_toml() {
let toml_str = r#"
[projects.huskies]
url = "http://localhost:3001"
[projects.robot-studio]
url = "http://localhost:3002"
"#;
let config: GatewayConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.projects.len(), 2);
assert_eq!(config.projects["huskies"].url, "http://localhost:3001");
assert_eq!(config.projects["robot-studio"].url, "http://localhost:3002");
}
#[test]
fn parse_empty_projects_toml() {
let toml_str = "[projects]\n";
let config: GatewayConfig = toml::from_str(toml_str).unwrap();
assert!(config.projects.is_empty());
}
#[test]
fn gateway_state_rejects_empty_config() {
let config = GatewayConfig {
projects: BTreeMap::new(),
};
assert!(GatewayState::new(config).is_err());
}
#[test]
fn gateway_state_sets_first_project_active() {
let mut projects = BTreeMap::new();
projects.insert(
"alpha".into(),
ProjectEntry {
url: "http://a:3001".into(),
},
);
projects.insert(
"beta".into(),
ProjectEntry {
url: "http://b:3002".into(),
},
);
let config = GatewayConfig { projects };
let state = GatewayState::new(config).unwrap();
let active = state.active_project.blocking_read().clone();
assert_eq!(active, "alpha"); // BTreeMap sorts alphabetically.
}
#[test]
fn gateway_tool_definitions_has_expected_tools() {
let defs = gateway_tool_definitions();
let names: Vec<&str> = defs
.iter()
.filter_map(|d| d.get("name").and_then(|n| n.as_str()))
.collect();
assert!(names.contains(&"switch_project"));
assert!(names.contains(&"gateway_status"));
assert!(names.contains(&"gateway_health"));
}
#[tokio::test]
async fn switch_project_to_known_project() {
let mut projects = BTreeMap::new();
projects.insert(
"alpha".into(),
ProjectEntry {
url: "http://a:3001".into(),
},
);
projects.insert(
"beta".into(),
ProjectEntry {
url: "http://b:3002".into(),
},
);
let config = GatewayConfig { projects };
let state = GatewayState::new(config).unwrap();
let params = json!({ "arguments": { "project": "beta" } });
let resp = handle_switch_project(&params, &state).await;
assert!(resp.result.is_some());
let active = state.active_project.read().await.clone();
assert_eq!(active, "beta");
}
#[tokio::test]
async fn switch_project_to_unknown_project_fails() {
let mut projects = BTreeMap::new();
projects.insert(
"alpha".into(),
ProjectEntry {
url: "http://a:3001".into(),
},
);
let config = GatewayConfig { projects };
let state = GatewayState::new(config).unwrap();
let params = json!({ "arguments": { "project": "nonexistent" } });
let resp = handle_switch_project(&params, &state).await;
assert!(resp.error.is_some());
}
#[tokio::test]
async fn active_url_returns_correct_url() {
let mut projects = BTreeMap::new();
projects.insert(
"myproj".into(),
ProjectEntry {
url: "http://my:3001".into(),
},
);
let config = GatewayConfig { projects };
let state = GatewayState::new(config).unwrap();
let url = state.active_url().await.unwrap();
assert_eq!(url, "http://my:3001");
}
#[test]
fn json_rpc_response_success_serializes() {
let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true}));
let s = serde_json::to_string(&resp).unwrap();
assert!(s.contains("\"result\""));
assert!(!s.contains("\"error\""));
}
#[test]
fn json_rpc_response_error_serializes() {
let resp = JsonRpcResponse::error(Some(json!(1)), -32600, "bad".into());
let s = serde_json::to_string(&resp).unwrap();
assert!(s.contains("\"error\""));
assert!(!s.contains("\"result\""));
}
#[test]
fn load_config_from_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("projects.toml");
std::fs::write(
&path,
r#"
[projects.test]
url = "http://localhost:9999"
"#,
)
.unwrap();
let config = GatewayConfig::load(&path).unwrap();
assert_eq!(config.projects.len(), 1);
assert_eq!(config.projects["test"].url, "http://localhost:9999");
}
#[test]
fn load_config_missing_file_fails() {
let result = GatewayConfig::load(Path::new("/nonexistent/projects.toml"));
assert!(result.is_err());
}
// ── bot.toml in gateway and standalone modes ─────────────────────────
//
// Both gateway and standalone modes load bot.toml via `BotConfig::load(dir)`
// which looks for `dir/.huskies/bot.toml`. These tests document that the
// same loading convention works from a gateway config directory.
#[test]
fn bot_config_loads_from_gateway_config_dir() {
use crate::chat::transport::matrix::BotConfig;
let tmp = tempfile::tempdir().unwrap();
let huskies_dir = tmp.path().join(".huskies");
std::fs::create_dir_all(&huskies_dir).unwrap();
std::fs::write(
huskies_dir.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
"#,
)
.unwrap();
// Gateway passes config_dir (parent of projects.toml) to spawn_bot,
// which calls BotConfig::load(config_dir). Verify this resolves correctly.
let config = BotConfig::load(tmp.path());
assert!(
config.is_some(),
"bot.toml should load from gateway config dir"
);
let config = config.unwrap();
assert_eq!(
config.homeserver.as_deref(),
Some("https://matrix.example.com")
);
}
#[test]
fn bot_config_absent_returns_none_in_gateway_mode() {
use crate::chat::transport::matrix::BotConfig;
// A gateway config directory without a .huskies/bot.toml should yield None,
// allowing the gateway to start without a Matrix bot.
let tmp = tempfile::tempdir().unwrap();
let config = BotConfig::load(tmp.path());
assert!(
config.is_none(),
"absent bot.toml must return None in gateway mode"
);
}
#[test]
fn bot_config_disabled_returns_none_in_gateway_mode() {
use crate::chat::transport::matrix::BotConfig;
let tmp = tempfile::tempdir().unwrap();
let huskies_dir = tmp.path().join(".huskies");
std::fs::create_dir_all(&huskies_dir).unwrap();
std::fs::write(
huskies_dir.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = false
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path());
assert!(
config.is_none(),
"disabled bot.toml must return None in gateway mode"
);
}
}