use std::collections::HashSet;
use std::process::ExitCode;
use std::sync::Arc;
use clap::{CommandFactory, Parser};
use serde_json::{Value, json};
use tokio::io::AsyncBufReadExt;
use ignition_cli::cli::{Cli, McpArgs};
use ignition_core::error::CoreError;
use crate::GUARDED_OPS;
use crate::render::RenderMode;
pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";
pub(crate) const EXCLUDED_LEAVES: &[&str] = &["completions", "mcp", "lsp", "tui", "edit"];
#[derive(Debug, Clone)]
pub(crate) struct ArgSpec {
pub key: String,
pub positional: bool,
pub boolean: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct CatalogEntry {
pub name: String,
pub path: String,
pub description: String,
pub schema: Value,
pub args: Vec<ArgSpec>,
}
pub(crate) type Catalog = Arc<Vec<CatalogEntry>>;
pub(crate) fn tool_name_for_path(path: &str) -> String {
path.replace(' ', "_")
}
pub(crate) fn path_is_excluded(path: &str) -> bool {
path.split(' ')
.any(|segment| EXCLUDED_LEAVES.contains(&segment))
}
pub(crate) fn build_catalog() -> Catalog {
let mut entries = Vec::new();
walk_catalog(&Cli::command(), "", &mut entries);
Arc::new(entries)
}
fn walk_catalog(cmd: &clap::Command, prefix: &str, out: &mut Vec<CatalogEntry>) {
for sub in cmd.get_subcommands() {
if sub.get_name() == "help" || sub.is_hide_set() {
continue;
}
let path = if prefix.is_empty() {
sub.get_name().to_string()
} else {
format!("{prefix} {}", sub.get_name())
};
let has_real_children = sub.get_subcommands().any(|c| c.get_name() != "help");
if (!has_real_children || !sub.is_subcommand_required_set()) && !path_is_excluded(&path) {
out.push(leaf_entry(&path, sub));
}
walk_catalog(sub, &path, out);
}
}
fn leaf_entry(path: &str, cmd: &clap::Command) -> CatalogEntry {
let guarded = GUARDED_OPS
.iter()
.any(|(guarded_path, _)| *guarded_path == path);
let mut properties = serde_json::Map::new();
let mut required: Vec<Value> = Vec::new();
let mut args = Vec::new();
for arg in cmd.get_arguments() {
if arg.is_hide_set() || arg.is_global_set() {
continue;
}
let id = arg.get_id().as_str().to_string();
if id == "help" || id == "version" {
continue;
}
let positional = arg.is_positional();
let key = if positional {
id.clone()
} else {
arg.get_long().unwrap_or(id.as_str()).to_string()
};
let action = arg.get_action();
let boolean = matches!(action, clap::ArgAction::SetTrue);
let repeatable = matches!(action, clap::ArgAction::Append);
args.push(ArgSpec {
key: key.clone(),
positional,
boolean,
});
let mut prop = serde_json::Map::new();
let help = arg
.get_long_help()
.or_else(|| arg.get_help())
.map(|styled| styled.to_string())
.unwrap_or_default();
if !help.is_empty() {
prop.insert("description".into(), Value::String(help));
}
if boolean {
prop.insert("type".into(), Value::String("boolean".into()));
} else {
let enum_values: Vec<Value> = arg
.get_possible_values()
.iter()
.filter(|pv| !pv.is_hide_set())
.map(|pv| Value::String(pv.get_name().to_string()))
.collect();
if repeatable {
let mut item = serde_json::Map::new();
item.insert("type".into(), Value::String("string".into()));
if !enum_values.is_empty() {
item.insert("enum".into(), Value::Array(enum_values));
}
prop.insert("type".into(), Value::String("array".into()));
prop.insert("items".into(), Value::Object(item));
} else {
prop.insert("type".into(), Value::String("string".into()));
if !enum_values.is_empty() {
prop.insert("enum".into(), Value::Array(enum_values));
}
}
}
if arg.is_required_set() {
required.push(Value::String(key.clone()));
}
properties.insert(key, Value::Object(prop));
}
if guarded {
properties.insert(
"confirm".into(),
json!({
"type": "boolean",
"description": "Set true to execute this destructive operation; omit or \
false to receive the refusal."
}),
);
}
let mut schema = serde_json::Map::new();
schema.insert("type".into(), Value::String("object".into()));
schema.insert("properties".into(), Value::Object(properties));
schema.insert("required".into(), Value::Array(required));
CatalogEntry {
name: tool_name_for_path(path),
path: path.to_string(),
description: cmd
.get_about()
.map(|styled| styled.to_string())
.unwrap_or_default(),
schema: Value::Object(schema),
args,
}
}
pub(crate) async fn serve(args: McpArgs, profile_flag: Option<&str>) -> ExitCode {
let McpArgs { serve } = args;
debug_assert_eq!(serve, "serve");
let catalog = build_catalog();
let (resp_tx, resp_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let writer = tokio::spawn(writer_task(resp_rx));
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
let mut line = String::new();
loop {
line.clear();
match stdin.read_line(&mut line).await {
Ok(0) | Err(_) => break, Ok(_) => {}
}
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
continue;
}
let message: Value = match serde_json::from_str(trimmed) {
Ok(parsed) => parsed,
Err(_) => {
tracing::warn!("mcp: dropped an unparseable stdin line");
continue;
}
};
let id = message.get("id").cloned();
let Some(method) = message
.get("method")
.and_then(Value::as_str)
.map(str::to_string)
else {
tracing::warn!("mcp: ignored a message without a method");
continue;
};
if method.starts_with("notifications/") {
continue;
}
let Some(id) = id else {
continue;
};
match method.as_str() {
"initialize" => {
let requested = message
.pointer("/params/protocolVersion")
.and_then(Value::as_str);
let result = json!({
"protocolVersion": negotiate(requested),
"capabilities": { "tools": {} },
"serverInfo": { "name": "ign", "version": env!("CARGO_PKG_VERSION") },
});
let _ = resp_tx.send(response_message(Some(id), Ok(result)));
}
"ping" => {
let _ = resp_tx.send(response_message(Some(id), Ok(json!({}))));
}
"tools/list" => {
let tools: Vec<Value> = catalog.iter().map(tool_to_wire).collect();
let _ = resp_tx.send(response_message(Some(id), Ok(json!({ "tools": tools }))));
}
"tools/call" => {
let tx = resp_tx.clone();
let catalog = Arc::clone(&catalog);
let profile = profile_flag.map(str::to_string);
let params = message.get("params").cloned().unwrap_or(Value::Null);
tokio::spawn(async move {
let answer = execute(¶ms, profile.as_deref(), &catalog).await;
let _ = tx.send(response_message(Some(id), answer));
});
}
unknown => {
let _ = resp_tx.send(response_message(
Some(id),
Err((-32601, format!("Method not found: {unknown}"))),
));
}
}
}
drop(resp_tx);
let _ = writer.await;
ExitCode::SUCCESS
}
async fn writer_task(mut resp_rx: tokio::sync::mpsc::UnboundedReceiver<String>) {
use tokio::io::AsyncWriteExt;
let mut stdout = tokio::io::stdout();
while let Some(line) = resp_rx.recv().await {
if stdout.write_all(line.as_bytes()).await.is_err()
|| stdout.write_all(b"\n").await.is_err()
{
break; }
let _ = stdout.flush().await;
}
}
fn negotiate(requested: Option<&str>) -> &'static str {
match requested {
Some(version) if version == PROTOCOL_VERSION => PROTOCOL_VERSION,
_ => PROTOCOL_VERSION,
}
}
fn tool_to_wire(entry: &CatalogEntry) -> Value {
json!({
"name": entry.name,
"description": entry.description,
"inputSchema": entry.schema,
})
}
fn response_message(id: Option<Value>, answer: Result<Value, (i64, String)>) -> String {
let mut message = serde_json::Map::new();
message.insert("jsonrpc".into(), Value::String("2.0".into()));
if let Some(id) = id {
message.insert("id".into(), id);
}
match answer {
Ok(result) => {
message.insert("result".into(), result);
}
Err((code, text)) => {
message.insert("error".into(), json!({ "code": code, "message": text }));
}
}
serde_json::to_string(&Value::Object(message)).expect("a JSON-RPC response serializes")
}
async fn execute(
params: &Value,
profile_flag: Option<&str>,
catalog: &Catalog,
) -> Result<Value, (i64, String)> {
let tool_name = params
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| (-32602, "tools/call requires a string tool name".to_string()))?;
let entry = catalog
.iter()
.find(|entry| entry.name == tool_name)
.ok_or_else(|| (-32601, format!("Unknown tool: {tool_name}")))?;
let arguments = params.get("arguments").and_then(Value::as_object);
const SINK_STREAMING_TOOLS: &[&str] = &["rig_logs"];
if SINK_STREAMING_TOOLS.contains(&tool_name) {
return Err((
-32602,
format!(
"tool {tool_name} streams raw lines to stdout and cannot ride the MCP \
protocol stream — run `ign {}` in a terminal instead",
entry.path
),
));
}
let follow = arguments
.and_then(|args| args.get("follow"))
.and_then(Value::as_bool);
if entry.path == "logs" && follow == Some(true) {
return Err((
-32602,
"tool logs --follow streams raw lines to stdout and cannot ride the MCP \
protocol stream — run `ign logs -f` in a terminal instead"
.to_string(),
));
}
let arguments = arguments.cloned().unwrap_or_default();
let is_guarded = GUARDED_OPS.iter().any(|(path, _)| *path == entry.path);
let mut known: HashSet<&str> = entry.args.iter().map(|spec| spec.key.as_str()).collect();
if is_guarded {
known.insert("confirm");
}
let unknown: Vec<&str> = arguments
.keys()
.map(String::as_str)
.filter(|key| !known.contains(key))
.collect();
if !unknown.is_empty() {
return Err((
-32602,
format!(
"Unknown argument(s) for {tool_name}: {}",
unknown.join(", ")
),
));
}
let confirm = arguments.get("confirm").and_then(Value::as_bool);
let mut argv: Vec<String> = vec!["ign".to_string()];
argv.extend(entry.path.split(' ').map(str::to_string));
for spec in &entry.args {
let Some(value) = arguments.get(&spec.key) else {
continue;
};
if spec.positional {
match value {
Value::Array(items) => {
for item in items {
if let Some(token) = scalar_token(item) {
argv.push(token);
}
}
}
other => {
if let Some(token) = scalar_token(other) {
argv.push(token);
}
}
}
} else if spec.boolean {
if value.as_bool() == Some(true) {
argv.push(format!("--{}", spec.key));
}
} else {
match value {
Value::Null => {}
Value::Array(items) => {
for item in items {
if let Some(token) = scalar_token(item) {
argv.push(format!("--{}={}", spec.key, token));
}
}
}
other => {
if let Some(token) = scalar_token(other) {
argv.push(format!("--{}={}", spec.key, token));
}
}
}
}
}
if confirm == Some(true) {
argv.push("--yes".to_string());
}
let mut inner = Cli::try_parse_from(argv)
.map_err(|err| (-32602, format!("Invalid arguments for {tool_name}: {err}")))?;
inner.profile = profile_flag.map(str::to_string);
inner.json = false;
inner.compact = false;
let mode = RenderMode::resolve(true, true);
let (profile, result) = crate::dispatch(inner, mode).await;
let confirm_refusal = match &result {
Err(err) => confirm != Some(true) && is_guarded && err.code() == "confirmation_required",
Ok(_) => false,
};
let envelope = match &result {
Ok(out) => success_envelope(out, profile.as_deref()),
Err(err) if confirm_refusal => {
mcp_confirm_refusal_envelope(err, profile.as_deref(), &entry.path)
}
Err(err) => failure_envelope(err, profile.as_deref()),
};
let is_error = envelope_reports_failure(&envelope);
let content = vec![json!({ "type": "text", "text": envelope })];
Ok(json!({
"content": content,
"isError": is_error,
}))
}
fn success_envelope(out: &crate::ActionOutput, profile: Option<&str>) -> String {
out.render_json(profile, true)
}
fn failure_envelope(err: &CoreError, profile: Option<&str>) -> String {
serde_json::to_string(&err.envelope(profile)).expect("envelope serialization cannot fail")
}
fn mcp_confirm_refusal_envelope(err: &CoreError, profile: Option<&str>, verb: &str) -> String {
let mut env = err.envelope(profile);
env.error.message = format!("{verb} is destructive; rerun with confirm: true to confirm");
env.error.hint = Some(
"Set {\"confirm\": true} in the tool arguments to execute it. (--yes and \
IGNITION_YES=1 are CLI-only confirmations — they cannot confirm a call made \
over MCP.)"
.to_string(),
);
serde_json::to_string(&env).expect("envelope serialization cannot fail")
}
fn envelope_reports_failure(envelope: &str) -> bool {
serde_json::from_str::<Value>(envelope)
.ok()
.and_then(|parsed| parsed.get("ok").and_then(Value::as_bool).map(|ok| !ok))
.unwrap_or(true)
}
fn scalar_token(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::Bool(boolean) => Some(boolean.to_string()),
Value::Number(number) => Some(number.to_string()),
Value::String(text) => Some(text.clone()),
Value::Array(_) => None,
Value::Object(_) => Some(value.to_string()),
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::*;
use crate::GUARDED_OPS;
fn independent_leaf_names() -> BTreeSet<String> {
fn walk(cmd: &clap::Command, prefix: &str, out: &mut BTreeSet<String>) {
for sub in cmd.get_subcommands() {
if sub.get_name() == "help" || sub.is_hide_set() {
continue;
}
let path = if prefix.is_empty() {
sub.get_name().to_string()
} else {
format!("{prefix} {}", sub.get_name())
};
let has_real_children = sub.get_subcommands().any(|c| c.get_name() != "help");
if (!has_real_children || !sub.is_subcommand_required_set())
&& !path_is_excluded(&path)
{
out.insert(tool_name_for_path(&path));
}
walk(sub, &path, out);
}
}
let mut out = BTreeSet::new();
walk(&Cli::command(), "", &mut out);
out
}
#[test]
fn catalog_names_match_the_clap_tree_bidirectionally() {
let catalog: BTreeSet<String> = build_catalog().iter().map(|e| e.name.clone()).collect();
let tree = independent_leaf_names();
assert!(!catalog.is_empty(), "the catalog derived the tree");
let missing: Vec<_> = tree.difference(&catalog).collect();
assert!(
missing.is_empty(),
"clap leaves with no tool entry (builder filter bug): {missing:#?}"
);
let extra: Vec<_> = catalog.difference(&tree).collect();
assert!(
extra.is_empty(),
"tool entries with no clap leaf (hand-written drift): {extra:#?}"
);
assert_eq!(catalog.len(), tree.len(), "bidirectional set equality");
}
#[test]
fn guarded_leaves_carry_optional_confirm_and_readonly_leaves_do_not() {
let catalog = build_catalog();
let delete = catalog
.iter()
.find(|e| e.name == "project_delete")
.expect("project_delete derives from the clap tree");
let delete_props = delete.schema.get("properties").expect("schema object");
let confirm = delete_props
.get("confirm")
.expect("guarded leaf carries confirm");
assert_eq!(
confirm.get("type").and_then(Value::as_str),
Some("boolean"),
"confirm is a boolean property"
);
let delete_required = delete
.schema
.get("required")
.and_then(Value::as_array)
.expect("required array");
assert!(
!delete_required.iter().any(|v| v == "confirm"),
"confirm must be OPTIONAL on project_delete \
(schema-required invites reflexive auto-fill): {delete_required:?}"
);
let status = catalog
.iter()
.find(|e| e.name == "status")
.expect("status derives from the clap tree");
assert!(
status
.schema
.get("properties")
.and_then(|props| props.get("confirm"))
.is_none(),
"the read-only status verb carries no confirm property"
);
for (path, _) in GUARDED_OPS {
let name = tool_name_for_path(path);
if path_is_excluded(path) {
assert_eq!(
*path, "edit",
"only edit may be both guarded and catalog-excluded"
);
assert!(
!catalog.iter().any(|e| e.name == name),
"excluded leaf {name} must not appear in the catalog"
);
continue;
}
let entry = catalog
.iter()
.find(|e| e.name == name)
.unwrap_or_else(|| panic!("GUARDED_OPS leaf {path:?} missing from the catalog"));
assert!(
entry
.schema
.get("properties")
.and_then(|props| props.get("confirm"))
.is_some(),
"guarded leaf {name} must carry the confirm property"
);
}
}
#[test]
fn tool_name_encoding_is_injective_over_the_clap_tree() {
let catalog = build_catalog();
let mut names: Vec<&str> = catalog.iter().map(|e| e.name.as_str()).collect();
let count = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), count, "tool names must be unique");
}
#[test]
fn initialize_negotiation_echoes_the_pinned_revision() {
assert_eq!(negotiate(Some("2025-06-18")), "2025-06-18");
assert_eq!(negotiate(Some("2024-11-05")), "2025-06-18");
assert_eq!(negotiate(Some("2030-01-01")), "2025-06-18");
assert_eq!(negotiate(None), "2025-06-18");
}
#[test]
fn response_message_is_single_line_json_rpc() {
let ok = response_message(Some(json!(1)), Ok(json!({})));
assert!(!ok.contains('\n'), "compact only: {ok}");
let parsed: Value = serde_json::from_str(&ok).expect("valid JSON");
assert_eq!(parsed.get("jsonrpc").and_then(Value::as_str), Some("2.0"));
assert_eq!(parsed.get("id").and_then(Value::as_i64), Some(1));
assert!(parsed.get("result").is_some());
let string_id = response_message(Some(json!("123")), Ok(json!({})));
let parsed: Value = serde_json::from_str(&string_id).expect("valid JSON");
assert_eq!(parsed.get("id").and_then(Value::as_str), Some("123"));
let err = response_message(Some(json!(7)), Err((-32601, "Method not found".into())));
assert!(!err.contains('\n'), "compact only: {err}");
let parsed: Value = serde_json::from_str(&err).expect("valid JSON");
assert_eq!(
parsed.pointer("/error/code").and_then(Value::as_i64),
Some(-32601)
);
assert!(parsed.get("result").is_none());
}
#[test]
fn envelope_serializations_are_chassis_byte_identical() {
use ignition_core::actions;
use ignition_core::output::render_failure;
let out = crate::ActionOutput::Version(actions::version::VersionResult {
cli_version: env!("CARGO_PKG_VERSION"),
gateway: None,
warnings: vec![],
});
let success = success_envelope(&out, Some("dev"));
assert_eq!(
success,
out.render_json(Some("dev"), true),
"success envelope == render_ok's CompactJson stdout line"
);
assert!(!success.contains('\n'), "compact only: {success}");
let parsed: Value = serde_json::from_str(&success).expect("valid envelope JSON");
assert_eq!(parsed.get("ok").and_then(Value::as_bool), Some(true));
assert_eq!(parsed.get("profile").and_then(Value::as_str), Some("dev"));
let err = CoreError::ConfirmationRequired {
operation: "project delete".to_string(),
};
let failure = failure_envelope(&err, Some("dev"));
assert_eq!(
failure,
render_failure(&err.envelope(Some("dev")), true),
"failure envelope == render_error's CompactJson stderr line"
);
assert!(!failure.contains('\n'), "compact only: {failure}");
let parsed: Value = serde_json::from_str(&failure).expect("valid envelope JSON");
assert_eq!(parsed.get("ok").and_then(Value::as_bool), Some(false));
assert_eq!(
parsed.pointer("/error/code").and_then(Value::as_str),
Some("confirmation_required"),
"the refusal rides the frozen envelope verbatim"
);
}
#[test]
fn mcp_confirm_refusal_adapts_prose_only_shape_byte_stable() {
let err = CoreError::ConfirmationRequired {
operation: "project delete".to_string(),
};
let cli = failure_envelope(&err, Some("dev"));
let mcp = mcp_confirm_refusal_envelope(&err, Some("dev"), "project delete");
let stable_prefix =
r#"{"ok":false,"profile":"dev","error":{"code":"confirmation_required","#;
assert!(
mcp.starts_with(stable_prefix),
"the MCP refusal must serialize in the LOCKED field order: {mcp}"
);
let mcp_parsed: Value = serde_json::from_str(&mcp).expect("valid envelope JSON");
let cli_parsed: Value = serde_json::from_str(&cli).expect("valid envelope JSON");
assert_eq!(
mcp_parsed.get("ok"),
cli_parsed.get("ok"),
"ok:false both ways"
);
assert_eq!(
mcp_parsed.get("profile"),
cli_parsed.get("profile"),
"profile echo unchanged"
);
assert_eq!(
mcp_parsed.pointer("/error/code"),
cli_parsed.pointer("/error/code"),
"the stable slug is unchanged"
);
assert_eq!(
mcp_parsed.pointer("/error/endpoint"),
cli_parsed.pointer("/error/endpoint"),
"endpoint is unchanged (null on the pre-network refusal)"
);
let message = mcp_parsed
.pointer("/error/message")
.and_then(Value::as_str)
.expect("message present");
assert_eq!(
message, "project delete is destructive; rerun with confirm: true to confirm",
"the message names the MCP-native confirmation: {message}"
);
let hint = mcp_parsed
.pointer("/error/hint")
.and_then(Value::as_str)
.expect("hint present");
assert!(hint.contains("confirm"), "hint names confirm: {hint}");
assert!(
hint.contains("CLI-only"),
"hint marks the CLI paths: {hint}"
);
assert!(
hint.contains("IGNITION_YES"),
"hint marks the env path: {hint}"
);
let mut repaired = mcp_parsed.clone();
repaired["error"]["message"] = cli_parsed["error"]["message"].clone();
repaired["error"]["hint"] = cli_parsed["error"]["hint"].clone();
assert_eq!(
repaired, cli_parsed,
"only message/hint may differ from the frozen CLI envelope"
);
}
#[test]
fn is_error_follows_the_envelope_ok_field() {
assert!(!envelope_reports_failure(
r#"{"ok":true,"profile":null,"data":{}}"#
));
assert!(envelope_reports_failure(
r#"{"ok":false,"profile":null,"error":{"code":"x","message":"m","endpoint":null,"hint":null}}"#
));
assert!(envelope_reports_failure("not json at all"));
assert!(envelope_reports_failure(r#"{"no_ok_field":1}"#));
}
#[test]
fn scalar_tokens_map_1_to_1_to_cli_tokens() {
assert_eq!(scalar_token(&json!("hello")), Some("hello".to_string()));
assert_eq!(scalar_token(&json!(42)), Some("42".to_string()));
assert_eq!(scalar_token(&json!(1.5)), Some("1.5".to_string()));
assert_eq!(scalar_token(&json!(true)), Some("true".to_string()));
assert_eq!(scalar_token(&Value::Null), None);
assert_eq!(
scalar_token(&json!({"a":1})),
Some(r#"{"a":1}"#.to_string())
);
}
#[tokio::test]
async fn execute_refuses_before_dispatch() {
let catalog = build_catalog();
let err = execute(&json!({ "name": "nonexistent_verb" }), None, &catalog)
.await
.expect_err("unknown tool");
assert_eq!(err.0, -32601);
let err = execute(
&json!({ "name": "status", "arguments": { "yes": true } }),
None,
&catalog,
)
.await
.expect_err("hostile yes must refuse");
assert_eq!(err.0, -32602, "{err:?}");
assert!(err.1.contains("yes"), "names the offending key: {err:?}");
let err = execute(
&json!({ "name": "version", "arguments": { "bogus": 1 } }),
None,
&catalog,
)
.await
.expect_err("unknown argument");
assert_eq!(err.0, -32602);
let err = execute(&json!({ "name": "rig_logs" }), None, &catalog)
.await
.expect_err("raw-streaming verb");
assert_eq!(err.0, -32602);
let err = execute(
&json!({ "name": "logs", "arguments": { "follow": true } }),
None,
&catalog,
)
.await
.expect_err("follow streams raw lines");
assert_eq!(err.0, -32602);
let err = execute(
&json!({ "name": "tags_read", "arguments": {} }),
None,
&catalog,
)
.await
.expect_err("missing required positional");
assert_eq!(err.0, -32602);
}
}