Skip to content

rtb-mcp v0.1 — MCP server that exports registered Commands

Status: IMPLEMENTED — landed on feat/rtb-mcp-v0.1. Parent contract: rust-tool-base.md and the v0.3 scope addendum 2026-05-01-v0.3-scope.md. Replaces: the rtb-mcp v0.1 stub (placeholder crate) and the McpStub in rtb-cli::builtins.


1. Goal

A thin layer over rmcp (the official Rust MCP SDK) that lets every RTB-built tool act as an MCP server with zero boilerplate:

  1. Walk BUILTIN_COMMANDS for entries marked mcp_exposed.
  2. For each, derive the MCP tool's input schema from a caller-supplied clap::Args struct (or skip schema generation when the command takes no args).
  3. Register each command as an MCP tool whose call invokes the existing Command::run.
  4. Bind to one of the rmcp-supplied transports — stdio (default), SSE, streamable HTTP.

What we explicitly avoid: re-inventing rmcp's wire-protocol layer, reimplementing the JSON-RPC machinery, or maintaining a separate tool registry. Every MCP tool is just a Command impl that chose to expose itself.

2. Public API surface

2.1 Command::mcp_exposed opt-in

Per O1, this is a default trait method on rtb_app::command::Command:

pub trait Command: Send + Sync + 'static {
    fn spec(&self) -> &CommandSpec;
    async fn run(&self, app: App) -> miette::Result<()>;
    fn subcommand_passthrough(&self) -> bool { false }

    /// When `true`, this command is registered as an MCP tool by
    /// [`rtb_mcp::McpServer`]. Defaults to `false` — additive trait
    /// method, no impact on the existing 27+ `impl Command` sites.
    fn mcp_exposed(&self) -> bool { false }

    /// Optional JSON Schema for the command's arguments. Returned to
    /// MCP clients in the tool listing. Default: empty schema (no
    /// args). Tool authors that take args should derive this from
    /// their `clap::Args` struct via `schemars::schema_for!`.
    fn mcp_input_schema(&self) -> Option<serde_json::Value> { None }
}

Both are additive; existing impls inherit the false / None default.

2.2 McpServer + Transport

pub struct McpServer { /* … */ }

#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Transport {
    /// stdin/stdout — the default for "spawn me as a subprocess" use.
    Stdio,
    /// HTTP+SSE on the supplied bind address.
    Sse { bind: SocketAddr },
    /// Streamable HTTP on the supplied bind address.
    Http { bind: SocketAddr },
}

impl McpServer {
    /// Build a server. Walks [`BUILTIN_COMMANDS`] eagerly, filters
    /// by [`Command::mcp_exposed`], and builds the registry once.
    #[must_use]
    pub fn new(app: App, transport: Transport) -> Self;

    /// Run the server. Returns when `app.shutdown` fires or the
    /// transport closes.
    ///
    /// # Errors
    ///
    /// [`McpError::Transport`] on bind / accept failure;
    /// [`McpError::Protocol`] on invalid client requests we surface
    /// (most are handled by `rmcp` itself).
    pub async fn serve(self) -> Result<(), McpError>;
}

2.3 McpError

#[non_exhaustive], Clone-derivable:

pub enum McpError {
    Transport(String),
    Protocol(String),
    /// A registered `Command::run` returned an error during a
    /// `call_tool` invocation. Surfaced back to the MCP client.
    Command { command: String, message: String },
}

3. mcp CLI subcommand

The existing McpStub in rtb-cli::builtins is removed. rtb-mcp ships a real McpCmd registered into BUILTIN_COMMANDS:

mytool mcp serve [--transport stdio|sse|http] [--bind 127.0.0.1:0]
mytool mcp list   # print every MCP-exposed command + its schema

mcp serve follows the same subcommand_passthrough pattern as docs / update so its inner clap parser owns help/error rendering.

4. Implementation sketch

use rmcp::handler::server::ServerHandler;
use rmcp::model::{CallToolRequestParams, ListToolsResult, Tool};
use rmcp::ServiceExt;

pub struct McpServer { app: App, tools: Vec<RegisteredTool>, transport: Transport }

struct RegisteredTool {
    name: &'static str,
    about: &'static str,
    schema: serde_json::Value,
    factory: fn() -> Box<dyn rtb_app::command::Command>,
}

