Skip to content

Expose a command over MCP

Opt the command in

mcp_exposed and mcp_input_schema are default methods on Command. Override them on the commands you want reachable:

use schemars::JsonSchema;

#[derive(JsonSchema)]
struct DeployArgs {
    region: Option<String>,
    force: bool,
}

#[async_trait::async_trait]
impl Command for Deploy {
    fn spec(&self) -> &CommandSpec { /* … */ }

    fn mcp_exposed(&self) -> bool {
        true
    }

    fn mcp_input_schema(&self) -> Option<serde_json::Value> {
        serde_json::to_value(schemars::schema_for!(DeployArgs)).ok()
    }

    async fn run(&self, app: App) -> miette::Result<()> { /* … */ }
}

Both default to false and None, so nothing is exposed until you say so.

A schema that is not a JSON object is discarded and replaced with {"type": "object"}, silently. Returning None does the same thing.

Make the command reachable

Something in your binary must reference rtb_mcp, or the mcp command will not exist:

use rtb_mcp as _;

Confirm what is published

$ mytool mcp list
{"name":"deploy","description":"Deploy the thing","input_schema":{...}}

One JSON object per line — JSON Lines, not an array. mcp list walks BUILTIN_COMMANDS directly and ignores the runtime Features set, so a command whose feature is disabled still appears here. It also rejects --output; it is already JSON.

Run the server

$ mytool mcp serve

stdio is the default and the only implemented transport. --transport sse and --transport http parse, demand a --bind, and then fail.

Point an MCP client that spawns servers as subprocesses at the binary with mcp serve as its arguments.

Choose commands that fit what the server actually carries

Three constraints decide whether a command is worth exposing today:

  • Arguments are not delivered. The published schema tells the client what to send; tools/call then discards it. Your command runs with an empty App::trailing_args(). A command that needs input cannot get it.
  • Output is not returned. A successful call replies with the fixed text <name> ok. Anything the command printed went to the process's stdout — which, over stdio, is the protocol stream. A command that prints will corrupt the session.
  • The effect has to be the point. Only commands that need no input, produce no stdout, and are useful for what they do work properly right now.

That is a narrow set, and it is worth knowing before designing around it.

Think about the blast radius

A CLI command runs because a person typed it. An MCP tool runs because a model decided to call it, possibly from text it read somewhere. Anything destructive, anything that spends money, anything writing to a shared system — decide about it one command at a time. That is why exposure is opt-in.