Files
huskies/.huskies/specs/tech/GATEWAY_CROSS_PROJECT_MCP.md
T

5.8 KiB

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:

{
  "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.