impl McpServer {
    pub fn new(app: App, transport: Transport) -> Self {
        let tools = BUILTIN_COMMANDS
            .iter()
            .map(|f| f())
            .filter(|cmd| cmd.mcp_exposed())
            .map(|cmd| RegisteredTool {
                name: cmd.spec().name,
                about: cmd.spec().about,
                schema: cmd.mcp_input_schema().unwrap_or(serde_json::json!({})),
                factory: /* re-fetch the factory from BUILTIN_COMMANDS */,
            })
            .collect();
        Self { app, tools, transport }
    }
}

#[async_trait]
impl ServerHandler for McpServer {
    async fn list_tools(/* ... */) -> Result<ListToolsResult, /* ... */> { ... }

    async fn call_tool(/* request, context */) -> Result</* ... */> {
        let tool = self.tools.iter().find(|t| t.name == request.name)?;
        let cmd = (tool.factory)();
        // Pipe args through Command::run; its stdout is the tool result.
        cmd.run(self.app.clone()).await?;
        Ok(/* ... */)
    }
}

impl McpServer {
    pub async fn serve(self) -> Result<(), McpError> {
        match self.transport {
            Transport::Stdio => self.serve_stdio().await,
            Transport::Sse { bind } => self.serve_sse(bind).await,
            Transport::Http { bind } => self.serve_http(bind).await,
        }
    }
}

The factory indirection sidesteps the trait-object lifetime hassle of holding the deduped Vec<Box<dyn Command>> across call_tool invocations.

5. Test plan (TDD)

  • T1Command::mcp_exposed default returns false; existing impls compile unchanged.
  • T2Command::mcp_input_schema default returns None.
  • T3McpServer::new filters BUILTIN_COMMANDS to entries with mcp_exposed = true.
  • T4list_tools returns one entry per registered command with the correct name + schema.
  • T5call_tool invokes the underlying Command::run and surfaces success.
  • T6call_tool for an unknown name returns an MCP error with the requested name in the message.
  • T7Transport::Stdio is the default; explicit construction via the variant.
  • T8mcp list (CLI) prints every exposed tool's name + schema as JSON, one per line.
  • T9mcp serve --transport stdio round-trips a tool call via rmcp's in-process test client.
  • T10McpError is Clone (compile-time check).

BDD scenario: - S1 — "Given a tool with one MCP-exposed command, When I call it via the test client, Then the command's Command::run body executes and the response shape matches the schema."

6. Cross-cutting changes

  • rtb-app::Command gains mcp_exposed + mcp_input_schema default methods.
  • rtb-cli::builtins: McpStub deleted (mirrors the v0.2 cleanup pattern).
  • rtb umbrella: mcp feature flips from "active but stub" to "active + real" — already lists dep:rtb-mcp, so no Cargo.toml change.
  • examples/minimal/tests/smoke.rs: smoke test for minimal mcp --help lists serve + list.

6.1 v0.1 narrowing — SSE / streamable HTTP transport stubs

The approved API surface keeps Transport::Sse and Transport::Http variants for forward-compatibility. The v0.1 implementation only ships the stdio transport in fully working form — invoking mcp serve --transport sse|http returns a clear McpError::Transport ("not yet implemented in rtb-mcp v0.1; use --transport stdio").

The rmcp 0.16 crate folds SSE into the transport-streamable-http-server family, which requires an axum / hyper-util mount (≈ 60 LOC of HTTP plumbing the spec didn't budget). That work is tracked for v0.3.x as a point release; the existing tests (T7) keep both variants on the API surface so adding the bind path is a non-breaking change.

7. Non-goals for v0.1

  • MCP client — the symmetric "let RTB-built tools consume other MCP servers" capability. v0.4 work.
  • Resources / promptsrmcp::ServerHandler exposes list_resources / list_prompts too; we ship tools-only at v0.1.
  • Tool authentication — MCP supports per-tool auth; out of scope until a downstream tool asks.
  • Streaming tool outputCommand::run is a blocking-async fire-and-return today; streaming partial results back to the MCP client lands in v0.3.x.

8. Approval gate

Implemented when (a) status flips to APPROVED, (b) T1–T10 + S1 land green with ≥ 90% line coverage on rtb-mcp, © mcp subcommand is wired in examples/minimal and the smoke test passes.