Skip to main content

rtb_mcp/
command.rs

1//! `mcp` CLI subcommand — `serve | list`.
2//!
3//! Wires the [`crate::McpServer`] library API to the user-facing CLI:
4//!
5//! - `mcp serve [--transport stdio|sse|http] [--bind ADDR]` — run the
6//!   MCP server in the foreground until the chosen transport closes.
7//! - `mcp list` — print every MCP-exposed command's name + description
8//!   + JSON Schema to stdout, one tool per line as JSON.
9//!
10//! No subcommand defaults to `serve` over stdio (the spawn-as-subprocess
11//! pattern MCP clients expect).
12//!
13//! # Lint exception
14//!
15//! `linkme::distributed_slice` emits `#[link_section]` which Rust 1.95+
16//! flags under `unsafe_code`. Allowed at module level — no hand-rolled
17//! `unsafe` blocks anywhere in the module.
18
19#![allow(unsafe_code)]
20
21use std::ffi::OsString;
22use std::net::SocketAddr;
23
24use async_trait::async_trait;
25use clap::{Parser, Subcommand, ValueEnum};
26use linkme::distributed_slice;
27use miette::miette;
28use rtb_app::app::App;
29use rtb_app::command::{Command, CommandSpec, BUILTIN_COMMANDS};
30use rtb_app::features::Feature;
31
32use crate::server::McpServer;
33use crate::transport::Transport;
34
35/// The `mcp` subcommand.
36pub struct McpCmd;
37
38#[async_trait]
39impl Command for McpCmd {
40    fn spec(&self) -> &CommandSpec {
41        static SPEC: CommandSpec = CommandSpec {
42            name: "mcp",
43            about: "Expose registered commands as Model Context Protocol tools",
44            feature: Some(Feature::Mcp),
45            ..CommandSpec::DEFAULT
46        };
47        &SPEC
48    }
49
50    /// `mcp` owns its inner clap subtree (`serve / list`).
51    fn subcommand_passthrough(&self) -> bool {
52        true
53    }
54
55    async fn run(&self, app: App) -> miette::Result<()> {
56        let mut args: Vec<OsString> = std::env::args_os().collect();
57        if args.len() >= 2 {
58            args.drain(..2);
59        }
60        args.insert(0, OsString::from("mcp"));
61        let cli = match McpCli::try_parse_from(args) {
62            Ok(c) => c,
63            Err(e) => {
64                use clap::error::ErrorKind;
65                if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
66                    print!("{e}");
67                    return Ok(());
68                }
69                return Err(miette!("{e}"));
70            }
71        };
72
73        let sub = cli.command.unwrap_or_else(|| McpSub::Serve(ServeOpts::default()));
74        match sub {
75            McpSub::Serve(opts) => run_serve(app, opts).await,
76            McpSub::List(_) => {
77                run_list();
78                Ok(())
79            }
80        }
81    }
82}
83
84#[distributed_slice(BUILTIN_COMMANDS)]
85fn __register_mcp() -> Box<dyn Command> {
86    Box::new(McpCmd)
87}
88
89// ---------------------------------------------------------------------
90// clap surface
91// ---------------------------------------------------------------------
92
93#[derive(Debug, Parser)]
94#[command(name = "mcp", about = "Expose registered commands as Model Context Protocol tools")]
95struct McpCli {
96    #[command(subcommand)]
97    command: Option<McpSub>,
98}
99
100#[derive(Debug, Subcommand)]
101enum McpSub {
102    /// Run the MCP server.
103    Serve(ServeOpts),
104    /// Print every MCP-exposed command + its JSON Schema.
105    List(ListOpts),
106}
107
108#[derive(Debug, Default, clap::Args)]
109struct ServeOpts {
110    /// Wire transport. Defaults to `stdio`.
111    #[arg(long, value_enum, default_value_t = TransportArg::Stdio)]
112    transport: TransportArg,
113    /// Bind address for SSE / HTTP transports. Required when the
114    /// transport is not `stdio`.
115    #[arg(long, value_name = "ADDR")]
116    bind: Option<SocketAddr>,
117}
118
119#[derive(Debug, clap::Args)]
120struct ListOpts {}
121
122#[derive(Debug, Clone, Copy, ValueEnum, Default)]
123enum TransportArg {
124    #[default]
125    Stdio,
126    Sse,
127    Http,
128}
129
130// ---------------------------------------------------------------------
131// Subcommand bodies
132// ---------------------------------------------------------------------
133
134async fn run_serve(app: App, opts: ServeOpts) -> miette::Result<()> {
135    let transport = match opts.transport {
136        TransportArg::Stdio => Transport::Stdio,
137        TransportArg::Sse => Transport::Sse {
138            bind: opts
139                .bind
140                .ok_or_else(|| miette!("`mcp serve --transport sse` requires `--bind ADDR`"))?,
141        },
142        TransportArg::Http => Transport::Http {
143            bind: opts
144                .bind
145                .ok_or_else(|| miette!("`mcp serve --transport http` requires `--bind ADDR`"))?,
146        },
147    };
148    let server = McpServer::new(app, transport);
149    server.serve().await.map_err(miette::Report::new)
150}
151
152fn run_list() {
153    // Build a registry without taking the App — `mcp list` is a
154    // schema dump and shouldn't require any subsystem to be live.
155    for factory in BUILTIN_COMMANDS {
156        let cmd = factory();
157        if !cmd.mcp_exposed() {
158            continue;
159        }
160        let spec = cmd.spec();
161        let schema =
162            cmd.mcp_input_schema().unwrap_or_else(|| serde_json::json!({"type": "object"}));
163        let entry = serde_json::json!({
164            "name": spec.name,
165            "description": spec.about,
166            "input_schema": schema,
167        });
168        println!("{entry}");
169    }
170}