Skip to content

MCP command exposure gating

Status: APPROVED — GTB-parity gap from the v0.22.0 audit (Phase 1). Mirrors GTB's 2026-06-19-mcp-command-exposure-gating.md, adapted to RTB's flat-registry / trait-impl model. Decisions resolved 2026-06-23 (§7). Ready for TDD.

Resolutions (binding): - Default polarity = OPT-IN / hidden (Q-1) — a command is NOT an MCP tool unless explicitly Exposed. Keeps today's behavior; smaller AI attack surface; no churn. - Scope = full incl. runtime enable/disable mcp <path> (Q-2). - Tri-state stored on CommandSpec (§2); resolver per §3; hierarchy via a new optional parent/path field, ancestor-walk forward-compatible but v1 operates on the flat BUILTIN_COMMANDS registry (§3.1).

1. Motivation

RTB exposes CLI commands as MCP tools today via a single flat method — Command::mcp_exposed() -> bool (default false), filtered in rtb-mcp (command.rs:157, server.rs). There is no granularity: no tri-state, no subtree semantics, no scaffolder threading, no per-command toggle. GTB shipped a full hierarchical exposure system in v0.21. This adds the missing structure while keeping RTB's safer opt-in polarity.

GTB reference: tri-state MCPExposure (Inherit/Exposed/Excluded) as cobra annotations + an ancestor-walk resolver (IsExposedToMCP, default exposed), root gate via an ophis.Selector, scaffolder --mcp-enabled threading, and gtb enable/disable mcp <path>. RTB diverges on polarity (opt-in vs GTB's default-exposed) and on mechanism (trait impls + marker regions, not cobra annotations).

2. The McpExposure tri-state

New enum (in rtb-app, alongside CommandSpec):

pub enum McpExposure {
    /// No explicit decision; resolves via ancestors, else the default.
    /// Under RTB's opt-in polarity, an unresolved Inherit -> HIDDEN.
    Inherit,
    /// This command (and Inherit descendants) exposed as MCP tools.
    Exposed,
    /// This command (and Inherit descendants) hidden; overrides an
    /// exposing ancestor.
    Excluded,
}

Added to CommandSpec as pub mcp: McpExposure (default Inherit). The existing mcp_exposed() trait method becomes a provided default that returns resolve_mcp_exposure(self) == Exposed so existing flat impls keep working; new code sets CommandSpec::mcp.

3. Resolver

rtb_app::command::is_exposed_to_mcp(spec) -> bool: - Exposed → true; Excluded → false; - Inherit → walk ancestors (nearest explicit Exposed/Excluded wins); no explicit ancestor → the opt-in default = false (hidden).

3.1 Hierarchy on a flat registry (the RTB nuance)

BUILTIN_COMMANDS is flat and CommandSpec has no parent today, so v1: - Adds an optional parent: Option<&'static str> (command-path) to CommandSpec. Top-level commands leave it None. - Ancestor walk uses parent to climb; with all-None it degrades to per-command resolution (Exposed/Excluded/Inherit→hidden), which is exactly today's effective model plus tri-state. - Passthrough commands (generate, remove) own internal subcommands that are NOT separate registry entries — their exposure is decided at the top-level entry for v1. Modelling their internal trees as MCP tools is out of scope (§8).

This keeps the resolver shape identical to GTB's (so behavior is auditable + future-proof) while being honest that RTB's tree is shallow.

4. rtb-mcp integration

Replace the flat if !cmd.mcp_exposed() filter in rtb-mcp (command.rs:157, server.rs) with is_exposed_to_mcp(cmd.spec()). Construction stays eager (RTB freezes the registry at McpServer::new, unlike GTB's lazy ophis selector — acceptable; documented divergence). Log the exposed tool set at INFO once.

5. Scaffolder threading (rtb-cli-bin)

generate command gains: - --mcp-exposed (tri-state: absent = Inherit/hidden, --mcp-exposed = Exposed, --mcp-exposed=false/--no-mcp = Excluded), and an interactive "Expose to MCP?" confirm (default No, matching opt-in). - Threads the choice into the generated impl Command via a marker region (// rtb:mcp-exposure-*) setting CommandSpec::mcp, and records mcp: exposed|excluded (Inherit omitted) in .rtb/manifest.yaml.

6. Runtime rtb enable/disable mcp <command-path>... (scaffolder verb)

A new scaffolder verb (rtb-cli-bin, parallel to generate/remove), operating on a scaffolded project: - Sets the target command(s)' CommandSpec::mcp to Exposed (enable) / Excluded (disable) by rewriting the // rtb:mcp-exposure marker region in the command file + updating the manifest. Honors protected + --force + --dry-run (mirrors remove). - This is the GTB enable/disable mcp <path> analogue. The no-arg feature-toggle form is a separate concern (already decided a non-goal — see 2026-04-22-rtb-app-v0.1.md).

7. Resolutions (resolved 2026-06-23)

  • [Q-1] Default polarity → OPT-IN / hidden. Inherit resolves to hidden; authors opt commands in. Safer than GTB default-exposed; no behavior change for existing tools.
  • [Q-2] Scope → full, including the runtime enable/disable mcp scaffolder verb (§6).
  • [Q-3] Hierarchy mechanism → add parent: Option<&'static str> to CommandSpec for the ancestor walk; degrades cleanly to per-command on the flat registry. (Recommended; alternative name-path convention rejected as implicit/fragile.)
  • [Q-4] Backward compat → keep mcp_exposed() as a provided default delegating to the resolver, so existing flat impls don't break.

8. Testing (TDD)

  • Unit (rtb-app): resolver truth table — Exposed/Excluded/Inherit at top level; ancestor walk (Exposed parent + Inherit child = exposed; Excluded child overrides; Inherit with no ancestor = hidden).
  • Unit (rtb-mcp): registry filters by is_exposed_to_mcp; a default (Inherit) command is absent; an Exposed one present.
  • E2E (assert_cmd, rtb-cli-bin): generate command --mcp-exposed → generated impl exposes; rtb disable mcp <path> flips it (+ --dry-run / protected+--force); rtb enable mcp <path> re-exposes; manifest records the state and survives regenerate.

9. Out of scope

  • Per-flag MCP gating (GTB leaves flag selectors nil = all exposed; RTB matches — all flags of an exposed command are exposed).
  • Modelling passthrough subcommands (generate x, remove y) as individually-gated MCP tools (the internal tree isn't in the registry).
  • Lazy/selector-based gating at enumeration time (RTB stays eager at McpServer::new).
  • A no-arg enable/disable mcp feature toggle (non-goal, per rtb-app).

10. Amendment (2026-06-26) — flag-without-command edge case

The GTB v0.22.0 parity audit (Phase 2) found Feature::Changelog exists as a flag with no registered command (a counterexample to "every feature has a command"). The exposure resolver and enumeration must not assume every Feature/command pairing has a registered command:

  • McpServer::new enumerates over the registered BUILTIN_COMMANDS only; a feature flag with no command contributes no MCP tool (there is nothing to call). This holds by construction today — state it explicitly so a future feature-with-no-command cannot trip the resolver.
  • Shares the single source-of-truth "feature-has-backing-command" table with the feature-toggle spec's §9 invariant, so both verbs agree.

Resolved fully once 2026-06-26-rtb-cli-changelog-subcommand.md gives Changelog a command; the explicit rule remains as a guard.