Phase 1 (SDD) — architecture documentation: Ported specs (adapted for alkcall, producer/consumer terms, 6-endpoint gateway, channels-over-WS, Sub/Pub operation types): - overview.md, http-server.md, http-adapters.md, http-mcp.md - README.md index (rewritten for alkhttp) New ADRs: - 067: WebSocket carries the channels protocol (8-byte chunk demux, channel 0 = alk/call, upgrade path /alk/channels) - 068: gateway /publish endpoint for Pub operations (NDJSON body) - 069: WebTransport out of scope in alkhttp (alknet concern) - 070: from_wss consumer adapter (wss feature, tokio-tungstenite) Ported ADRs (25, same numbers, port notes + amendments where the extraction changed facts): 001-004, 010, 014, 015, 017, 022, 023, 027, 034, 036, 037, 039, 041, 042, 044, 045, 046, 047, 048, 049, 051, 066. websocket.md rewritten for the channels session; open-questions.md seeded (OQ-01 WS byte-stream adapter, OQ-02 /publish framing, OQ-03 from_wss reconnect, OQ-04 browser client ownership). Verified: cargo test, clippy -D warnings, fmt, doc --no-deps.
263 lines
13 KiB
Markdown
263 lines
13 KiB
Markdown
# ADR-041: MCP Tool-Gateway Pattern for to_mcp
|
|
|
|
*Ported from alknet ADR-041 (MCP Tool-Gateway Pattern for to_mcp); re-targeted to alkhttp.*
|
|
|
|
## Status
|
|
|
|
Proposed
|
|
|
|
## Context
|
|
|
|
The current `to_mcp` spec (`docs/architecture/http-mcp.md`) describes
|
|
`to_mcp` as "exposes the local registry's `External` operations as MCP
|
|
tools" — one MCP tool per alkhttp operation. An LLM connecting to an
|
|
alkhttp node with 200 registered operations gets 200 MCP tools dumped
|
|
into its context. This is the **tool-bloat problem**: the LLM's context
|
|
is bloated with tools that are irrelevant to the current task, degrading
|
|
its reasoning and wasting context budget.
|
|
|
|
### The problem in concrete terms
|
|
|
|
The MCP `tools/list` response returns every tool the server exposes.
|
|
An MCP client (an editor, an AI tool) loads all of them into the LLM's
|
|
context as tool definitions. An alkhttp node exposing 200 operations
|
|
produces a `tools/list` response with 200 `Tool` structs, each with a
|
|
name, description, and `inputSchema` (JSON Schema). The LLM sees 200
|
|
tool definitions whether it needs them or not. This is the same anti-
|
|
pattern as loading every man page into a shell's environment —
|
|
absurd, but it's what the naive one-tool-per-operation mapping produces.
|
|
|
|
### The pattern that works
|
|
|
|
The project already has two examples of a better pattern:
|
|
|
|
1. **The `memory` tool** (opencode): read-only access to the underlying
|
|
session database. The LLM doesn't load all past sessions into
|
|
context — it calls `memory` with a search query when it needs to
|
|
recall something. One tool, access to a large dataset on demand.
|
|
2. **The `worktree` tool** (opencode): gates 8-10 sub-tools behind a
|
|
single `worktree` entry point. The LLM has one tool in context; the
|
|
sub-tools are discovered and invoked through it.
|
|
|
|
The general principle (same as Linux's `man` command): **don't load all
|
|
documentation/tools into context 24/7; expose a small fixed set of
|
|
meta-tools that gate access to the full set on demand.**
|
|
|
|
### The call protocol's discovery surface
|
|
|
|
The call protocol already has the discovery primitives that make this
|
|
work:
|
|
|
|
- `services/list` — lists registered operations (filtered by
|
|
`AccessControl`).
|
|
- `services/schema` — returns an operation's `OperationSpec`
|
|
(input/output JSON Schemas, error schemas).
|
|
|
|
The `to_mcp` gateway exposes these primitives (plus invocation) as a
|
|
small fixed set of MCP tools. The LLM searches for what it needs, learns
|
|
the schema, then calls — instead of having every operation pre-loaded.
|
|
|
|
## Decision
|
|
|
|
### 1. `to_mcp` exposes a fixed gateway tool set, not one tool per operation
|
|
|
|
`to_mcp` exposes a small fixed set of MCP tools that gate access to the
|
|
full operation registry. The LLM has a few tools in context (not
|
|
hundreds); it discovers and invokes operations through the gateway.
|
|
|
|
The gateway tool set (initial, two-way-door extensible):
|
|
|
|
| MCP tool | Call protocol operation | Purpose |
|
|
|----------|------------------------|---------|
|
|
| `search` | `services/list` | List/search available operations (filtered by the caller's `AccessControl`). The LLM discovers what it can call. |
|
|
| `schema` | `services/schema` | Get an operation's `OperationSpec` (input/output JSON Schemas, error schemas). The LLM learns how to call a specific operation. |
|
|
| `call` | `call.requested` (Query/Mutation) | Invoke an operation by name with a JSON input. Returns the operation's output (or a typed error per the alkcall crate's ADR-016, `decisions/016-operation-error-schemas.md`). |
|
|
| `batch` | multiple `call.requested` | Invoke multiple operations in one tool call (correlated request IDs, OQ-14). The LLM batches independent calls. |
|
|
|
|
Four tools. The LLM calls `search` to find operations relevant to its
|
|
task, `schema` to learn the input shape, `call` to invoke. Same pattern
|
|
as `man <command>` — discover on demand, don't preload.
|
|
|
|
Sub (streaming responses) AND Pub (streaming requests, alkcall ADR-046)
|
|
operations are both excluded from to_mcp — MCP tool calls are
|
|
request/response.
|
|
|
|
### 2. Sub operations are excluded from the MCP gateway
|
|
|
|
MCP tool calls are request/response — an LLM invokes a tool and
|
|
receives a result. The call protocol's `Sub` type
|
|
(streaming, many `call.responded` events) does not map onto the MCP
|
|
tool-call model. The gateway exposes only `Query` and `Mutation`
|
|
operations (request/response). `Sub` operations are filtered
|
|
out of `search` results and cannot be invoked via `call`.
|
|
|
|
This is a deliberate scoping decision, not a deferral: MCP tool calls
|
|
are request/response by protocol design; streaming subscriptions are a
|
|
different interaction model that doesn't fit the LLM tool-call pattern.
|
|
If a future MCP extension adds streaming tool calls, the gateway could
|
|
expose `Sub` operations through it — but that's a future MCP
|
|
spec question, not an alkhttp decision.
|
|
|
|
### 3. `search` returns names + descriptions, not full schemas
|
|
|
|
The `search` tool (backed by `services/list`) returns operation names,
|
|
namespaces, types, and short descriptions — not the full input/output
|
|
JSON Schemas. This keeps the search result small (the LLM is choosing
|
|
what to call, not how to call it yet). The LLM calls `schema` for the
|
|
specific operation it wants to invoke, getting the full `OperationSpec`
|
|
only when needed. Two-step discovery: search (cheap, list) → schema
|
|
(targeted, full spec).
|
|
|
|
### 4. `call` maps to the call protocol's request/response dispatch
|
|
|
|
The `call` tool takes `{ operation: "/fs/readFile", input: { ... } }`
|
|
and dispatches through the `OperationRegistry::invoke()` — the same
|
|
dispatch path the HTTP server uses
|
|
([ADR-036](decisions/036-http-to-call-operation-mapping.md)). The result
|
|
is mapped to an MCP `CallToolResult` (`structuredContent` for the
|
|
output, or `isError: true` for a `CallError` with the typed `details`
|
|
payload per the alkcall crate's ADR-016,
|
|
`decisions/016-operation-error-schemas.md`). The `batch` tool takes an
|
|
array of `{ operation, input }` pairs and returns an array of results.
|
|
|
|
### 5. `AccessControl` gates the gateway
|
|
|
|
The `search` tool's results are filtered by the caller's
|
|
`AccessControl::check(identity)` — the LLM (authenticated by bearer
|
|
token, [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) §4)
|
|
sees only the operations it is authorized to call.
|
|
The `call` tool's dispatch runs the same `AccessControl` check. An
|
|
LLM that calls `call` with an operation it isn't authorized for gets
|
|
`FORBIDDEN` (mapped to an MCP error result). The gateway does not
|
|
bypass the call protocol's authorization — it's the same dispatch
|
|
path, just reached through an MCP tool call instead of an HTTP request.
|
|
|
|
## Consequences
|
|
|
|
**Positive:**
|
|
- The LLM has 4 tools in context, not hundreds. Context budget is
|
|
preserved for the actual task; the LLM discovers operations on
|
|
demand through `search` + `schema`. This is the same pattern that
|
|
makes the `memory` and `worktree` tools effective.
|
|
- The gateway maps onto the call protocol's existing discovery
|
|
primitives (`services/list`, `services/schema`) and dispatch
|
|
(`OperationRegistry::invoke`). No new call-protocol mechanisms
|
|
needed — `to_mcp` is a thin wrapper around the existing surface.
|
|
- `AccessControl` gates the gateway. An LLM sees only what it's
|
|
authorized to call; the gateway doesn't leak operation existence or
|
|
schemas to unauthorized callers.
|
|
- `Sub` exclusion is explicit. The LLM tool-call model is
|
|
request/response; streaming doesn't fit, and pretending it does
|
|
would produce a broken mapping.
|
|
|
|
**Negative:**
|
|
- The LLM needs two round-trips to call an operation it hasn't seen
|
|
before (`search` → `schema` → `call`). A one-tool-per-operation
|
|
mapping would let it call directly. The tradeoff: 4 tools in context
|
|
+ 2 discovery round-trips vs. 200 tools in context + 0 round-trips.
|
|
The context budget is the scarcer resource; the round-trips are
|
|
cheap (the MCP server is local or nearby).
|
|
- The `search` tool's result format (names + descriptions, not full
|
|
schemas) means the LLM may need to call `schema` for multiple
|
|
operations before finding the right one. Mitigated: `search` can
|
|
accept a query/filter (namespace, keyword) to narrow results.
|
|
- The gateway tool set is fixed (4 tools). An operation that wants a
|
|
custom MCP tool (e.g., a specialized `git_clone` tool with a curated
|
|
input schema, not the generic `call` wrapper) is not exposed through
|
|
the gateway. A future "custom tool" extension could allow operations
|
|
to declare an MCP tool projection — but the gateway pattern is the
|
|
default, and the custom-tool path is additive (not a replacement).
|
|
|
|
## Assumptions
|
|
|
|
1. **The LLM context budget is the scarcer resource.** The tradeoff
|
|
favoring 4 tools + discovery round-trips over 200 preloaded tools
|
|
assumes the LLM's context window is more valuable than the network
|
|
round-trips. This holds for current LLMs (context windows are
|
|
large but not unlimited; tool definitions consume context
|
|
proportionally to their schemas).
|
|
|
|
2. **`Query` and `Mutation` cover the LLM tool-call use case.**
|
|
LLMs invoke tools in a request/response pattern: call a tool,
|
|
receive a result, reason about it. Streaming subscriptions
|
|
(`call.responded` events over time) don't fit this pattern — the
|
|
LLM expects one result per tool call. The assumption is that the
|
|
operations an LLM wants to call are `Query`/`Mutation`, not
|
|
`Sub`.
|
|
|
|
3. **The gateway tool set is stable.** Once LLM clients build
|
|
prompts/workflows against the `search`/`schema`/`call`/`batch`
|
|
tool set, changing the tool surface (renaming, removing) breaks
|
|
them. Adding tools is additive (non-breaking); removing or renaming
|
|
is a one-way door. The initial 4-tool set is the published contract.
|
|
|
|
4. **`AccessControl` filtering is sufficient for `search`.** The LLM
|
|
sees the operations it's authorized to call. If an operation's
|
|
existence is itself sensitive (the LLM shouldn't know it exists
|
|
even if it can't call it), `Visibility::Internal`
|
|
([ADR-015](015-privilege-model-and-authority-context.md)) is the
|
|
mechanism — Internal ops are excluded from `services/list` and
|
|
therefore from `search` results. The gateway does not add a
|
|
separate visibility layer.
|
|
|
|
## References
|
|
|
|
- [ADR-015](015-privilege-model-and-authority-context.md) —
|
|
External/Internal visibility (Internal ops excluded from
|
|
`services/list`, therefore from `search`)
|
|
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) —
|
|
`to_*` adapters are projections (consume the registry, don't
|
|
produce entries)
|
|
- the alkcall crate's ADR-016 (`decisions/016-operation-error-schemas.md`)
|
|
— typed error `details` mapped to MCP error results
|
|
- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) §4 —
|
|
browsers/MCP clients are not call-protocol peers (bearer token, no
|
|
`PeerId`)
|
|
- [ADR-036](decisions/036-http-to-call-operation-mapping.md) — the
|
|
HTTP-to-call dispatch path the `call` tool reuses
|
|
- [ADR-037](037-mcp-stdio-transport-exclusion.md) — streamable HTTP
|
|
only (the transport `to_mcp` uses)
|
|
- `docs/architecture/http-mcp.md` — this crate's spec that implements
|
|
the gateway
|
|
- `/workspace/rust-sdk/crates/rmcp/src/model/tool.rs` — the MCP
|
|
`Tool` struct (name, description, input_schema, output_schema)
|
|
- `/workspace/rust-sdk/crates/rmcp/src/handler/server.rs` —
|
|
`list_tools` / `call_tool` server trait (the interface `to_mcp`
|
|
implements)
|
|
|
|
## Port notes
|
|
|
|
- Renames: "alknet" → "alkhttp" where it referred to the crate/node
|
|
("an alknet operation" → "an alkhttp operation"; "an alknet node" →
|
|
"an alkhttp node"; "not an alknet decision" → "not an alkhttp
|
|
decision").
|
|
- **`Subscription` → `Sub`** throughout (alkcall rename; the
|
|
`OperationType` enum value).
|
|
- **MCP gateway exclusion widened (extension note):** the alknet
|
|
original excluded only `Subscription` operations. In alkhttp,
|
|
alkcall ADR-046 added `OperationType::Pub` (streaming requests,
|
|
producer→consumer). One line is added under §1: Sub (streaming
|
|
responses) AND Pub (streaming requests, alkcall ADR-046) operations
|
|
are both excluded from to_mcp — MCP tool calls are request/response.
|
|
§2 keeps the original `Subscription`-exclusion decision text, renamed
|
|
to `Sub`; the four-tool gateway set (search/schema/call/batch) is
|
|
unchanged.
|
|
- **Producer/consumer terminology**: §5 "browsers/MCP clients are not
|
|
alknet peers" → "browsers/MCP clients are not call-protocol peers"
|
|
(the ADR-034 peer-roles framing). HTTP/MCP server-client
|
|
directionality untouched (MCP client/server, `tools/list`,
|
|
`transport-*` names are protocol-inherent).
|
|
- **Cross-refs**: `../../decisions/...` → `decisions/...`; ADR-036 is
|
|
ported to this crate under the same number and linked as
|
|
`decisions/036-http-to-call-operation-mapping.md` (alknet slug).
|
|
The source cited alknet ADR-023 (operation error schemas) — its
|
|
alkcall decision record is ADR-016, cited textually per the
|
|
alkcall-cited-decisions convention.
|
|
- `crates/http/http-mcp.md` → `docs/architecture/http-mcp.md` (this
|
|
crate's docs/architecture/).
|
|
- No frontmatter in the source; status kept as Proposed.
|
|
- No decision content changed — the 4-tool gateway set, the
|
|
request/response exclusion rule, the two-step discovery shape, the
|
|
dispatch/`AccessControl` mapping, and the consequences/assumptions
|
|
are verbatim from the alknet ADR modulo the renames and the logged
|
|
Pub-exclusion extension line. |