pub mod http;
pub mod stdio;
use std::collections::HashMap;
use std::sync::Arc;
use clap::Command;
use futures::future::BoxFuture;
use rmcp::ErrorData as McpError;
use rmcp::ServerHandler;
use rmcp::model::{
CallToolRequestParams, CallToolResult, Content, Implementation, InitializeResult,
ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool,
};
use rmcp::service::{RequestContext, RoleServer};
use crate::Config;
use crate::Result;
use crate::command::ResolvedTool;
use crate::selector::{BoxedNext, MiddlewareCtx, MiddlewareResult};
use crate::tool::{ToolInput, ToolOutput};
#[doc(hidden)]
pub struct BrontesServer {
cli: Command,
cfg: Arc<Config>,
tools: Vec<ResolvedTool>,
}
impl BrontesServer {
#[doc(hidden)]
pub fn new(mut cli: Command, cfg: Config) -> Result<Self> {
cli.build();
let tools = crate::command::generate_tools_with_middleware(&cli, &cfg)?;
Ok(Self {
cli,
cfg: Arc::new(cfg),
tools,
})
}
fn build_server_info(&self) -> ServerInfo {
let capabilities = ServerCapabilities::builder().enable_tools().build();
let server_info = self.cfg.implementation.clone().unwrap_or_else(|| {
Implementation::new(
self.cli.get_name().to_string(),
self.cli
.get_version()
.map_or_else(|| "0.0.0".to_string(), str::to_string),
)
});
InitializeResult::new(capabilities).with_server_info(server_info)
}
fn find_tool(&self, name: &str) -> Option<Tool> {
self.find_resolved(name).map(|r| r.tool.clone())
}
fn find_resolved(&self, name: &str) -> Option<&ResolvedTool> {
self.tools.iter().find(|t| t.tool.name.as_ref() == name)
}
}
impl ServerHandler for BrontesServer {
fn get_info(&self) -> ServerInfo {
self.build_server_info()
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> std::result::Result<ListToolsResult, McpError> {
let tools: Vec<Tool> = self.tools.iter().map(|r| r.tool.clone()).collect();
Ok(ListToolsResult::with_all_items(tools))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> std::result::Result<CallToolResult, McpError> {
let name = request.name.as_ref();
let Some(resolved) = self.find_resolved(name) else {
return Err(McpError::invalid_params(
format!("unknown tool: {name}"),
None,
));
};
let input: ToolInput = match request.arguments {
Some(map) => serde_json::from_value(serde_json::Value::Object(map)).map_err(|e| {
McpError::invalid_params(format!("invalid arguments for {name}: {e}"), None)
})?,
None => ToolInput::default(),
};
let env: HashMap<String, String> = self.cfg.default_env.clone();
let middleware = resolved.middleware.clone();
let command_path = resolved.command_path.clone();
let ctx = MiddlewareCtx {
cancellation_token: context.ct.clone(),
tool_name: name.to_string(),
input,
};
let exec_tool_name = name.to_string();
let next: BoxedNext = Box::new(
move |ctx_inner: MiddlewareCtx| -> BoxFuture<'static, MiddlewareResult> {
Box::pin(async move {
crate::exec::run_tool(
&exec_tool_name,
&ctx_inner.input,
&env,
ctx_inner.cancellation_token,
)
.await
})
},
);
let join_handle = if let Some(mw) = middleware {
tokio::spawn(async move { mw(ctx, next).await })
} else {
tokio::spawn(async move { next(ctx).await })
};
let result: MiddlewareResult = match join_handle.await {
Ok(r) => r,
Err(join_err) if join_err.is_panic() => {
let payload = join_err.try_into_panic().ok().map_or_else(
|| "unknown panic payload".to_string(),
|b| panic_message_from(&*b),
);
return Ok(tool_error_result(
name,
&command_path,
&crate::Error::Panic(payload),
));
}
Err(join_err) => {
return Ok(tool_error_result(
name,
&command_path,
&crate::Error::Panic(format!("middleware/exec task join error: {join_err}")),
));
}
};
match result {
Ok(output) => Ok(tool_output_to_result(name, &output)),
Err(e) => Ok(tool_error_result(name, &command_path, &e)),
}
}
fn get_tool(&self, name: &str) -> Option<Tool> {
self.find_tool(name)
}
}
fn tool_error_result(name: &str, command_path: &str, e: &crate::Error) -> CallToolResult {
let base = format!("tool '{name}' failed to execute: {e}");
let body = if command_path.is_empty() {
base
} else {
format!("{base} (command: \"{command_path}\")")
};
let mut r = CallToolResult::error(vec![Content::text(body.clone())]);
r.structured_content = Some(serde_json::json!({
"error": body,
"category": brontes_error_category(e),
"command": command_path,
}));
r
}
const fn brontes_error_category(e: &crate::Error) -> &'static str {
match e {
crate::Error::Config(_) => "config",
crate::Error::Io { .. } => "io",
crate::Error::Spawn(_) => "spawn",
crate::Error::Schema(_) => "schema",
crate::Error::EditorConfigRead { .. }
| crate::Error::EditorConfigJson { .. }
| crate::Error::EditorConfigBackup { .. }
| crate::Error::EditorConfigWrite { .. } => "editor_config",
crate::Error::Panic(_) => "panic",
crate::Error::McpInitialize(_) => "mcp_initialize",
crate::Error::Mcp(_) => "mcp",
}
}
fn panic_message_from(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&'static str>() {
return (*s).to_string();
}
if let Some(s) = payload.downcast_ref::<String>() {
return s.clone();
}
"unknown panic payload".to_string()
}
fn tool_output_to_result(tool_name: &str, output: &ToolOutput) -> CallToolResult {
let structured = serde_json::to_value(output).unwrap_or_else(|_| {
serde_json::json!({
"stdout": output.stdout,
"stderr": output.stderr,
"exit_code": output.exit_code,
})
});
if output.exit_code == 0 {
let body = if output.stdout.is_empty() && !output.stderr.is_empty() {
output.stderr.clone()
} else {
output.stdout.clone()
};
let mut r = CallToolResult::success(vec![Content::text(body)]);
r.structured_content = Some(structured);
r
} else {
let mut body = String::new();
if !output.stdout.is_empty() {
body.push_str(&output.stdout);
}
if !output.stderr.is_empty() {
if !body.is_empty() {
body.push('\n');
}
body.push_str(&output.stderr);
}
if body.is_empty() {
body = format!("tool '{tool_name}' exited with code {}", output.exit_code);
}
let mut r = CallToolResult::error(vec![Content::text(body)]);
r.structured_content = Some(structured);
r
}
}
#[cfg(test)]
mod tests {
use super::*;
fn root() -> Command {
Command::new("myapp")
.version("1.2.3")
.subcommand(Command::new("greet").about("Say hi"))
}
#[test]
fn server_info_uses_root_name_and_version_by_default() {
let s = BrontesServer::new(root(), Config::default()).expect("construct");
let info = s.build_server_info();
assert_eq!(info.server_info.name, "myapp");
assert_eq!(info.server_info.version, "1.2.3");
assert!(info.capabilities.tools.is_some());
}
#[test]
fn server_info_respects_config_implementation() {
let imp = Implementation::new("custom-name", "9.9.9");
let cfg = Config::default().implementation(imp);
let s = BrontesServer::new(root(), cfg).expect("construct");
let info = s.build_server_info();
assert_eq!(info.server_info.name, "custom-name");
assert_eq!(info.server_info.version, "9.9.9");
}
#[test]
fn find_tool_locates_walked_command() {
let s = BrontesServer::new(root(), Config::default()).expect("construct");
assert!(s.find_tool("myapp_greet").is_some());
assert!(s.find_tool("nonexistent").is_none());
}
#[test]
fn tools_cached_at_construction() {
let s = BrontesServer::new(root(), Config::default()).expect("construct");
let n1 = s.tools.len();
let _ = s.find_tool("myapp_greet");
let n2 = s.tools.len();
assert_eq!(n1, n2);
assert!(n1 >= 1, "at least one tool from the walked tree");
}
#[test]
fn tool_output_zero_exit_is_success() {
let out = ToolOutput {
stdout: "hi\n".into(),
stderr: String::new(),
exit_code: 0,
};
let result = tool_output_to_result("myapp_greet", &out);
assert_eq!(result.is_error, Some(false));
assert!(result.structured_content.is_some());
}
#[test]
fn tool_output_non_zero_is_error() {
let out = ToolOutput {
stdout: String::new(),
stderr: "boom\n".into(),
exit_code: 2,
};
let result = tool_output_to_result("myapp_greet", &out);
assert_eq!(result.is_error, Some(true));
}
#[test]
fn tool_error_result_includes_command_path_in_body() {
let e = crate::Error::Panic("test panic".to_string());
let result = tool_error_result("myapp_greet", "myapp greet", &e);
assert_eq!(result.is_error, Some(true));
let body = result
.content
.iter()
.filter_map(|c| c.as_text())
.map(|t| t.text.as_str())
.collect::<Vec<_>>()
.join("");
assert!(
body.contains("command: \"myapp greet\""),
"body must include command path; got: {body:?}"
);
let sc = result
.structured_content
.as_ref()
.expect("structured_content must be Some");
assert_eq!(
sc["command"].as_str(),
Some("myapp greet"),
"structured_content.command must equal the command path"
);
}
#[test]
fn tool_error_result_empty_command_path_omits_parenthetical() {
let e = crate::Error::Panic("boom".to_string());
let result = tool_error_result("myapp_greet", "", &e);
assert_eq!(result.is_error, Some(true));
let body = result
.content
.iter()
.filter_map(|c| c.as_text())
.map(|t| t.text.as_str())
.collect::<Vec<_>>()
.join("");
assert!(
!body.contains("command:"),
"empty command_path must not add the parenthetical; got: {body:?}"
);
}
}