ADR-0023 — The CLI routes through MCP, chosen per call at runtime
Status: Accepted
Context
Section titled “Context”Anvil aligns three surfaces — skill, CLI, MCP — on one meaning. But a skill that tells an agent to “use the CLI” and a platform that calls “the MCP tool” can still execute along different code paths (the CLI hitting the upstream API directly, the MCP server hitting it through the runtime hot path). Two paths are two places for behaviour to drift: a retry guard, a confirmation gate, an idempotency key applied in one and not the other. The surfaces agree on paper and diverge in production.
The relationship we actually want is skill → CLI → MCP: the CLI is the ergonomic front door, and it can route through an MCP server — the same safety hot path the platform uses — rather than re-implementing execution. And whether that MCP server is the local stdio one or a remote HTTP one should be a runtime choice, made per call, not baked in at generation time.
Decision
Section titled “Decision”Give the generated tool-CLI two execution modes and let a per-call flag pick
between them (packages/cli/src/tool-cli.ts):
- Direct — the CLI executes the operation itself (the default).
- Via MCP — the CLI connects to an MCP server as a client and calls the tool, then re-renders the response through the same exit-code contract, so the user sees no difference except where the work ran.
The switch is resolveMcpTarget(flags.mcp, env.ANVIL_MCP_TARGET):
- A per-call
--mcpflag wins over theANVIL_MCP_TARGETenvironment default, so the routing is genuinely a runtime choice, overridable per invocation. direct/off/none/ empty force direct execution; a bare--mcpmeansstdio.connectMcpClientmaps the target to a transport:stdio/local⇒StdioClientTransportspawning the bundle’s own siblingmcp/server.js(the “skill → CLI → MCP” loopback, wired atpackages/generators/src/entrypoints.ts); an ordinary HTTP(S) URL ⇒StreamableHTTPClientTransportfor the deployed/mcpendpoint; only an explicitsse:https://…⇒SSEClientTransportlegacy target.- Authenticated Streamable HTTP reads a bearer token only from a named
environment variable:
--mcp-token-env <NAME>wins, whileANVIL_MCP_TOKEN_ENV=<NAME>is the default. The token value is resolved at connect time, sent in theAuthorizationheader, and never placed in argv, target URLs, help, or errors. URL userinfo is rejected.
Routing through MCP reuses the reserved-argument contract: CLI safety flags map to
the synthesized confirm / idempotency_key inputs and anvil_dry_run, so a
confirmation gate or a non-idempotent-retry refusal fires identically whether the
CLI executed directly or over the tool. anvil run <dir> wires
mcpServerPath = <dir>/mcp/server.js so --mcp stdio targets that bundle’s local
server. A direct air.yaml/air.json coordinate first resolves its parent bundle
and therefore reaches the same sibling server. The harness loopback check
drives every approved tool over the real stdio MCP transport to prove the CLI
and MCP surfaces receive identical logical input (cliFlagsFor in
bundle-driver.ts is the exact inverse of the flag→input mapping).
Consequences
Section titled “Consequences”- “The CLI and the MCP tool do the same thing” becomes testable and true by
construction: with
--mcp, the CLI is an MCP client of its own bundle. - An operator can develop against a local stdio server and flip a single flag / env var to run the same command against a remote deployment — no regeneration.
- Secure remote calls remain explicit: no token variable name means no
Authorizationheader, which preserves deliberately unauthenticated endpoints without guessing a credential source.