hecks

The MCP servers stay stdio-only, and a network transport needs real authentication first

Status: Proposed (draft). The stdio gate described under “What is enforced today” shipped with this ADR. Nothing under “Decision” is built, and this ADR does not authorize building a network transport; it says what has to be true before anyone does.

Context

bin/hecks_mcp_door (the Storehouse bus, lib/hecks/storehouse.rb) and bin/hecks_query_ir_mcp (structural queries over the language, lib/hecks/query_ir.rb) are registered in .mcp.json and speak MCP over stdio. An outside production-readiness review (item 8) said both are unauthenticated beyond a caller-asserted role/actor_id. That is true of the door and slightly understated for the query-IR server, which has no identity at all. The README already said both are stdio-only and self-asserted; before this change nothing in the code enforced either statement.

What is enforced today

Claim Enforced by Proved by
Both servers run only over stdio Hecks::McpStdioGuard.enforce_stdio!, called before either server loads anything: refuses an argument other than --stdio, any HECKS_MCP_* variable except HECKS_MCP_TRANSPORT=stdio, and an IP socket as stdin or stdout (the shape a socat/inetd wrapper produces). A Unix-domain socket is accepted, because some MCP clients spawn servers over a socketpair. spec/mcp_servers_spec.rb
Startup says what is and is not protected McpStdioGuard.warn!, written to stderr only (stdout carries the protocol) spec/mcp_servers_spec.rb
A role-gated command is refused when the caller supplies no role Storehouse.require_caller_for_role_gated!, for dispatch, dry_run and every step of a batch, in the short and qualified spellings spec/storehouse_spec.rb, and through the real door in spec/mcp_servers_spec.rb
domain: cannot boot code outside the project root Storehouse.confine! against BOOT_ROOT spec/storehouse_spec.rb (domains, validate)
query_ir_duplicates cannot load Ruby from outside the project root confined_domains in bin/hecks_query_ir_mcp, using the same confine! spec/mcp_servers_spec.rb

What is not enforced, and is not authentication

The only thing standing between a stranger and either server is that reaching stdin means already being able to run the process.

Decision (proposed)

  1. Stay stdio-only. Both servers keep refusing any other transport. Remote use goes through a transport that authenticates the person and hands the server a pipe, for example ssh host bin/hecks_mcp_door. The gate accepts that, and the person is authenticated by SSH rather than by the request body.
  2. bin/hecks_query_ir_mcp never gets a network transport. It is meta-tooling for working on hecks and loads bluebooks as Ruby.
  3. If the Storehouse bus is ever served over a network, it is a new door beside the stdio one, not a flag on it, and it does not ship until all of the following hold.
    • Transport. MCP over HTTP with TLS, bound to loopback unless an operator opts in, with Origin checking so a browser page cannot drive a local server.
    • Authentication. Every request carries a credential the server verifies before parsing the tool call: OAuth 2.1 bearer tokens as the MCP authorization specification describes, or mutual TLS. A shared static secret is acceptable only for a single-operator deployment, and it gives no per-principal audit.
    • Identity comes from the credential. The principal is derived from the verified token. role: and actor_id: request arguments are rejected when a principal is present, and roles are looked up from Governance::RoleAssignment for that principal. The audit log records the principal, and records claimed fields separately.
    • Every tool is authorized, not only dispatch. The readers get per-tool grants. query is bound to the principal’s tenant through Runtime::TenantScope.
    • No code loading per request. A network door serves an allowlist of domains booted at startup. domain: paths, validate, behaviors and domains do not accept caller-supplied paths there.
    • Limits. Request size, per-principal rate limits and per-call time limits, because a caller can now be anyone with a token.
  4. The claim stays checkable. Any network door adds specs that send an unauthenticated request, a forged token and a valid token with a forged role:, and prove the first two are refused and the last is ignored.

Alternatives considered

Consequences

Open questions