128 lines
5.8 KiB
Markdown
128 lines
5.8 KiB
Markdown
# 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.
|