use std::collections::BTreeMap;
use crate::schema::FieldMeta;
#[derive(Debug, Clone)]
pub struct ToolEntry {
pub name: String,
pub description: Option<String>,
pub input_schema: serde_json::Value,
pub output_schema: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct CommandEntry {
pub is_group: bool,
pub description: Option<String>,
pub commands: BTreeMap<String, CommandEntry>,
pub args_fields: Vec<FieldMeta>,
pub options_fields: Vec<FieldMeta>,
pub output_schema: Option<serde_json::Value>,
}
#[cfg(feature = "mcp")]
#[derive(Debug, Clone, Default)]
pub struct McpServeOptions {
pub version: Option<String>,
}
pub fn collect_tools(
commands: &BTreeMap<String, CommandEntry>,
prefix: &[String],
) -> Vec<ToolEntry> {
let mut result: Vec<ToolEntry> = Vec::new();
for (name, entry) in commands {
let mut path = prefix.to_vec();
path.push(name.clone());
if entry.is_group {
result.extend(collect_tools(&entry.commands, &path));
} else {
let tool_name = path.join("_");
let input_schema = build_tool_schema(&entry.args_fields, &entry.options_fields);
result.push(ToolEntry {
name: tool_name,
description: entry.description.clone(),
input_schema,
output_schema: entry.output_schema.clone(),
});
}
}
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub(crate) fn build_tool_schema(
args_fields: &[FieldMeta],
options_fields: &[FieldMeta],
) -> serde_json::Value {
let mut properties = serde_json::Map::new();
let mut required: Vec<String> = Vec::new();
for field in args_fields.iter().chain(options_fields.iter()) {
let mut prop = serde_json::Map::new();
prop.insert(
"type".to_string(),
serde_json::Value::String(field_type_to_json_type(&field.field_type)),
);
if let Some(desc) = field.description {
prop.insert(
"description".to_string(),
serde_json::Value::String(desc.to_string()),
);
}
if let Some(default) = &field.default {
prop.insert("default".to_string(), default.clone());
}
properties.insert(field.name.to_string(), serde_json::Value::Object(prop));
if field.required {
required.push(field.name.to_string());
}
}
let mut schema = serde_json::Map::new();
schema.insert(
"type".to_string(),
serde_json::Value::String("object".to_string()),
);
schema.insert(
"properties".to_string(),
serde_json::Value::Object(properties),
);
if !required.is_empty() {
schema.insert(
"required".to_string(),
serde_json::Value::Array(
required
.into_iter()
.map(serde_json::Value::String)
.collect(),
),
);
}
serde_json::Value::Object(schema)
}
fn field_type_to_json_type(ft: &crate::schema::FieldType) -> String {
use crate::schema::FieldType;
match ft {
FieldType::String => "string".to_string(),
FieldType::Number => "number".to_string(),
FieldType::Boolean => "boolean".to_string(),
FieldType::Array(_) => "array".to_string(),
FieldType::Enum(_) => "string".to_string(),
FieldType::Count => "number".to_string(),
FieldType::Value => "string".to_string(),
}
}
#[cfg(feature = "mcp")]
mod server {
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use futures::StreamExt;
use serde_json::Value;
use rmcp::handler::server::ServerHandler;
use rmcp::model::{
CallToolRequestParam, CallToolResult, Content, Implementation, ListToolsResult,
PaginatedRequestParam, ServerCapabilities, ServerInfo, Tool,
};
use rmcp::service::{Peer, RequestContext, RoleServer};
use rmcp::Error as McpError;
use crate::command::{self, CommandDef, ExecuteOptions, ParseMode};
use crate::middleware::MiddlewareFn;
use crate::output::Format;
use crate::schema::FieldMeta;
use super::{build_tool_schema, McpServeOptions};
struct ResolvedTool {
name: String,
description: String,
input_schema: Arc<serde_json::Map<String, Value>>,
command: Arc<CommandDef>,
middleware: Vec<MiddlewareFn>,
}
fn collect_resolved_tools(
commands: &BTreeMap<String, crate::cli::CommandEntry>,
prefix: &[String],
parent_middleware: &[MiddlewareFn],
) -> Vec<ResolvedTool> {
let mut result = Vec::new();
for (name, entry) in commands {
let mut path = prefix.to_vec();
path.push(name.clone());
match entry {
crate::cli::CommandEntry::Leaf(def) => {
let tool_name = path.join("_");
let schema_value =
build_tool_schema(&def.args_fields, &def.options_fields);
let input_schema = match schema_value {
Value::Object(map) => Arc::new(map),
_ => Arc::new(serde_json::Map::new()),
};
result.push(ResolvedTool {
name: tool_name,
description: def.description.clone().unwrap_or_default(),
input_schema,
command: Arc::clone(def),
middleware: parent_middleware.to_vec(),
});
}
crate::cli::CommandEntry::Group {
commands: sub,
middleware,
..
} => {
let mut merged_mw = parent_middleware.to_vec();
merged_mw.extend(middleware.iter().cloned());
result.extend(collect_resolved_tools(sub, &path, &merged_mw));
}
crate::cli::CommandEntry::FetchGateway { .. } => {
}
}
}
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
#[derive(Clone)]
struct IncurMcpServer {
cli_name: String,
cli_version: Option<String>,
server_name: String,
server_version: String,
tools_by_name: Arc<HashMap<String, Arc<ResolvedTool>>>,
tool_list: Arc<Vec<Tool>>,
root_middleware: Vec<MiddlewareFn>,
env_fields: Vec<FieldMeta>,
peer: Option<Peer<RoleServer>>,
}
impl IncurMcpServer {
fn new(
name: String,
version: String,
resolved_tools: Vec<ResolvedTool>,
root_middleware: Vec<MiddlewareFn>,
env_fields: Vec<FieldMeta>,
) -> Self {
let tool_list: Vec<Tool> = resolved_tools
.iter()
.map(|t| {
Tool::new(
Cow::Owned(t.name.clone()),
Cow::Owned(t.description.clone()),
Arc::clone(&t.input_schema),
)
})
.collect();
let mut tools_by_name = HashMap::new();
for tool in resolved_tools {
let name = tool.name.clone();
tools_by_name.insert(name, Arc::new(tool));
}
IncurMcpServer {
cli_name: name.clone(),
cli_version: Some(version.clone()),
server_name: name,
server_version: version,
tools_by_name: Arc::new(tools_by_name),
tool_list: Arc::new(tool_list),
root_middleware,
env_fields,
peer: None,
}
}
}
impl ServerHandler for IncurMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: Default::default(),
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation {
name: self.server_name.clone(),
version: self.server_version.clone(),
},
instructions: None,
}
}
fn get_peer(&self) -> Option<Peer<RoleServer>> {
self.peer.clone()
}
fn set_peer(&mut self, peer: Peer<RoleServer>) {
self.peer = Some(peer);
}
fn list_tools(
&self,
_request: PaginatedRequestParam,
_context: RequestContext<RoleServer>,
) -> impl std::future::Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
let tools = (*self.tool_list).clone();
std::future::ready(Ok(ListToolsResult {
tools,
next_cursor: None,
}))
}
fn call_tool(
&self,
request: CallToolRequestParam,
_context: RequestContext<RoleServer>,
) -> impl std::future::Future<Output = Result<CallToolResult, McpError>> + Send + '_ {
let tools_by_name = Arc::clone(&self.tools_by_name);
let cli_name = self.cli_name.clone();
let cli_version = self.cli_version.clone();
let root_middleware = self.root_middleware.clone();
let env_fields = self.env_fields.clone();
async move {
let tool_name = request.name.to_string();
let tool = tools_by_name.get(&tool_name).ok_or_else(|| {
McpError::invalid_params(format!("Unknown tool: {tool_name}"), None)
})?;
let input_options: BTreeMap<String, Value> =
request.arguments.unwrap_or_default().into_iter().collect();
let mut all_middleware = root_middleware.clone();
all_middleware.extend(tool.middleware.iter().cloned());
all_middleware.extend(tool.command.middleware.iter().cloned());
let env_source: HashMap<String, String> = std::env::vars().collect();
let result = command::execute(
Arc::clone(&tool.command),
ExecuteOptions {
agent: true,
argv: vec![],
defaults: None,
env_fields: env_fields.clone(),
env_source,
format: Format::Json,
format_explicit: true,
input_options,
middlewares: all_middleware,
name: cli_name,
parse_mode: ParseMode::Flat,
path: tool_name.clone(),
vars_fields: vec![],
version: cli_version,
},
)
.await;
match result {
command::InternalResult::Ok { data, .. } => {
let text =
serde_json::to_string(&data).unwrap_or_else(|_| "null".into());
Ok(CallToolResult::success(vec![Content::text(text)]))
}
command::InternalResult::Error { message, .. } => {
let text = if message.is_empty() {
"Command failed".to_string()
} else {
message
};
Ok(CallToolResult::error(vec![Content::text(text)]))
}
command::InternalResult::Stream(stream) => {
let chunks: Vec<Value> = stream.collect().await;
let text =
serde_json::to_string(&chunks).unwrap_or_else(|_| "[]".into());
Ok(CallToolResult::success(vec![Content::text(text)]))
}
}
}
}
}
pub async fn serve(
name: &str,
version: &str,
commands: &BTreeMap<String, crate::cli::CommandEntry>,
root_middleware: &[MiddlewareFn],
env_fields: &[FieldMeta],
_options: &McpServeOptions,
) -> Result<(), crate::errors::Error> {
use rmcp::transport::io::stdio;
use rmcp::ServiceExt;
let resolved = collect_resolved_tools(commands, &[], &[]);
let server = IncurMcpServer::new(
name.to_string(),
version.to_string(),
resolved,
root_middleware.to_vec(),
env_fields.to_vec(),
);
let transport = stdio();
let running = server.serve(transport).await.map_err(|e| {
crate::errors::Error::Other(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
format!("MCP server failed to start: {e}"),
)))
})?;
let _quit_reason = running.waiting().await.map_err(|e| {
crate::errors::Error::Other(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
format!("MCP server task failed: {e}"),
)))
})?;
Ok(())
}
}
#[cfg(feature = "mcp")]
pub async fn serve(
name: &str,
version: &str,
commands: &std::collections::BTreeMap<String, crate::cli::CommandEntry>,
root_middleware: &[crate::middleware::MiddlewareFn],
env_fields: &[FieldMeta],
options: &McpServeOptions,
) -> Result<(), crate::errors::Error> {
server::serve(name, version, commands, root_middleware, env_fields, options).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::{to_kebab, FieldType};
fn make_field(name: &'static str, ft: FieldType, required: bool) -> FieldMeta {
FieldMeta {
name,
cli_name: to_kebab(name),
description: Some("A field"),
field_type: ft,
required,
default: None,
alias: None,
deprecated: false,
env_name: None,
}
}
fn make_leaf(desc: &str) -> CommandEntry {
CommandEntry {
is_group: false,
description: Some(desc.to_string()),
commands: BTreeMap::new(),
args_fields: vec![],
options_fields: vec![],
output_schema: None,
}
}
fn make_group(desc: &str, commands: BTreeMap<String, CommandEntry>) -> CommandEntry {
CommandEntry {
is_group: true,
description: Some(desc.to_string()),
commands,
args_fields: vec![],
options_fields: vec![],
output_schema: None,
}
}
#[test]
fn test_collect_tools_flat() {
let mut commands = BTreeMap::new();
commands.insert("deploy".to_string(), make_leaf("Deploy app"));
commands.insert("status".to_string(), make_leaf("Show status"));
let tools = collect_tools(&commands, &[]);
assert_eq!(tools.len(), 2);
assert_eq!(tools[0].name, "deploy");
assert_eq!(tools[1].name, "status");
}
#[test]
fn test_collect_tools_nested() {
let mut sub = BTreeMap::new();
sub.insert("app".to_string(), make_leaf("Deploy app"));
sub.insert("config".to_string(), make_leaf("Deploy config"));
let mut commands = BTreeMap::new();
commands.insert("deploy".to_string(), make_group("Deploy group", sub));
commands.insert("status".to_string(), make_leaf("Show status"));
let tools = collect_tools(&commands, &[]);
assert_eq!(tools.len(), 3);
assert_eq!(tools[0].name, "deploy_app");
assert_eq!(tools[1].name, "deploy_config");
assert_eq!(tools[2].name, "status");
}
#[test]
fn test_collect_tools_sorted() {
let mut commands = BTreeMap::new();
commands.insert("zebra".to_string(), make_leaf("Z"));
commands.insert("alpha".to_string(), make_leaf("A"));
let tools = collect_tools(&commands, &[]);
assert_eq!(tools[0].name, "alpha");
assert_eq!(tools[1].name, "zebra");
}
#[test]
fn test_build_tool_schema_basic() {
let args = vec![make_field("target", FieldType::String, true)];
let opts = vec![make_field("verbose", FieldType::Boolean, false)];
let schema = build_tool_schema(&args, &opts);
let obj = schema.as_object().unwrap();
assert_eq!(obj["type"], "object");
let props = obj["properties"].as_object().unwrap();
assert!(props.contains_key("target"));
assert!(props.contains_key("verbose"));
let required = obj["required"].as_array().unwrap();
assert_eq!(required.len(), 1);
assert_eq!(required[0], "target");
}
#[test]
fn test_build_tool_schema_no_required() {
let schema = build_tool_schema(&[], &[make_field("verbose", FieldType::Boolean, false)]);
let obj = schema.as_object().unwrap();
assert!(!obj.contains_key("required"));
}
#[test]
fn test_field_type_to_json_type() {
assert_eq!(field_type_to_json_type(&FieldType::String), "string");
assert_eq!(field_type_to_json_type(&FieldType::Number), "number");
assert_eq!(field_type_to_json_type(&FieldType::Boolean), "boolean");
assert_eq!(
field_type_to_json_type(&FieldType::Array(Box::new(FieldType::String))),
"array"
);
assert_eq!(
field_type_to_json_type(&FieldType::Enum(vec!["a".to_string()])),
"string"
);
assert_eq!(field_type_to_json_type(&FieldType::Count), "number");
}
}