Files
alkhttp/docs/architecture/decisions/041-mcp-tool-gateway-pattern.md
T
glm-5.3-flash 320ea87b08 docs: port architecture specs and ADRs from alknet-http; write new alkhttp ADRs 067-070
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.
2026-08-27 14:19:24 +00:00

13 KiB

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). 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 §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 (searchschemacall). 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) 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 — External/Internal visibility (Internal ops excluded from services/list, therefore from search)
  • ADR-017to_* 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 §4 — browsers/MCP clients are not call-protocol peers (bearer token, no PeerId)
  • ADR-036 — the HTTP-to-call dispatch path the call tool reuses
  • ADR-037 — 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.rslist_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").
  • SubscriptionSub 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.mddocs/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.