From 405d29d933276946107857dc06d5fd6b77c303d2 Mon Sep 17 00:00:00 2001 From: Huskies Agent Date: Sat, 18 Jul 2026 01:44:40 +0000 Subject: [PATCH] huskies: merge 1208 story Ops/LLM sessions can reach gateway-mode + cross-project MCP (the biggest shell-fallback cause) --- .../specs/tech/GATEWAY_CROSS_PROJECT_MCP.md | 127 +++++++++++++++ server/src/http/gateway/mcp.rs | 144 +++++++++++++++++- server/src/service/gateway/mod.rs | 91 ++++++++++- 3 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 .huskies/specs/tech/GATEWAY_CROSS_PROJECT_MCP.md diff --git a/.huskies/specs/tech/GATEWAY_CROSS_PROJECT_MCP.md b/.huskies/specs/tech/GATEWAY_CROSS_PROJECT_MCP.md new file mode 100644 index 00000000..e0f46a35 --- /dev/null +++ b/.huskies/specs/tech/GATEWAY_CROSS_PROJECT_MCP.md @@ -0,0 +1,127 @@ +# Story 1208: Cross-Project MCP for Ops/LLM Sessions + +## 1. Problem Statement + +An ops/LLM session connects to a `huskies --gateway` instance's `/mcp` +endpoint. Before this story, the *only* way to act on a specific registered +project was: + +1. Call `switch_project` (mutates the gateway's shared, global + `GatewayState.active_project`), then +2. Call the ordinary project-level tool (`create_story`, `get_story_todos`, + `show`, …), which the gateway silently proxies to whichever project is + currently active. + +This has two problems: +- **Race condition**: `active_project` is one value shared by every + connected client. Two concurrent ops sessions targeting different projects + will step on each other's `switch_project` calls. +- **No true "read a named project once" path**: for a single lookup against + a project that isn't the current default, a caller had to mutate shared + state just to read something, then (optionally) switch back. + +The practical consequence (and the reason this story exists) is that +operators and LLM agents fall back to hand-crafting raw JSON-RPC requests +directly against a project's own container port, bypassing the gateway +entirely — the "shell-fallback" this story is named for. + +## 2. Chosen Mechanism: Per-Call `project` Argument + +Any `tools/call` request for a non-gateway (proxied) tool may now include an +optional top-level `project` field inside `arguments`: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "create_story", + "arguments": { + "name": "Fix login bug", + "acceptance_criteria": ["..."], + "origin": "...", + "project": "robot-studio" + } + } +} +``` + +- If `project` is present and non-empty, the gateway looks it up in + `projects.toml` (`GatewayState.projects`) and proxies the call directly to + that project's live sled-uplink WebSocket connection — + `GatewayState::proxy_mcp_for_project` in + `server/src/service/gateway/mod.rs`. `GatewayState.active_project` is + **not read or mutated** by this path. +- If `project` is absent (the common case, and all pre-existing behavior), + the call proxies to whichever project is currently active, exactly as + before — full backward compatibility with existing sessions and + `switch_project`-based workflows. +- An unknown project name returns a JSON-RPC `-32602` (invalid params) + error listing the registered project names. A known project with no live + WS-uplink connection returns `-32603` naming the sled, matching the + existing `active_project` proxy error shape. + +Implementation: `server/src/http/gateway/mcp.rs` +(`gateway_mcp_post_handler`'s `tools/call` branch, +`proxy_and_respond_for_project`) and +`server/src/service/gateway/mod.rs` (`GatewayState::sled_connection_for`, +`GatewayState::proxy_mcp_for_project`, generalized from the existing +`active_sled_connection` / `proxy_active_mcp`). + +### Schema discoverability + +`tools/list` merges gateway tools with the active project's own tool list. +Every merged (proxied) tool's `inputSchema.properties` gets a `project` +property injected (`inject_project_arg_schema` in `http/gateway/mcp.rs`) so +MCP clients that validate call arguments against the declared schema before +sending don't strip or reject the extra field. This is additive only — no +existing property, and no `required` list, is touched. + +### Why not mirror every tool at the gateway level? + +Rejected alternative: define a `project_create_story`, `project_show`, etc. +for every project-level tool at the gateway. This was rejected because it +duplicates ~15+ tool schemas and dispatch arms and drifts out of sync every +time a project-level tool's schema changes. A single per-call argument that +every proxied tool call can carry scales to new project-level tools for +free. + +## 3. Fleet-Wide Reads (AC 2) + +These already existed as gateway-level tools before this story and needed +no code change — listed here for completeness of the "how an ops session +connects" picture: + +| Tool | Purpose | +|------|---------| +| `list_projects` | Every registered project: name, url, ssh_port, host_path, adopted/built-in marker, active marker. No liveness check. | +| `gateway_health` | Per-project health (WS heartbeat or HTTP poll) plus CRDT event-relay staleness. | +| `aggregate_pipeline_status` | Pipeline stage counts and blocked/failing items across every registered project, fetched in parallel. | +| `fleet_identity` | (Story 1206) Per-sled identity pin vs. live signed identity, and TOFU re-pin. | + +## 4. How an Ops Session Should Connect + +1. Point the MCP client at the gateway's `/mcp` endpoint + (`http://:/mcp`), the same endpoint local agents use + — there is no separate "ops" endpoint. +2. Call `tools/list` to see the merged tool surface (gateway tools + the + active project's tools, each carrying the optional `project` schema + property). +3. For a one-off call against a specific project, pass `project: ""` + inside `arguments` on that call — no `switch_project` required, and no + risk of racing another session's active-project selection. +4. For fleet-wide questions (is anything down, what's blocked everywhere), + use `list_projects`, `gateway_health`, or `aggregate_pipeline_status` + directly; they already scan every registered project. +5. `switch_project` remains available for sessions that want a persistent + default (e.g. an interactive chat session working one project at a + time) — it is unaffected by this change. + +## 5. Design Review Note (AC 4) + +This document captures the chosen approach (per-call `project` argument, +generalized proxy functions, additive schema injection) as required by AC 4. +No new gateway-level tool surface was added for AC 1 — the existing proxy +path was extended instead, minimizing new schema/dispatch surface area and +keeping every future project-level tool automatically cross-project-capable. diff --git a/server/src/http/gateway/mcp.rs b/server/src/http/gateway/mcp.rs index e3ab2a1b..d1cd84fe 100644 --- a/server/src/http/gateway/mcp.rs +++ b/server/src/http/gateway/mcp.rs @@ -298,7 +298,21 @@ pub async fn gateway_mcp_post_handler( handle_gateway_tool(tool_name, &rpc.params, &state, rpc.id.clone()).await, ) } else { - proxy_and_respond(&state, &bytes, rpc.id).await + // 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, @@ -323,6 +337,36 @@ async fn proxy_and_respond(state: &GatewayState, bytes: &[u8], id: Option } } +/// Proxy a request to an explicitly named project (story 1208 AC 1) rather +/// than whatever project is currently active, so a single ops/LLM session +/// can address any registered project per-call without a prior +/// `switch_project`. Returns a JSON-RPC error when `project` is not a +/// registered project name. +async fn proxy_and_respond_for_project( + state: &GatewayState, + project: &str, + bytes: &[u8], + id: Option, +) -> Response { + { + let projects = state.projects.read().await; + if let Err(e) = gateway::config::validate_project_exists(&projects, project) { + return to_json_response(JsonRpcResponse::error(id, -32602, e)); + } + } + match state.proxy_mcp_for_project(project, bytes).await { + Ok(resp_body) => Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(resp_body)), + Err(e) => to_json_response(JsonRpcResponse::error( + id, + -32603, + format!("proxy error: {e}"), + )), + } +} + /// Stream an MCP tool call to the active sled as SSE, re-emitting each `data:` /// event from the sled to the originating gateway client without buffering. /// @@ -441,12 +485,35 @@ async fn handle_tools_list( .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. @@ -1335,4 +1402,79 @@ mod tests { fn fleet_identity_is_in_gateway_tools() { assert!(GATEWAY_TOOLS.contains(&"fleet_identity")); } + + // ── 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"})); + } } diff --git a/server/src/service/gateway/mod.rs b/server/src/service/gateway/mod.rs index 682df647..8f2d80ab 100644 --- a/server/src/service/gateway/mod.rs +++ b/server/src/service/gateway/mod.rs @@ -354,6 +354,26 @@ impl GatewayState { self.sled_connections.write().await.remove(project_name); } + /// Look up the live sled connection for an explicit project name, + /// returning a clone if one exists and has a recent heartbeat. + /// + /// Returns `None` when no sled has connected for this project or when its + /// heartbeat is overdue. Generalizes [`GatewayState::active_sled_connection`] + /// to any registered project rather than only the active one (story 1208). + pub async fn sled_connection_for(&self, project_name: &str) -> Option { + let conn = self + .sled_connections + .read() + .await + .get(project_name) + .cloned()?; + if conn.is_alive(HEARTBEAT_MAX_AGE_MS) { + Some(conn) + } else { + None + } + } + /// Look up the live sled connection for the active project, returning a /// clone if one exists and has a recent heartbeat. /// @@ -361,12 +381,7 @@ impl GatewayState { /// heartbeat is overdue. pub async fn active_sled_connection(&self) -> Option { let name = self.active_project.read().await.clone(); - let conn = self.sled_connections.read().await.get(&name).cloned()?; - if conn.is_alive(HEARTBEAT_MAX_AGE_MS) { - Some(conn) - } else { - None - } + self.sled_connection_for(&name).await } /// Proxy an MCP request to the active project over its live sled-uplink @@ -389,6 +404,31 @@ impl GatewayState { )), } } + + /// Proxy an MCP request to an explicitly named project over its live + /// sled-uplink WebSocket, without reading or mutating `active_project` + /// (story 1208 AC 1). + /// + /// Lets a single ops/LLM session address any registered project by name + /// on a per-call basis instead of first mutating the shared + /// `active_project` via `switch_project` — which avoids the race where a + /// second concurrent caller's tool calls get proxied to the wrong + /// project mid-session. + /// + /// Returns the raw response body bytes ready to be relayed to the caller. + pub async fn proxy_mcp_for_project( + &self, + project: &str, + bytes: &[u8], + ) -> Result, String> { + match self.sled_connection_for(project).await { + Some(conn) => proxy_mcp_via_ws(&conn, bytes).await, + None => Err(format!( + "sled '{project}' has no live WS uplink connection; \ + ensure the sled is running and connected to this gateway" + )), + } + } } // ── Public API ────────────────────────────────────────────────────────────── @@ -989,6 +1029,45 @@ mod tests { assert!(!state.sled_connections.read().await.contains_key("myproj")); } + // ── explicit per-project proxy targeting (story 1208) ────────────────── + + #[tokio::test] + async fn sled_connection_for_unregistered_project_returns_none() { + let config = make_config(&[("alpha", "http://a:3001")]); + let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); + assert!(state.sled_connection_for("nonexistent").await.is_none()); + } + + #[tokio::test] + async fn sled_connection_for_finds_connection_by_explicit_name_not_active() { + let config = make_config(&[("alpha", "http://a:3001"), ("beta", "http://b:3002")]); + let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); + // "alpha" is active (first project), but the live connection is for "beta". + assert_eq!(*state.active_project.read().await, "alpha"); + + let (tx, _rx) = mpsc::unbounded_channel(); + let conn = SledConnection { + tx, + last_heartbeat_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())), + in_flight: Arc::new(TokioMutex::new(HashMap::new())), + }; + state + .register_sled_connection("beta".to_string(), conn) + .await; + + assert!(state.sled_connection_for("beta").await.is_some()); + assert!(state.active_sled_connection().await.is_none()); + } + + #[tokio::test] + async fn proxy_mcp_for_project_without_live_connection_fails() { + let config = make_config(&[("alpha", "http://a:3001")]); + let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); + let result = state.proxy_mcp_for_project("alpha", b"{}").await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("no live WS uplink")); + } + #[tokio::test] async fn auth_token_in_project_entry_populates_sled_tokens_map() { let mut projects = BTreeMap::new();