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 mcpscaffolder verb (§6). - [Q-3] Hierarchy mechanism → add
parent: Option<&'static str>toCommandSpecfor 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 byis_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 survivesregenerate.
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 mcpfeature 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::newenumerates over the registeredBUILTIN_COMMANDSonly; 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.