use std::collections::HashSet;
use clap::Command;
use rmcp::model::Tool;
use crate::Result;
use crate::config::Config;
use crate::selector::{FlagMatcher, Middleware};
pub struct ResolvedTool {
pub tool: Tool,
pub middleware: Option<Middleware>,
pub command_path: String,
}
pub fn generate_tools(root: &Command, cfg: &Config) -> Result<Vec<Tool>> {
Ok(generate_tools_with_middleware(root, cfg)?
.into_iter()
.map(|r| r.tool)
.collect())
}
pub fn generate_tools_with_middleware(root: &Command, cfg: &Config) -> Result<Vec<ResolvedTool>> {
let mut built = root.clone();
built.build();
let resolved = crate::walk::walk(&built);
validate_paths(&resolved, cfg)?;
let prefix = cfg
.tool_name_prefix
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| root.get_name());
let mut tools = Vec::new();
for entry in &resolved {
if crate::walk::should_filter(entry.cmd, &entry.path, cfg) {
continue;
}
let matched_selector: Option<&crate::selector::Selector> = if cfg.selectors.is_empty() {
None } else {
let found = cfg
.selectors
.iter()
.find(|sel| sel.cmd.as_ref().is_none_or(|m| m(&entry.path)));
match found {
Some(sel) => Some(sel),
None => continue, }
};
let tool_name = build_tool_name(&entry.path, prefix);
if tool_name.len() > 64 {
tracing::warn!(
target: "brontes::command",
name = %tool_name,
len = tool_name.len(),
"MCP tool name exceeds 64 characters; consider setting Config.tool_name_prefix"
);
}
let local_flag: Option<&FlagMatcher> = matched_selector.and_then(|s| s.local_flag.as_ref());
let inherited_flag: Option<&FlagMatcher> =
matched_selector.and_then(|s| s.inherited_flag.as_ref());
let middleware: Option<Middleware> = matched_selector.and_then(|s| s.middleware.clone());
let input_schema = crate::schema::build_input_schema_with_matchers(
entry.cmd,
cfg,
&entry.path,
local_flag,
inherited_flag,
);
let output_schema = crate::schema::build_output_schema();
let description = crate::schema::build_description(entry.cmd, cfg, &entry.path);
let annotations = cfg
.annotations
.get(&entry.path)
.and_then(crate::annotations::ToolAnnotations::to_rmcp);
let mut tool = Tool::new(tool_name, description, input_schema);
tool.output_schema = Some(output_schema);
tool.annotations = annotations;
tools.push(ResolvedTool {
tool,
middleware,
command_path: entry.path.clone(),
});
}
Ok(tools)
}
fn build_tool_name(path: &str, prefix: &str) -> String {
let after_first = path.find(' ').map_or("", |i| &path[i..]);
let body = format!("{prefix}{after_first}");
body.replace(' ', "_")
}
fn validate_paths(resolved: &[crate::walk::ResolvedCmd<'_>], cfg: &Config) -> Result<()> {
let valid_paths: HashSet<&str> = resolved.iter().map(|r| r.path.as_str()).collect();
for path in cfg.annotations.keys() {
if !valid_paths.contains(path.as_str()) {
return Err(crate::Error::Config(format!(
"Config.annotations references unknown command path {path:?}"
)));
}
}
for path in &cfg.deprecated_commands {
if !valid_paths.contains(path.as_str()) {
return Err(crate::Error::Config(format!(
"Config.deprecated_commands references unknown command path {path:?}"
)));
}
}
for (path, flag) in cfg.flag_schemas.keys() {
validate_flag_path(resolved, &valid_paths, path, flag, "flag_schemas")?;
}
for (path, flag) in cfg.flag_type_overrides.keys() {
validate_flag_path(resolved, &valid_paths, path, flag, "flag_type_overrides")?;
}
for (path, text) in &cfg.descriptions {
if !valid_paths.contains(path.as_str()) {
return Err(crate::Error::Config(format!(
"Config.descriptions references unknown command path {path:?}"
)));
}
if text.trim().is_empty() {
return Err(crate::Error::Config(format!(
"description override for command path '{path}' is empty; \
description text must be non-empty"
)));
}
}
for path in cfg.description_modes.keys() {
if !valid_paths.contains(path.as_str()) {
return Err(crate::Error::Config(format!(
"Config.description_modes references unknown command path {path:?}"
)));
}
}
for sel in &cfg.selectors {
if let Some(matcher) = &sel.cmd
&& let Some(spec) = crate::selectors::lookup(matcher)
{
match spec.kind {
crate::selectors::MatcherKind::AllowCmds
| crate::selectors::MatcherKind::ExcludeCmds => {
for s in &spec.args {
if !valid_paths.contains(s.as_str()) {
return Err(crate::Error::Config(format!(
"Selector references unknown command path {s:?}"
)));
}
}
}
crate::selectors::MatcherKind::AllowCmdsContaining
| crate::selectors::MatcherKind::ExcludeCmdsContaining => {
for s in &spec.args {
if !valid_paths.iter().any(|p| p.contains(s.as_str())) {
tracing::warn!(
target: "brontes::command",
needle = %s,
"Selector substring matches no walked command path"
);
}
}
}
_ => {} }
}
}
Ok(())
}
fn validate_flag_path(
resolved: &[crate::walk::ResolvedCmd<'_>],
valid_paths: &HashSet<&str>,
path: &str,
flag: &str,
config_field: &str,
) -> Result<()> {
if !valid_paths.contains(path) {
return Err(crate::Error::Config(format!(
"Config.{config_field} references unknown command path {path:?}"
)));
}
if let Some(r) = resolved.iter().find(|r| r.path == path) {
let has_flag = r.cmd.get_arguments().any(|a| a.get_id().as_str() == flag);
if !has_flag {
return Err(crate::Error::Config(format!(
"Config.{config_field} references unknown flag {flag:?} on command {path:?}"
)));
}
}
Ok(())
}
const DEFAULT_COMMAND_NAME: &str = "mcp";
#[must_use]
pub fn command(cfg: Option<&Config>) -> Command {
let name = cfg
.and_then(|c| c.command_name.as_deref())
.filter(|s| !s.is_empty())
.unwrap_or(DEFAULT_COMMAND_NAME);
crate::subcommands::build(name)
}
pub async fn handle(matches: &clap::ArgMatches, cli: &Command, cfg: Option<&Config>) -> Result<()> {
let cfg_owned = cfg.map_or_else(Config::default, Config::clone);
if matches!(cfg.and_then(|c| c.command_name.as_deref()), Some("")) {
return Err(crate::Error::Config(
"Config.command_name must not be empty".into(),
));
}
let group_name = cfg
.and_then(|c| c.command_name.as_deref())
.filter(|s| !s.is_empty())
.unwrap_or(DEFAULT_COMMAND_NAME);
let group = cli.find_subcommand(group_name).ok_or_else(|| {
crate::Error::Config(format!(
"no subcommand named {group_name:?} found on the CLI; \
did you forget to mount brontes::command(...)?"
))
})?;
let has_marker = group
.get_subcommands()
.any(|s| s.get_name() == crate::subcommands::MARKER_NAME);
if !has_marker {
return Err(crate::Error::Config(format!(
"subcommand {group_name:?} on the CLI was not minted by brontes \
(sibling collision); rename via Config::command_name"
)));
}
match matches.subcommand() {
Some(("start", sub)) => {
crate::subcommands::start::run(sub, cli.clone(), Some(cfg_owned)).await
}
Some(("tools", sub)) => crate::subcommands::tools::run(sub, cli, Some(cfg_owned)),
Some(("stream", sub)) => {
crate::subcommands::stream::run(sub, cli.clone(), Some(cfg_owned)).await
}
Some(("claude", sub)) => crate::subcommands::editor::claude::run(sub, Some(&cfg_owned)),
Some(("vscode", sub)) => crate::subcommands::editor::vscode::run(sub, Some(&cfg_owned)),
Some(("cursor", sub)) => crate::subcommands::editor::cursor::run(sub, Some(&cfg_owned)),
Some(("zed", sub)) => crate::subcommands::editor::zed::run(sub, Some(&cfg_owned)),
Some((other, _)) if other == crate::subcommands::MARKER_NAME => Err(crate::Error::Config(
"internal marker subcommand is not a runnable command".into(),
)),
Some((other, _)) => Err(crate::Error::Config(format!(
"unknown mcp subcommand: {other:?}"
))),
None => Err(crate::Error::Config(
"no mcp subcommand selected; pass --help to see options".into(),
)),
}
}
pub async fn run(cli: Command, cfg: Option<&Config>) -> Result<()> {
let mounted = cli.subcommand(command(cfg));
let cli_for_dispatch = mounted.clone();
let matches = mounted.get_matches();
match matches.subcommand() {
Some((name, sub)) => {
let group_name = cfg
.and_then(|c| c.command_name.as_deref())
.unwrap_or(DEFAULT_COMMAND_NAME);
if name == group_name {
handle(sub, &cli_for_dispatch, cfg).await
} else {
Err(crate::Error::Config(format!(
"brontes::run only dispatches the {group_name:?} subtree; \
got subcommand {name:?}. Mount brontes::command() on a \
hand-built CLI for multi-subcommand apps."
)))
}
}
None => Err(crate::Error::Config(format!(
"no subcommand provided; expected the {:?} subtree",
cfg.and_then(|c| c.command_name.as_deref())
.unwrap_or(DEFAULT_COMMAND_NAME)
))),
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use clap::{Arg, Command};
use super::*;
use crate::annotations::ToolAnnotations;
use crate::config::Config;
#[test]
fn build_tool_name_omctl_case() {
let name = build_tool_name("omnistrate-ctl cost by-cell list", "omctl");
assert_eq!(name, "omctl_cost_by-cell_list");
}
#[test]
fn build_tool_name_single_token() {
let name = build_tool_name("myapp", "myapp");
assert_eq!(name, "myapp");
}
#[test]
fn build_tool_name_preserves_hyphens() {
let name = build_tool_name("myapp by-cell list", "myapp");
assert_eq!(name, "myapp_by-cell_list");
}
#[test]
fn build_tool_name_prefix_substitution() {
let name = build_tool_name("myapp mcp install", "myapp");
assert_eq!(name, "myapp_mcp_install");
}
#[test]
fn build_tool_name_collapses_spaces_in_prefix() {
assert_eq!(build_tool_name("ignored sub", "my prefix"), "my_prefix_sub");
}
fn root_with_list() -> Command {
Command::new("myapp").subcommand(Command::new("list").arg(Arg::new("limit").long("limit")))
}
#[test]
fn validate_paths_rejects_unknown_annotation() {
let root = root_with_list();
let resolved = crate::walk::walk(&root);
let cfg = Config::default().annotation(
"nonexistent path",
ToolAnnotations {
read_only_hint: Some(true),
..Default::default()
},
);
let result = validate_paths(&resolved, &cfg);
assert!(
matches!(result, Err(crate::Error::Config(_))),
"expected Config error, got {result:?}"
);
}
#[test]
fn validate_paths_accepts_known_path() {
let root = root_with_list();
let resolved = crate::walk::walk(&root);
let cfg = Config::default().annotation(
"myapp list",
ToolAnnotations {
read_only_hint: Some(true),
..Default::default()
},
);
assert!(validate_paths(&resolved, &cfg).is_ok());
}
#[test]
fn validate_paths_rejects_unknown_flag_on_known_path() {
let root = root_with_list();
let resolved = crate::walk::walk(&root);
let cfg =
Config::default().flag_schema("myapp list", "nonexistent-flag", serde_json::json!({}));
let result = validate_paths(&resolved, &cfg);
assert!(
matches!(result, Err(crate::Error::Config(_))),
"expected Config error for unknown flag, got {result:?}"
);
}
#[test]
fn validate_paths_accepts_known_flag() {
let root = root_with_list();
let resolved = crate::walk::walk(&root);
let cfg = Config::default().flag_schema(
"myapp list",
"limit",
serde_json::json!({"type": "integer"}),
);
assert!(validate_paths(&resolved, &cfg).is_ok());
}
#[test]
fn validate_paths_rejects_unknown_description_path() {
let root = root_with_list();
let resolved = crate::walk::walk(&root);
let cfg = Config::default().description("nonexistent path", "some text");
let result = validate_paths(&resolved, &cfg);
assert!(
matches!(&result, Err(crate::Error::Config(msg)) if msg.contains("descriptions")),
"expected Config error for unknown description path, got {result:?}"
);
}
#[test]
fn validate_paths_rejects_empty_description_text() {
let root = root_with_list();
let resolved = crate::walk::walk(&root);
let cfg = Config::default().description("myapp list", "");
let result = validate_paths(&resolved, &cfg);
assert!(
matches!(&result, Err(crate::Error::Config(msg)) if msg.contains("is empty")),
"expected Config error for empty description, got {result:?}"
);
}
#[test]
fn validate_paths_rejects_whitespace_only_description_text() {
let root = root_with_list();
let resolved = crate::walk::walk(&root);
let cfg = Config::default().description("myapp list", " \n\t ");
let result = validate_paths(&resolved, &cfg);
assert!(
matches!(&result, Err(crate::Error::Config(msg)) if msg.contains("is empty")),
"expected Config error for whitespace-only description, got {result:?}"
);
}
#[test]
fn validate_paths_rejects_unknown_description_mode_path() {
let root = root_with_list();
let resolved = crate::walk::walk(&root);
let cfg = Config::default()
.description_mode_for("nonexistent path", crate::config::DescriptionMode::Short);
let result = validate_paths(&resolved, &cfg);
assert!(
matches!(&result, Err(crate::Error::Config(msg)) if msg.contains("description_modes")),
"expected Config error for unknown description_mode path, got {result:?}"
);
}
#[test]
fn description_with_empty_cmd_path_validation_error() {
let root = root_with_list();
let cfg = Config::default().description("", "x");
let result = generate_tools(&root, &cfg);
assert!(
matches!(&result, Err(crate::Error::Config(msg)) if msg.contains("descriptions")),
"expected Config error for empty command-path description, got {result:?}"
);
}
#[test]
fn validate_paths_accepts_global_flag_at_child_path() {
use clap::ArgAction;
let root = Command::new("myapp")
.arg(
Arg::new("verbose")
.long("verbose")
.global(true)
.action(ArgAction::SetTrue),
)
.subcommand(Command::new("sub"));
let cfg = Config::default().flag_schema(
"myapp sub",
"verbose",
serde_json::json!({"type": "boolean", "description": "override"}),
);
let tools = generate_tools(&root, &cfg).expect("global flag at child path must validate");
assert!(!tools.is_empty(), "tree should produce at least one tool");
}
#[test]
fn first_match_wins_ordering() {
let root = Command::new("myapp").subcommand(Command::new("status").about("Show status"));
let cfg = Config::default()
.selector(crate::selector::Selector {
cmd: Some(Arc::new(|p: &str| p == "myapp status")),
..Default::default()
})
.selector(crate::selector::Selector {
cmd: Some(Arc::new(|p: &str| p == "myapp status")),
..Default::default()
});
let tools = generate_tools(&root, &cfg).expect("should succeed");
assert_eq!(
tools.iter().filter(|t| t.name.contains("status")).count(),
1,
"first-match-wins: only one tool per command"
);
}
#[test]
fn no_selectors_means_include_all() {
let root = Command::new("myapp")
.subcommand(Command::new("list").about("List"))
.subcommand(Command::new("create").about("Create"))
.subcommand(Command::new("delete").about("Delete"));
let cfg = Config::default();
let tools = generate_tools(&root, &cfg).expect("should succeed");
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
assert!(
names.contains(&"myapp_list"),
"missing myapp_list: {names:?}"
);
assert!(
names.contains(&"myapp_create"),
"missing myapp_create: {names:?}"
);
assert!(
names.contains(&"myapp_delete"),
"missing myapp_delete: {names:?}"
);
}
#[test]
fn tool_long_name_still_generated() {
let long_sub = "a-very-long-subcommand-name-exceeding-sixty-four-characters-total";
let root =
Command::new("myapp").subcommand(Command::new(long_sub).about("Long named command"));
let cfg = Config::default();
let tools = generate_tools(&root, &cfg).expect("should succeed");
let found = tools.iter().any(|t| t.name.contains(long_sub));
assert!(found, "long-named tool must still be generated");
}
#[tokio::test]
async fn handle_rejects_marker_subcommand_invocation() {
let cli = Command::new("myapp")
.version("0.0.1")
.subcommand(command(None));
let matches = cli
.clone()
.try_get_matches_from(["myapp", "mcp", crate::subcommands::MARKER_NAME])
.expect("clap parses the hidden marker subcommand");
let mcp_matches = matches
.subcommand_matches("mcp")
.expect("mcp subcommand selected");
let err = handle(mcp_matches, &cli, None)
.await
.expect_err("invoking the marker must surface an error");
let msg = err.to_string();
assert!(
matches!(err, crate::Error::Config(_)),
"expected Config error, got {err:?}"
);
assert!(
!msg.contains(crate::subcommands::MARKER_NAME),
"error message must not leak the marker name; got {msg:?}"
);
assert!(
msg.contains("internal marker subcommand is not a runnable command"),
"error must use the friendly message; got {msg:?}"
);
}
}