#![cfg(feature = "mcp")]
use std::collections::HashMap;
use std::io::{BufRead, Write};
use serde_json::{json, Value};
use crate::model::{Command, ParsedCommand};
use crate::query::Registry;
pub struct McpServer {
registry: Registry,
server_name: String,
server_version: String,
}
impl McpServer {
pub fn new(registry: Registry) -> Self {
Self {
registry,
server_name: "argot".to_string(),
server_version: "0.1.0".to_string(),
}
}
pub fn server_name(mut self, name: impl Into<String>) -> Self {
self.server_name = name.into();
self
}
pub fn server_version(mut self, version: impl Into<String>) -> Self {
self.server_version = version.into();
self
}
pub fn serve<R: BufRead, W: Write>(
&self,
mut reader: R,
writer: &mut W,
) -> std::io::Result<()> {
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line)?;
if n == 0 {
break; }
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let request: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => {
let error = json!({"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}});
writeln!(writer, "{}", error)?;
writer.flush()?;
continue;
}
};
if let Some(response) = self.handle_request(&request) {
writeln!(writer, "{}", response)?;
writer.flush()?;
}
}
Ok(())
}
pub fn serve_stdio(&self) -> std::io::Result<()> {
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let reader = std::io::BufReader::new(stdin.lock());
let mut writer = stdout.lock();
self.serve(reader, &mut writer)
}
fn handle_request(&self, request: &Value) -> Option<Value> {
let id = request.get("id")?;
let method = request.get("method")?.as_str().unwrap_or("");
let params = request.get("params").unwrap_or(&Value::Null);
Some(match method {
"initialize" => self.handle_initialize(id),
"tools/list" => self.handle_tools_list(id),
"tools/call" => self.handle_tools_call(id, params),
_ => json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32601,
"message": "Method not found"
}
}),
})
}
fn handle_initialize(&self, id: &Value) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": self.server_name,
"version": self.server_version
}
}
})
}
fn handle_tools_list(&self, id: &Value) -> Value {
let mut tools: Vec<Value> = Vec::new();
for cmd in self.registry.list_commands() {
Self::collect_tools(cmd, "", &mut tools);
}
json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"tools": tools
}
})
}
fn collect_tools(cmd: &Command, prefix: &str, tools: &mut Vec<Value>) {
tools.push(Self::command_to_tool(cmd, prefix));
let sub_prefix = format!("{}{}-", prefix, cmd.canonical);
for sub in &cmd.subcommands {
Self::collect_tools(sub, &sub_prefix, tools);
}
}
fn handle_tools_call(&self, id: &Value, params: &Value) -> Value {
let name = match params.get("name").and_then(|v| v.as_str()) {
Some(n) => n,
None => {
return json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32602,
"message": "Invalid params: missing 'name'"
}
});
}
};
let arguments = params.get("arguments").unwrap_or(&Value::Null);
let cmd = match Self::find_command_by_tool_name(&self.registry, name) {
Some(c) => c,
None => {
return json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32602,
"message": format!("unknown tool: {}", name)
}
});
}
};
let mut args: HashMap<String, String> = HashMap::new();
for arg_def in &cmd.arguments {
if let Some(val) = arguments.get(&arg_def.name) {
let val_str = value_to_string(val);
args.insert(arg_def.name.clone(), val_str);
} else if let Some(default) = &arg_def.default {
args.insert(arg_def.name.clone(), default.clone());
}
}
let mut flags: HashMap<String, String> = HashMap::new();
for flag_def in &cmd.flags {
if let Some(val) = arguments.get(&flag_def.name) {
let val_str = value_to_string(val);
flags.insert(flag_def.name.clone(), val_str);
} else if let Some(default) = &flag_def.default {
flags.insert(flag_def.name.clone(), default.clone());
}
}
let parsed = ParsedCommand {
command: cmd,
args,
flags,
};
match &cmd.handler {
Some(handler) => match handler(&parsed) {
Ok(()) => json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{"type": "text", "text": "Command executed successfully."}]
}
}),
Err(e) => json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{"type": "text", "text": format!("Error: {}", e)}],
"isError": true
}
}),
},
None => json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{"type": "text", "text": "Command has no handler."}]
}
}),
}
}
fn command_to_tool(cmd: &Command, prefix: &str) -> Value {
let tool_name = format!("{}{}", prefix, cmd.canonical);
let desc = if !cmd.summary.is_empty() {
&cmd.summary
} else {
&cmd.description
};
json!({
"name": tool_name,
"description": desc,
"inputSchema": Self::build_input_schema(cmd)
})
}
fn build_input_schema(cmd: &Command) -> Value {
let mut properties: serde_json::Map<String, Value> = serde_json::Map::new();
let mut required: Vec<String> = Vec::new();
for arg in &cmd.arguments {
properties.insert(
arg.name.clone(),
json!({"type": "string", "description": arg.description}),
);
if arg.required {
required.push(arg.name.clone());
}
}
for flag in &cmd.flags {
let prop_type = if flag.takes_value {
"string"
} else {
"boolean"
};
properties.insert(
flag.name.clone(),
json!({"type": prop_type, "description": flag.description}),
);
if flag.required {
required.push(flag.name.clone());
}
}
let mut schema = json!({
"type": "object",
"properties": properties
});
if !required.is_empty() {
schema["required"] = json!(required);
}
schema
}
fn find_command_by_tool_name<'a>(registry: &'a Registry, name: &str) -> Option<&'a Command> {
for cmd in registry.list_commands() {
if let Some(found) = find_in_tree(cmd, "", name) {
return Some(found);
}
}
None
}
}
fn find_in_tree<'a>(cmd: &'a Command, prefix: &str, target: &str) -> Option<&'a Command> {
let tool_name = format!("{}{}", prefix, cmd.canonical);
let sub_prefix = format!("{}-", tool_name);
if tool_name == target {
return Some(cmd);
}
if target.starts_with(&sub_prefix) {
for sub in &cmd.subcommands {
if let Some(found) = find_in_tree(sub, &sub_prefix, target) {
return Some(found);
}
}
}
None
}
fn value_to_string(val: &Value) -> String {
match val {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Null => String::new(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use std::sync::Arc;
fn make_registry() -> Registry {
let deploy = Command::builder("deploy")
.summary("Deploy the application")
.argument(
crate::model::Argument::builder("env")
.description("target environment")
.required()
.build()
.unwrap(),
)
.flag(
crate::model::Flag::builder("dry-run")
.description("dry run mode")
.build()
.unwrap(),
)
.handler(Arc::new(|_parsed| Ok(())))
.build()
.unwrap();
let sub_cmd = Command::builder("rollback")
.summary("Rollback the deployment")
.build()
.unwrap();
let deploy_with_sub = Command::builder("service")
.summary("Service management")
.subcommand(sub_cmd)
.build()
.unwrap();
Registry::new(vec![deploy, deploy_with_sub])
}
fn run_server(input: &str) -> String {
let registry = make_registry();
let server = McpServer::new(registry);
run_server_with(&server, input)
}
fn run_server_with(server: &McpServer, input: &str) -> String {
let reader = Cursor::new(input.as_bytes().to_vec());
let mut output = Vec::new();
server.serve(reader, &mut output).unwrap();
String::from_utf8(output).unwrap()
}
#[test]
fn test_tools_list() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#,
"\n",
r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#,
"\n"
);
let output = run_server(input);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 2);
let init_resp: Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(init_resp["id"], 0);
assert!(init_resp["result"]["capabilities"].is_object());
let list_resp: Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(list_resp["id"], 1);
let tools = list_resp["result"]["tools"].as_array().unwrap();
let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(
tool_names.contains(&"deploy"),
"expected 'deploy' in tools: {:?}",
tool_names
);
assert!(
tool_names.contains(&"service"),
"expected 'service' in tools: {:?}",
tool_names
);
assert!(
tool_names.contains(&"service-rollback"),
"expected 'service-rollback' in tools: {:?}",
tool_names
);
}
#[test]
fn test_tools_call_with_handler() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"deploy","arguments":{"env":"prod","dry-run":true}}}"#,
"\n"
);
let output = run_server(input);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 1);
let resp: Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(resp["id"], 2);
assert!(
resp["error"].is_null(),
"expected no error, got: {}",
resp["error"]
);
let content = resp["result"]["content"].as_array().unwrap();
assert!(!content.is_empty());
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "Command executed successfully.");
}
#[test]
fn test_tools_call_unknown_tool() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"nonexistent","arguments":{}}}"#,
"\n"
);
let output = run_server(input);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 1);
let resp: Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(resp["id"], 3);
assert!(!resp["error"].is_null(), "expected error for unknown tool");
assert_eq!(resp["error"]["code"], -32602);
let msg = resp["error"]["message"].as_str().unwrap();
assert!(
msg.contains("nonexistent"),
"error message should mention tool name: {}",
msg
);
}
#[test]
fn test_notification_no_response() {
let input = concat!(
r#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#,
"\n"
);
let output = run_server(input);
assert!(
output.trim().is_empty(),
"expected no output for notification, got: {:?}",
output
);
}
#[test]
fn test_invalid_json() {
let input = "this is not json\n";
let output = run_server(input);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 1);
let resp: Value = serde_json::from_str(lines[0]).unwrap();
assert!(!resp["error"].is_null(), "expected parse error response");
assert_eq!(resp["error"]["code"], -32700);
assert_eq!(resp["error"]["message"], "Parse error");
}
#[test]
fn test_tools_call_no_handler() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"service","arguments":{}}}"#,
"\n"
);
let output = run_server(input);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 1);
let resp: Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(resp["id"], 4);
assert!(
resp["error"].is_null(),
"expected no JSON-RPC error, got: {}",
resp["error"]
);
let content = resp["result"]["content"].as_array().unwrap();
assert_eq!(content[0]["text"], "Command has no handler.");
}
#[test]
fn test_method_not_found() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":5,"method":"unknown/method","params":{}}"#,
"\n"
);
let output = run_server(input);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 1);
let resp: Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(resp["id"], 5);
assert_eq!(resp["error"]["code"], -32601);
}
#[test]
fn test_tools_list_three_level_nesting() {
use serde_json::Value;
let leaf = crate::model::Command::builder("blue-green")
.summary("Blue-green deployment strategy")
.build()
.unwrap();
let mid = crate::model::Command::builder("deployment")
.summary("Deployment operations")
.subcommand(leaf)
.build()
.unwrap();
let top = crate::model::Command::builder("service")
.summary("Service management")
.subcommand(mid)
.build()
.unwrap();
let registry = crate::query::Registry::new(vec![top]);
let server = McpServer::new(registry);
let input = concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#,
"\n"
);
let output = run_server_with(&server, input);
let line = output.lines().next().unwrap();
let resp: Value = serde_json::from_str(line).unwrap();
let tools = resp["result"]["tools"].as_array().unwrap();
let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(tool_names.contains(&"service"), "top-level");
assert!(tool_names.contains(&"service-deployment"), "2nd level");
assert!(
tool_names.contains(&"service-deployment-blue-green"),
"3rd level"
);
}
#[test]
fn test_build_input_schema() {
let cmd = Command::builder("deploy")
.argument(
crate::model::Argument::builder("env")
.description("target environment")
.required()
.build()
.unwrap(),
)
.flag(
crate::model::Flag::builder("dry-run")
.description("dry run mode")
.build()
.unwrap(),
)
.flag(
crate::model::Flag::builder("output")
.description("output format")
.takes_value()
.build()
.unwrap(),
)
.build()
.unwrap();
let schema = McpServer::build_input_schema(&cmd);
assert_eq!(schema["type"], "object");
let props = &schema["properties"];
assert_eq!(props["env"]["type"], "string");
assert_eq!(props["dry-run"]["type"], "boolean");
assert_eq!(props["output"]["type"], "string");
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("env")));
assert!(!required.contains(&json!("dry-run")));
}
}