ADR-0054: The MCP stdio bridge ships before the broker — read tools on by default, control behind one startup flag
Not yet accepted. Proposed in the PR for stump.wtf/harness#950 by the l lane. 📝 ADR-0035 already decided that Harness is the MCP gateway and that the facade tools (
harness_list,harness_describe,harness_logs,harness_start,harness_stop,harness_restart) delegate to the same handlers the CLI uses. It did not decide what happens before the broker, the per-harness endpoints and the spawn tokens exist, which is the state the tree is in today and the state an agent operating a daemon needs help with now.
Context and Problem Statement
Agents that drive a daemon shell out to the CLI: a compound command per check,
--json on some verbs and --raw on others, and a permission system that sees
one shell string doing both a read and a state change. Issue #950 asks for a
harness mcp subcommand that serves these operations as MCP tools over stdio.
📝 ADR-0035 and SPEC-0005 describe the full surface: upstream servers supervised by
the daemon, one filtered endpoint per harness, calls attributed by a per-spawn
HARNESS_MCP_TOKEN, and mcp_allow granting write per harness. None of that
is built: there is no MCP server in the tree, and [mcp.*] tables are a config
parse error. Building the bridge is a day of work; building the broker first is a
quarter of one.
How does an agent drive a daemon with tools today, without pre-empting the gateway's design or pretending to have an authority the daemon cannot attribute?
Decision
Ship harness mcp as a bridge, not a gateway: a stdio MCP server that is a
thin client of the daemon socket, exactly as every verb is (📝 ADR-0002), with
nothing new listening and no new trust boundary. Whoever can reach the socket can
already do everything the read tools report.
- Names come from the reserved facade namespace. The tools are
harness_list,harness_describe,harness_runs,harness_logs,harness_jobs,harness_triggers,harness_daemon_info,harness_trigger,harness_start,harness_stop,harness_restart, and the trajectory pair already registered ininternal/facade. When the broker lands, its endpoints serve the same names from the same registry; nothing here has to be renamed. - SPEC-0005's tool list grows four read entries.
harness_runs,harness_jobs,harness_triggersandharness_daemon_infoare classifiedread. SPEC-0005 excludeddaemon_infofrom the initial facade and said exposing it later requires classifying it; #950 asks for "daemon status" as a first-class tool, and a monitor that cannot ask the daemon whether it is up is not useful. No tool returns anything its control operation does not already return. - Read is on by default; control is opt-in at startup.
harness mcplists and answers read tools only. With--allow-controlit also lists and answersharness_trigger,harness_start,harness_stopandharness_restart. A write call against a read-only server is refused withnot_permittedbefore the daemon is called, and the tool is absent fromtools/list, so a monitoring agent's model never sees the option. Until the broker gives the daemon a caller to attribute, the startup flag is the whole grant, set by whoever launches the process; that is the operator's own authority, which is what 📝 ADR-0035 already assumes for the CLI. - Attribution is deferred, and the bridge says so. No
HARNESS_MCP_TOKENis minted or presented here, so every call is an unattributed caller in SPEC-0005's terms. This is safe only because control is off unless an operator turns it on for a specific process. When token attribution lands, it replaces the flag's authority, it does not add to it. - Logs are polled, not followed.
harness_logsanswerssummary(ordered activity entries) orraw(the log tail). A summary carriesnext_cursor, the stableLogEntry.IDof its newest kept entry, and acursorargument returns only newer entries; an aged-out cursor says so and returns the tail rather than nothing. A raw payload pages by byteoffsetwithnext_offset, and an offset past the returned tail repeats the whole tail with anote: the tail is a window, so an empty answer there would read as "caught up" to a poller that had in fact skipped output. Both are size-capped (256 KiB default) and saytruncated. A cursor is an entry id, not an offset, because ids are stable across requests and offsets are not. Real subscriptions (harness://<name>/runs/<id>/log) stay with the broker, where the daemon can push. - One control call at a time. The MCP session answers requests concurrently
so a followed trigger cannot stall the session, but the control connection is
a single request/response stream with no lock of its own; the CLI wrapper
therefore serializes calls per call, not per tool. This is a transport fact
about
internal/client, not a policy, and it belongs to whoever adds a connection pool. - Failures are results, not transport errors. A refusal or a daemon error
comes back as a tool result with
isErrorand a stablecode(unknown_harness,not_permitted,unknown_tool, …), which is SPEC-0005 REQ "Error Handling Standards" applied to a bridge: a caller branches on the code, and one bad call does not end the session.
Explicit non-goal
Editing harness.toml stays a file edit plus reload. Supervising upstream
servers, per-harness filtered endpoints, prompt side-loading, the call log,
resources and subscriptions are 📝 ADR-0035's, not this bridge's. No redaction is
added to log output beyond what harness logs already does; the summary format
echoes no environment by construction, since it renders activity entries.
Consequences
- Agents get structured, name-gated access to the daemon now, without waiting on the gateway, and without a second implementation of each verb to keep in step.
- A read-only
harness mcpprocess is safe to hand to a monitoring agent, which is the deployment #950's reporter wants. --allow-controlis coarser thanmcp_allow: it is per process, not per harness. That is stated in the docs rather than hidden, and the 📝 ADR-0035 sequence replaces it.- The reserved-namespace check SPEC-0005 wants (an
[mcp.*]key may not shadow a facade tool) has nothing to collide with yet, but the registry is one list, so the check will have one place to consult. - Concurrency within one session costs a lock: many simultaneous tool calls queue on the daemon socket instead of opening more connections. If that ever hurts, the fix is a small pool in the CLI wrapper.
Considered options
- Bridge now, broker later (chosen). One thin client, names and error codes already in the shape the gateway will use.
- Wait for the broker. Correct per 📝 ADR-0035's letter, but it leaves the reported pain untouched for a quarter and the tool surface unexercised.
- Per-connection
mcp_allowread/write with no identity. Would let any process that opens the socket change state by default, which is a wider authority than the CLI's and than the flag's: it is not attributable to anyone's decision. - Control tools on by default,
--read-onlyto opt out. Ships the widest authority to the least informed caller; the monitor in #950 would need to remember to disable what it must not use.
Open questions for the maintainer
- Is
harness_daemon_infoacceptable as a read tool, or should daemon status ride onharness_list? This PR classifies it read and says so in SPEC-0005's terms. - Should
harness_trigger'swaitfollow the run inside the bridge (as here), or should the daemon grow a follow op the CLI and the bridge share? - Does the gateway want this bridge to keep existing as the unattributed entry
point, or to become the token-presenting
harness mcpSPEC-0005 describes?
Links
- Issue: https://gitea.stump.rocks/stump.wtf/harness/issues/950
- Upstream report: https://github.com/stump-wtf/harness/issues/25
- Extends: 📝 ADR-0035, which supersedes 📝 ADR-0010
- Depends on: 📝 ADR-0002 (the CLI is the supported programmatic surface)
- Code:
internal/mcp,cmd/harness/mcp.go; tool classes ininternal/facade/facade.go
Related Artifacts
Direct relationships declared in YAML frontmatter (per the SDD plugin's artifact-graph convention). Run /sdd:graph chain ADR-0054 for the transitive view.