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:
- Walk
BUILTIN_COMMANDSfor entries markedmcp_exposed. - For each, derive the MCP tool's input schema from a caller-supplied
clap::Argsstruct (or skip schema generation when the command takes no args). - Register each command as an MCP tool whose
callinvokes the existingCommand::run. - 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)¶
- T1 —
Command::mcp_exposeddefault returnsfalse; existing impls compile unchanged. - T2 —
Command::mcp_input_schemadefault returnsNone. - T3 —
McpServer::newfiltersBUILTIN_COMMANDSto entries withmcp_exposed = true. - T4 —
list_toolsreturns one entry per registered command with the correct name + schema. - T5 —
call_toolinvokes the underlyingCommand::runand surfaces success. - T6 —
call_toolfor an unknown name returns an MCP error with the requested name in the message. - T7 —
Transport::Stdiois the default; explicit construction via the variant. - T8 —
mcp list(CLI) prints every exposed tool's name + schema as JSON, one per line. - T9 —
mcp serve --transport stdioround-trips a tool call viarmcp's in-process test client. - T10 —
McpErrorisClone(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::Commandgainsmcp_exposed+mcp_input_schemadefault methods.rtb-cli::builtins:McpStubdeleted (mirrors the v0.2 cleanup pattern).rtbumbrella:mcpfeature flips from "active but stub" to "active + real" — already listsdep:rtb-mcp, so no Cargo.toml change.examples/minimal/tests/smoke.rs: smoke test forminimal mcp --helplistsserve+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 / prompts —
rmcp::ServerHandlerexposeslist_resources/list_promptstoo; we ship tools-only at v0.1. - Tool authentication — MCP supports per-tool auth; out of scope until a downstream tool asks.
- Streaming tool output —
Command::runis 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.