use std::cell::RefCell;
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
const RESPONSE_BUDGET_SECS: u64 = 15;
const QUIET_WINDOW_MS: u64 = 800;
fn isolated_config() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
std::fs::write(&path, "bogus_key = 1\n").expect("write noisy config");
(dir, path)
}
struct LeafArg {
key: String,
positional: bool,
boolean: bool,
}
struct LeafInfo {
path: String,
args: Vec<LeafArg>,
}
struct ScriptedClient {
child: Child,
stdin: Option<ChildStdin>,
inbox: Inbox,
_config_dir: tempfile::TempDir,
}
struct Inbox {
lines: std::sync::mpsc::Receiver<Value>,
set_aside: RefCell<VecDeque<Value>>,
}
impl ScriptedClient {
fn spawn(extra_env: &[(&str, &str)]) -> Self {
let (dir, config) = isolated_config();
Self::spawn_with_config(&config, dir, extra_env)
}
fn spawn_with_config(
config: &Path,
config_dir: tempfile::TempDir,
extra_env: &[(&str, &str)],
) -> Self {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_ign"));
cmd.args(["mcp", "serve"])
.env("IGNITION_CLI_CONFIG", config)
.env("IGNITION_LOG", "trace")
.env_remove("IGNITION_PROFILE")
.env_remove("IGNITION_JSON")
.env_remove("IGNITION_YES")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
for (key, value) in extra_env {
cmd.env(key, value);
}
let mut child = cmd.spawn().expect("spawn ign mcp serve");
let stdout = child.stdout.take().expect("piped stdout");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut reader = BufReader::new(stdout);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => break, Ok(_) => {}
}
let parsed: Value = match serde_json::from_str(line.trim_end()) {
Ok(value) => value,
Err(error) => {
panic!("stdout carried a non-JSON-RPC byte (line {line:?}): {error}")
}
};
if tx.send(parsed).is_err() {
break; }
}
});
Self {
stdin: child.stdin.take(),
child,
inbox: Inbox::new(rx),
_config_dir: config_dir,
}
}
fn send(&mut self, message: &Value) {
let stdin = self
.stdin
.as_mut()
.expect("server stdin is open (a closed stdin fails every later step)");
stdin
.write_all(message.to_string().as_bytes())
.and_then(|_| stdin.write_all(b"\n"))
.and_then(|_| stdin.flush())
.expect("write a protocol frame to the server");
}
fn send_raw_line(&mut self, raw: &str) {
let stdin = self
.stdin
.as_mut()
.expect("server stdin is open (a closed stdin fails every later step)");
stdin
.write_all(raw.as_bytes())
.and_then(|_| stdin.write_all(b"\n"))
.and_then(|_| stdin.flush())
.expect("write a raw frame to the server");
}
fn recv_next(&self, secs: u64) -> Value {
self.inbox.recv_next(secs)
}
fn request(&mut self, id: Value, method: &str, params: Value) {
self.send(&json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
}));
}
fn expect_response(&self, id: &Value, secs: u64) -> Value {
self.inbox.expect_response(id, secs)
}
fn expect_silence(&self, ms: u64) {
self.inbox.expect_silence(ms)
}
fn handshake(&mut self) -> Value {
self.request(
json!(1),
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "contract-mcp", "version": "0" },
}),
);
let response = self.expect_response(&json!(1), RESPONSE_BUDGET_SECS);
let result = response.get("result").expect("initialize has a result");
self.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }));
result.clone()
}
fn tools(&mut self) -> Vec<Value> {
self.handshake();
self.request(json!(2), "tools/list", json!({}));
let response = self.expect_response(&json!(2), RESPONSE_BUDGET_SECS);
response
.pointer("/result/tools")
.and_then(Value::as_array)
.expect("tools/list returns tools[]")
.clone()
}
}
impl Drop for ScriptedClient {
fn drop(&mut self) {
self.stdin.take();
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Inbox {
fn new(lines: std::sync::mpsc::Receiver<Value>) -> Self {
Self {
lines,
set_aside: RefCell::new(VecDeque::new()),
}
}
fn recv_next(&self, secs: u64) -> Value {
if let Some(message) = self.set_aside.borrow_mut().pop_front() {
return message;
}
match self.lines.recv_timeout(Duration::from_secs(secs)) {
Ok(message) => message,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
panic!("no stdout message arrived within {secs}s — protocol death or starvation")
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!(
"the reader thread died — stdout closed mid-protocol or the byte-scan \
rejected a stray byte (see the panicked thread's message above)"
),
}
}
fn expect_response(&self, id: &Value, secs: u64) -> Value {
{
let mut set_aside = self.set_aside.borrow_mut();
if let Some(index) = set_aside
.iter()
.position(|message| message.get("id") == Some(id))
{
return set_aside
.remove(index)
.expect("position() returned an index inside the deque");
}
}
let deadline = Instant::now() + Duration::from_secs(secs);
loop {
let remaining = deadline
.checked_duration_since(Instant::now())
.unwrap_or_default();
match self.lines.recv_timeout(remaining) {
Ok(message) => {
assert_eq!(
message.get("jsonrpc").and_then(Value::as_str),
Some("2.0"),
"every response carries jsonrpc 2.0: {message}"
);
if message.get("id") == Some(id) {
return message;
}
self.set_aside.borrow_mut().push_back(message);
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!(
"no response with id {id} arrived within {secs}s — protocol death or \
starvation (see stderr for the server's trace log by rerunning with \
stderr inherited)"
),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!(
"the reader thread died — stdout closed mid-protocol or the byte-scan \
rejected a stray byte (see the panicked thread's message above)"
),
}
}
}
fn expect_silence(&self, ms: u64) {
if let Some(message) = self.set_aside.borrow().front() {
panic!(
"expected a quiet window ({ms}ms) but a reply the server sent is still \
unclaimed: {message}"
);
}
match self.lines.recv_timeout(Duration::from_millis(ms)) {
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} Ok(message) => {
panic!("expected a quiet window ({ms}ms) but the server answered: {message}")
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
panic!("reader thread died during the quiet window")
}
}
}
}
fn unknown_tool_reply(id: &Value) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": -32601, "message": format!("unknown tool (request {id})") },
})
}
fn inbox_fed(ids: &[Value]) -> (Inbox, std::sync::mpsc::Sender<Value>) {
let (tx, rx) = std::sync::mpsc::channel();
for id in ids {
tx.send(unknown_tool_reply(id)).expect("feed the inbox");
}
(Inbox::new(rx), tx)
}
#[test]
fn inbox_keeps_an_out_of_order_reply_for_a_later_wait() {
let (inbox, _tx) = inbox_fed(&[json!(32), json!(31), json!(30)]);
for id in [json!(31), json!(32), json!(30)] {
let reply = inbox.expect_response(&id, 1);
assert_eq!(reply.get("id"), Some(&id), "{reply}");
}
}
#[test]
fn inbox_recv_next_returns_set_aside_replies_first_in_arrival_order() {
let (inbox, _tx) = inbox_fed(&[json!("b"), json!(33), json!(31), json!(34)]);
let _ = inbox.expect_response(&json!(31), 1);
for expected in [json!("b"), json!(33), json!(34)] {
let next = inbox.recv_next(1);
assert_eq!(next.get("id"), Some(&expected), "arrival order: {next}");
}
}
#[test]
#[should_panic(expected = "quiet window")]
fn inbox_expect_silence_fails_while_a_reply_is_set_aside() {
let (inbox, _tx) = inbox_fed(&[json!(32), json!(31)]);
let _ = inbox.expect_response(&json!(31), 1);
inbox.expect_silence(50);
}
#[test]
fn initialize_echoes_the_pinned_revision_with_both_id_shapes() {
let mut client = ScriptedClient::spawn(&[]);
client.request(
json!(1),
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "contract-mcp", "version": "0" },
}),
);
let response = client.expect_response(&json!(1), RESPONSE_BUDGET_SECS);
let result = response.get("result").expect("initialize answers a result");
assert_eq!(
result.get("protocolVersion").and_then(Value::as_str),
Some("2025-06-18"),
"the supported revision is echoed exactly"
);
assert_eq!(
result.pointer("/serverInfo/name").and_then(Value::as_str),
Some("ign"),
"serverInfo.name is ign"
);
assert!(
result.get("capabilities").map(|c| c.get("tools")).is_some(),
"capabilities.tools is advertised: {result}"
);
client.request(
json!("str-id-9"),
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "contract-mcp", "version": "0" },
}),
);
let response = client.expect_response(&json!("str-id-9"), RESPONSE_BUDGET_SECS);
assert_eq!(
response.get("id"),
Some(&json!("str-id-9")),
"string ids ride back verbatim"
);
assert!(response.get("result").is_some());
}
#[test]
fn initialize_older_version_negotiates_to_the_server_revision() {
let mut client = ScriptedClient::spawn(&[]);
client.request(
json!(3),
"initialize",
json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "contract-mcp", "version": "0" },
}),
);
let response = client.expect_response(&json!(3), RESPONSE_BUDGET_SECS);
assert_eq!(
response
.pointer("/result/protocolVersion")
.and_then(Value::as_str),
Some("2025-06-18"),
"an older request negotiates to the server's revision"
);
}
#[test]
fn initialized_notification_produces_no_response_bytes() {
let mut client = ScriptedClient::spawn(&[]);
client.request(
json!(1),
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "contract-mcp", "version": "0" },
}),
);
let _ = client.expect_response(&json!(1), RESPONSE_BUDGET_SECS);
client.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }));
client.expect_silence(QUIET_WINDOW_MS);
}
#[test]
fn ping_answers_promptly_with_an_empty_result() {
let mut client = ScriptedClient::spawn(&[]);
client.handshake();
let start = Instant::now();
client.request(json!(7), "ping", json!({}));
let response = client.expect_response(&json!(7), 2);
assert_eq!(
response.get("result"),
Some(&json!({})),
"ping's result is exactly the empty object"
);
assert!(
start.elapsed() < Duration::from_secs(2),
"ping answered within the 2s promptness contract (took {:?})",
start.elapsed()
);
}
#[test]
fn tools_list_carries_the_catalog_with_the_pinned_shape() {
let mut client = ScriptedClient::spawn(&[]);
let tools = client.tools();
assert!(!tools.is_empty(), "the catalog is non-empty");
let mut names = Vec::new();
for tool in &tools {
let name = tool
.get("name")
.and_then(Value::as_str)
.expect("every tool has a string name")
.to_string();
assert!(
tool.get("description")
.and_then(Value::as_str)
.is_some_and(|d| !d.is_empty()),
"every tool carries a non-empty description ({name})"
);
let schema = tool.get("inputSchema").expect("every tool has inputSchema");
assert_eq!(
schema.get("type").and_then(Value::as_str),
Some("object"),
"every inputSchema is an object schema ({name})"
);
assert!(
schema.get("properties").is_some(),
"every inputSchema has properties ({name})"
);
names.push(name);
}
fn required_of(tools: &[Value], name: &str) -> Vec<String> {
tools
.iter()
.find(|t| t.get("name").and_then(Value::as_str) == Some(name))
.map(|t| {
t.pointer("/inputSchema/required")
.and_then(Value::as_array)
.expect("required array")
.iter()
.map(|v| v.as_str().expect("string name").to_string())
.collect()
})
.unwrap_or_else(|| panic!("tool {name} present in the catalog"))
}
let delete_required = required_of(&tools, "project_delete");
assert!(
!delete_required.iter().any(|r| r == "confirm"),
"confirm must be OPTIONAL on the wire — absent from required: {delete_required:?}"
);
let delete_props = tools
.iter()
.find(|t| t.get("name").and_then(Value::as_str) == Some("project_delete"))
.and_then(|t| t.pointer("/inputSchema/properties/confirm"))
.expect("project_delete carries a confirm property");
assert_eq!(
delete_props.get("type").and_then(Value::as_str),
Some("boolean"),
"confirm is a boolean property on the wire"
);
assert!(
delete_props
.get("description")
.and_then(Value::as_str)
.is_some_and(|d| d.contains("true")),
"the confirm description tells the agent what true does: {delete_props}"
);
let status = tools
.iter()
.find(|t| t.get("name").and_then(Value::as_str) == Some("status"))
.expect("status present");
assert!(
status.pointer("/inputSchema/properties/confirm").is_none(),
"the read-only status verb carries no confirm property"
);
for absent in ["mcp", "lsp", "tui", "completions", "edit"] {
assert!(
!names.iter().any(|n| n == absent),
"excluded verb {absent} must not appear in the catalog: {names:?}"
);
}
}
fn clap_leaf_map() -> std::collections::BTreeMap<String, LeafInfo> {
use clap::CommandFactory;
use ignition_cli::cli::Cli;
fn walk(
cmd: &clap::Command,
prefix: &str,
out: &mut std::collections::BTreeMap<String, LeafInfo>,
) {
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 excluded = path
.split(' ')
.any(|segment| ["completions", "mcp", "lsp", "tui", "edit"].contains(&segment));
let has_real_children = sub.get_subcommands().any(|c| c.get_name() != "help");
if (!has_real_children || !sub.is_subcommand_required_set()) && !excluded {
let mut args = Vec::new();
for arg in sub.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 key = if arg.is_positional() {
id.clone()
} else {
arg.get_long().unwrap_or(id.as_str()).to_string()
};
args.push(LeafArg {
key,
positional: arg.is_positional(),
boolean: matches!(arg.get_action(), clap::ArgAction::SetTrue),
});
}
out.insert(
path.replace(' ', "_"),
LeafInfo {
path: path.clone(),
args,
},
);
}
walk(sub, &path, out);
}
}
let mut out = std::collections::BTreeMap::new();
walk(&Cli::command(), "", &mut out);
out
}
fn sample_token(property: &Value) -> String {
property
.get("enum")
.and_then(Value::as_array)
.and_then(|values| values.first())
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| "x".to_string())
}
#[test]
fn catalog_sample_round_trips_through_clap_parse() {
let mut client = ScriptedClient::spawn(&[]);
let tools = client.tools();
let leaves = clap_leaf_map();
let mut sample: Vec<String> = tools
.iter()
.filter(|tool| tool.pointer("/inputSchema/properties/confirm").is_some())
.filter_map(|tool| tool.get("name").and_then(Value::as_str))
.map(str::to_string)
.collect();
for read in ["version", "status", "modules", "metrics", "profile_list"] {
sample.push(read.to_string());
}
assert!(
sample.len() >= 23 + 5,
"the sample must cover all wire-guarded verbs + 5 reads (got {})",
sample.len()
);
for name in &sample {
let entry = tools
.iter()
.find(|tool| tool.get("name").and_then(Value::as_str) == Some(name.as_str()))
.unwrap_or_else(|| panic!("sampled tool {name} missing from tools/list"));
let leaf = leaves
.get(name)
.unwrap_or_else(|| panic!("tool {name} has no clap leaf — catalog drift"));
let required: Vec<String> = entry
.pointer("/inputSchema/required")
.and_then(Value::as_array)
.expect("required array")
.iter()
.map(|v| v.as_str().expect("string").to_string())
.collect();
let properties = entry
.pointer("/inputSchema/properties")
.and_then(Value::as_object)
.expect("properties object");
let mut argv: Vec<String> = vec!["ign".to_string()];
argv.extend(leaf.path.split(' ').map(str::to_string));
for arg in &leaf.args {
if !required.contains(&arg.key) {
continue;
}
if arg.positional {
let property = properties.get(&arg.key).unwrap_or(&Value::Null);
argv.push(sample_token(property));
} else if arg.boolean {
argv.push(format!("--{}", arg.key));
} else {
let property = properties.get(&arg.key).unwrap_or(&Value::Null);
argv.push(format!("--{}={}", arg.key, sample_token(property)));
}
}
if properties.contains_key("confirm") {
argv.push("--yes".to_string());
}
use clap::Parser;
let parsed = ignition_cli::cli::Cli::try_parse_from(&argv);
assert!(
parsed.is_ok(),
"catalog tool {name} (leaf {:?}) does not round-trip to parseable argv \
{argv:?}: {:?}",
leaf.path,
parsed.err().map(|e| e.to_string())
);
}
}
use assert_cmd::Command as AssertCommand;
async fn mount_gateway_info(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/gateway-info"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"name": "ign-mock",
"redundancyRole": "Independent",
"edition": "standard",
"ignitionVersion": "8.3.6 (b2026042713)",
"jvmVersion": "17.0.11",
"license": {"mode": "Trial", "expirationDate": "2026-08-24T19:00:00Z"}
})),
)
.expect(1..)
.mount(server)
.await;
}
async fn mount_overview(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/overview"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"version": "8.3.6 (b2026042713)",
"redundancy": {"role": "Independent", "activityLevel": "ACTIVE", "projectState": "RUNNING"},
"java": {"version": "17.0.11", "vendor": "Azul Systems, Inc.", "name": "OpenJDK 64-Bit Server VM"},
"os": {"name": "Linux", "arch": "amd64", "version": "5.15.0"},
"uptime": 338137,
"memory": [338137088i64, 1073741824i64],
"cpu": 0.0031,
"disk": {"total": 62661259264i64, "used": 12272824320i64},
"license": {"state": "trial", "trialRemaining": 7017}
})))
.expect(1..)
.mount(server)
.await;
}
async fn mount_status_ping(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/StatusPing"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"state": "RUNNING"})),
)
.expect(1..)
.mount(server)
.await;
}
async fn mount_status_fixtures(server: &wiremock::MockServer) {
mount_gateway_info(server).await;
mount_overview(server).await;
mount_status_ping(server).await;
}
fn write_wiremock_profile_config(config: &Path, url: &str) {
std::fs::write(
config,
format!(
"bogus_key = 1\nactive = \"dev\"\n\n[profiles.dev]\nurl = \
\"{url}\"\nauth = {{ token_env = \"IGNITION_TOKEN\" }}\n"
),
)
.expect("write wiremock profile config");
}
const MOCK_TOKEN: &str = "mock:name-key";
#[tokio::test]
async fn tools_call_returns_the_frozen_envelope_verbatim() {
let server = wiremock::MockServer::start().await;
mount_status_fixtures(&server).await;
let dir = tempfile::tempdir().expect("tempdir");
let config = dir.path().join("config.toml");
write_wiremock_profile_config(&config, &server.uri());
let mut client =
ScriptedClient::spawn_with_config(&config, dir, &[("IGNITION_TOKEN", MOCK_TOKEN)]);
client.handshake();
client.request(
json!(10),
"tools/call",
json!({ "name": "status", "arguments": {} }),
);
let response = client.expect_response(&json!(10), RESPONSE_BUDGET_SECS);
let result = response.get("result").expect("tools/call answers a result");
assert_eq!(
result.get("isError"),
Some(&json!(false)),
"a successful envelope is isError:false: {result}"
);
let text = result
.pointer("/content/0/text")
.and_then(Value::as_str)
.expect("the tool result carries text content");
let envelope: Value = serde_json::from_str(text).expect("the envelope text parses");
assert_eq!(envelope.get("ok").and_then(Value::as_bool), Some(true));
assert_eq!(
envelope.get("profile").and_then(Value::as_str),
Some("dev"),
"the envelope echoes the resolved profile"
);
let direct = AssertCommand::cargo_bin("ign")
.expect("binary 'ign' not found")
.args(["--json", "--compact", "status"])
.env("IGNITION_CLI_CONFIG", &config)
.env("IGNITION_TOKEN", MOCK_TOKEN)
.env("IGNITION_LOG", "trace")
.env_remove("IGNITION_PROFILE")
.env_remove("IGNITION_JSON")
.env_remove("IGNITION_YES")
.output()
.expect("spawn ign status directly");
assert!(
direct.status.success(),
"direct run must succeed: {}",
String::from_utf8_lossy(&direct.stderr)
);
let direct_stdout = String::from_utf8(direct.stdout).expect("utf-8 stdout");
assert_eq!(
text,
direct_stdout.strip_suffix('\n').unwrap_or(&direct_stdout),
"MCP tool result must equal the direct CLI envelope byte-for-byte"
);
}
#[tokio::test]
async fn confirm_omitted_refuses_with_mcp_native_prose_and_cli_prose_survives_directly() {
let dir = tempfile::tempdir().expect("tempdir");
let config = dir.path().join("config.toml");
std::fs::write(&config, "bogus_key = 1\n").expect("write noisy config");
let mut client = ScriptedClient::spawn_with_config(&config, dir, &[("IGNITION_YES", "1")]);
client.handshake();
client.request(
json!(11),
"tools/call",
json!({ "name": "project_delete", "arguments": { "name": "x" } }),
);
let response = client.expect_response(&json!(11), RESPONSE_BUDGET_SECS);
let result = response.get("result").expect("tools/call answers a result");
assert_eq!(
result.get("isError"),
Some(&json!(true)),
"the refusal rides isError:true (IGNITION_YES=1 could not confirm it): {result}"
);
let content = result
.get("content")
.and_then(Value::as_array)
.expect("the refusal carries content blocks");
assert_eq!(
content.len(),
1,
"the refusal is ONE self-sufficient block (the JSON itself is MCP-native): {content:?}"
);
let envelope_text = content[0]
.get("text")
.and_then(Value::as_str)
.expect("the single block is text content");
let envelope: Value = serde_json::from_str(envelope_text)
.expect("the refusal text parses as the frozen envelope");
assert_eq!(envelope.get("ok").and_then(Value::as_bool), Some(false));
assert_eq!(
envelope.pointer("/error/code").and_then(Value::as_str),
Some("confirmation_required"),
"the stable slug is unchanged: {envelope}"
);
assert!(
envelope.get("profile").is_some_and(Value::is_null),
"profile echoes null on the pre-network refusal"
);
assert!(
envelope
.pointer("/error/endpoint")
.is_some_and(Value::is_null),
"endpoint is null (zero requests)"
);
let message = envelope
.pointer("/error/message")
.and_then(Value::as_str)
.expect("message present");
assert!(
message.contains("confirm: true"),
"the message names the MCP-native confirmation: {message}"
);
let hint = envelope
.pointer("/error/hint")
.and_then(Value::as_str)
.expect("hint present");
assert!(
hint.contains("confirm"),
"the hint names the confirm argument: {hint}"
);
assert!(
hint.contains("CLI-only"),
"the hint marks --yes/IGNITION_YES CLI-only: {hint}"
);
assert!(
hint.contains("IGNITION_YES"),
"the hint names the env var as CLI-only: {hint}"
);
let stable_prefix = r#"{"ok":false,"profile":null,"error":{"code":"confirmation_required","#;
assert!(
envelope_text.starts_with(stable_prefix),
"the MCP refusal must serialize in the LOCKED field order: {envelope_text}"
);
let direct = AssertCommand::cargo_bin("ign")
.expect("binary 'ign' not found")
.args(["--json", "--compact", "project", "delete", "x"])
.env("IGNITION_CLI_CONFIG", &config)
.env_remove("IGNITION_PROFILE")
.env_remove("IGNITION_JSON")
.env_remove("IGNITION_YES")
.output()
.expect("spawn ign project delete directly");
assert!(
!direct.status.success(),
"the direct refusal must fail (usage-class exit)"
);
let direct_stderr = String::from_utf8(direct.stderr).expect("utf-8 stderr");
let direct_envelope_text = direct_stderr.lines().last().unwrap_or_default();
let direct_envelope: Value = serde_json::from_str(direct_envelope_text)
.expect("the direct CLI prints a parseable refusal envelope");
let direct_message = direct_envelope
.pointer("/error/message")
.and_then(Value::as_str)
.expect("direct message present");
assert!(
direct_message.contains("--yes"),
"the CLI golden prose names --yes, unchanged: {direct_message}"
);
assert!(
!direct_message.contains("confirm: true"),
"the CLI prose must NOT carry MCP wording: {direct_message}"
);
let direct_hint = direct_envelope
.pointer("/error/hint")
.and_then(Value::as_str)
.expect("direct hint present");
assert!(
direct_hint.contains("IGNITION_YES"),
"the CLI hint still names the env confirmation: {direct_hint}"
);
let mut repaired = envelope.clone();
repaired["error"]["message"] = direct_envelope["error"]["message"].clone();
repaired["error"]["hint"] = direct_envelope["error"]["hint"].clone();
assert_eq!(
repaired, direct_envelope,
"only message/hint prose may differ between the transports"
);
}
#[tokio::test]
async fn confirm_true_executes_the_guarded_verb_on_the_wire() {
let server = wiremock::MockServer::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/data/api/v1/projects/x"))
.and(wiremock::matchers::query_param("confirm", "true"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1..)
.mount_as_scoped(&server)
.await;
let dir = tempfile::tempdir().expect("tempdir");
let config = dir.path().join("config.toml");
write_wiremock_profile_config(&config, &server.uri());
let mut client =
ScriptedClient::spawn_with_config(&config, dir, &[("IGNITION_TOKEN", MOCK_TOKEN)]);
client.handshake();
client.request(
json!(12),
"tools/call",
json!({ "name": "project_delete", "arguments": { "name": "x", "confirm": true } }),
);
let response = client.expect_response(&json!(12), RESPONSE_BUDGET_SECS);
let result = response.get("result").expect("tools/call answers a result");
assert_eq!(
result.get("isError"),
Some(&json!(false)),
"confirm:true executes: {result}"
);
let text = result
.pointer("/content/0/text")
.and_then(Value::as_str)
.expect("text content");
let envelope: Value = serde_json::from_str(text).expect("envelope parses");
assert_eq!(envelope.get("ok").and_then(Value::as_bool), Some(true));
assert_eq!(
envelope.pointer("/data/deleted").and_then(Value::as_str),
Some("x"),
"the frozen success envelope rides verbatim: {envelope}"
);
let requests = guard.received_requests().await;
assert_eq!(requests.len(), 1, "one DELETE per invocation");
let query = requests[0].url.query().expect("query present");
assert!(
query.contains("confirm=true"),
"the server's own guard rode the wire: {query}"
);
}
#[tokio::test]
async fn protocol_errors_map_correctly_and_survive_malformed_input() {
let mut client = ScriptedClient::spawn(&[]);
client.handshake();
client.request(
json!(20),
"tools/call",
json!({ "name": "no_such_tool", "arguments": {} }),
);
let response = client.expect_response(&json!(20), RESPONSE_BUDGET_SECS);
assert_eq!(
response.pointer("/error/code").and_then(Value::as_i64),
Some(-32601),
"unknown tool is a protocol-level method error: {response}"
);
assert!(
response
.pointer("/error/message")
.and_then(Value::as_str)
.is_some_and(|m| m.contains("no_such_tool")),
"the error names the offending tool"
);
client.request(
json!(21),
"tools/call",
json!({ "name": "tags_read", "arguments": {} }),
);
let response = client.expect_response(&json!(21), RESPONSE_BUDGET_SECS);
assert_eq!(
response.pointer("/error/code").and_then(Value::as_i64),
Some(-32602),
"invalid params map to -32602: {response}"
);
client.request(
json!(22),
"tools/call",
json!({ "name": "status", "arguments": { "yes": true } }),
);
let response = client.expect_response(&json!(22), RESPONSE_BUDGET_SECS);
assert_eq!(
response.pointer("/error/code").and_then(Value::as_i64),
Some(-32602),
"the hostile-yes probe refuses as invalid params: {response}"
);
assert!(
response
.pointer("/error/message")
.and_then(Value::as_str)
.is_some_and(|m| m.contains("yes")),
"the refusal names the offending key"
);
client.send_raw_line("this is not json at all");
client.expect_silence(QUIET_WINDOW_MS);
let start = Instant::now();
client.request(json!(23), "ping", json!({}));
let response = client.expect_response(&json!(23), 2);
assert_eq!(
response.get("result"),
Some(&json!({})),
"the protocol survived the malformed input"
);
assert!(
start.elapsed() < Duration::from_secs(2),
"post-malformed ping still prompt: {:?}",
start.elapsed()
);
}
#[tokio::test]
async fn mcp_tools_call_write_verbs_unknown() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::any())
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(json!({}))
.set_delay(Duration::from_secs(3)),
)
.mount(&server)
.await;
let dir = tempfile::tempdir().expect("tempdir");
let config = dir.path().join("config.toml");
write_wiremock_profile_config(&config, &server.uri());
let mut client =
ScriptedClient::spawn_with_config(&config, dir, &[("IGNITION_TOKEN", MOCK_TOKEN)]);
let tools = client.tools();
for name in ["deploy_apply", "deploy_rollback"] {
assert!(
!tools.iter().any(|t| t["name"] == name),
"{name} is not in the catalog"
);
}
client.request(
json!(30),
"tools/call",
json!({ "name": "status", "arguments": {} }),
);
let started = Instant::now();
let calls = [
(
31,
"deploy_apply",
json!({ "env": "dev", "plan-sha256": "0".repeat(64), "confirm": true }),
),
(
32,
"deploy_rollback",
json!({ "env": "dev", "run": "20261016T000000Z-aaaaaa", "confirm": true }),
),
];
for (id, name, arguments) in &calls {
client.request(
json!(id),
"tools/call",
json!({ "name": name, "arguments": arguments }),
);
}
for (id, name, _) in &calls {
let response = client.expect_response(&json!(id), RESPONSE_BUDGET_SECS);
assert_eq!(
response.pointer("/error/code").and_then(Value::as_i64),
Some(-32601),
"{name} is an unknown tool: {response}"
);
assert!(
response
.pointer("/error/message")
.and_then(Value::as_str)
.is_some_and(|m| m.contains(*name)),
"the error names {name}: {response}"
);
assert!(response.get("result").is_none(), "{name} ran nothing");
}
assert!(
started.elapsed() < Duration::from_secs(2),
"the refusals did not wait behind the slow call: {:?}",
started.elapsed()
);
let _ = client.expect_response(&json!(30), RESPONSE_BUDGET_SECS);
let paths: Vec<String> = server
.received_requests()
.await
.unwrap_or_default()
.iter()
.map(|r| r.url.path().to_string())
.collect();
assert!(
!paths.is_empty(),
"the in-flight status call reached the gateway, so the check below looks at something"
);
for forbidden in ["scan", "lock", "backup", "restart", "webdev", "resource"] {
assert!(
!paths.iter().any(|p| p.contains(forbidden)),
"the gateway saw {forbidden}: {paths:?}"
);
}
}
#[tokio::test]
async fn ping_never_starves_behind_an_in_flight_dead_gateway_call() {
let dead_gateway = std::net::TcpListener::bind("127.0.0.1:0").expect("bind dead gateway");
let dead_url = format!(
"http://127.0.0.1:{}/",
dead_gateway.local_addr().unwrap().port()
);
let dir = tempfile::tempdir().expect("tempdir");
let config = dir.path().join("config.toml");
write_wiremock_profile_config(&config, &dead_url);
let mut client =
ScriptedClient::spawn_with_config(&config, dir, &[("IGNITION_TOKEN", MOCK_TOKEN)]);
client.handshake();
client.request(
json!(30),
"tools/call",
json!({ "name": "version", "arguments": {} }),
);
client.request(json!(31), "ping", json!({}));
let first = client.recv_next(10);
assert_eq!(
first.get("id"),
Some(&json!(31)),
"ping must answer BEFORE the hung tool result: {first}"
);
assert_eq!(first.get("result"), Some(&json!({})), "ping's empty result");
let second = client.recv_next(75);
assert_eq!(
second.get("id"),
Some(&json!(30)),
"the in-flight call's result still arrives: {second}"
);
assert!(
second.get("result").is_some() || second.get("error").is_some(),
"the dead-gateway call resolves into a well-formed response: {second}"
);
}
#[tokio::test]
#[ignore = "conformance oracle: needs uv + the mcp SDK — run with `cargo test -p \
ignition-cli --test contract_mcp mcp_oracle -- --ignored`"]
async fn mcp_oracle_gate_drives_a_real_mcp_sdk_client() {
let probe = Command::new("uv").arg("--version").output();
let probe = match probe {
Ok(output) if output.status.success() => output,
_ => {
eprintln!("green-skip: uv not available — the mcp SDK oracle gate is skipped");
return;
}
};
let _ = probe;
let server = wiremock::MockServer::start().await;
mount_status_fixtures(&server).await;
let dir = tempfile::tempdir().expect("tempdir");
let config = dir.path().join("config.toml");
write_wiremock_profile_config(&config, &server.uri());
let oracle = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/mcp_oracle.py");
let output = Command::new("uv")
.args([
"run",
"--with",
"mcp",
"python",
oracle.to_string_lossy().as_ref(),
"--",
env!("CARGO_BIN_EXE_ign"),
])
.env("IGNITION_CLI_CONFIG", &config)
.env("IGNITION_TOKEN", MOCK_TOKEN)
.output()
.expect("run the oracle under uv");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"the mcp SDK oracle failed\nstdout: {stdout}\nstderr: {stderr}"
);
assert!(
stdout.contains("ORACLE OK"),
"the oracle did not report success: {stdout}"
);
}