huskies: merge 1208 story Ops/LLM sessions can reach gateway-mode + cross-project MCP (the biggest shell-fallback cause)
This commit is contained in:
@@ -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://<gateway-host>:<port>/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: "<name>"`
|
||||
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.
|
||||
@@ -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<Value>
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
@@ -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"}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SledConnection> {
|
||||
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<SledConnection> {
|
||||
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<Vec<u8>, 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();
|
||||
|
||||
Reference in New Issue
Block a user