use std::sync::Arc;
use color_eyre::eyre::Result;
use serde_json::{json, Value};
use crate::cli::lint::{
fetch_env_lint_inputs, fetch_stale_platform_issues, run_rules_for_env, EnvLintInputs,
};
use crate::{audit as audit_log, aws, cost_cache, demo_fixture, lint, terraform, util};
mod annotations;
mod setup;
mod tools;
mod writes;
use tools::*;
pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";
const TOOL_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, PartialEq, Eq)]
struct McpArgs {
demo: bool,
no_redact: bool,
write_scope: WriteScope,
}
const MCP_USAGE: &str =
"usage: ebman mcp <serve [--demo] [--no-redact] [--allow-writes[=verb,verb]] \
| setup [--allow-writes[=verb,verb]]>";
fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
if args.get(1).map(String::as_str) != Some("serve") {
return Err(MCP_USAGE.into());
}
let mut demo = false;
let mut no_redact = false;
let mut write_scope = WriteScope::None;
let mut saw_write_flag = false;
let known: Vec<String> = writes::write_verb_names();
let known_refs: Vec<&str> = known.iter().map(String::as_str).collect();
for arg in args.iter().skip(2) {
if let Some(rest) = arg.strip_prefix("--allow-writes") {
if saw_write_flag {
return Err(format!(
"ebman mcp: --allow-writes given more than once — a second one \
would silently widen the first. Name every verb in one flag: \
--allow-writes=a,b — {MCP_USAGE}"
));
}
saw_write_flag = true;
let value = match rest {
"" => None,
v => Some(v.strip_prefix('=').ok_or_else(|| {
format!("ebman mcp: expected `--allow-writes=verbs` — {MCP_USAGE}")
})?),
};
write_scope =
parse_write_scope(value, &known_refs).map_err(|e| format!("ebman mcp: {e}"))?;
continue;
}
match arg.as_str() {
"--demo" => demo = true,
"--no-redact" => no_redact = true,
other => return Err(format!("ebman mcp: unknown flag '{other}' — {MCP_USAGE}")),
}
}
Ok(McpArgs {
demo,
no_redact,
write_scope,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum WriteScope {
None,
All,
Only(Vec<String>),
}
impl WriteScope {
pub(crate) fn allows(&self, tool: &str) -> bool {
match self {
WriteScope::None => false,
WriteScope::All => true,
WriteScope::Only(v) => v.iter().any(|t| t == tool),
}
}
pub(crate) fn any(&self) -> bool {
match self {
WriteScope::None => false,
WriteScope::All => true,
WriteScope::Only(v) => !v.is_empty(),
}
}
fn agent_summary(&self) -> String {
match self {
WriteScope::None => "This server is READ-ONLY: no write tool is available. Ask the \
operator to restart it with --allow-writes (optionally \
--allow-writes=verb,verb to grant only what you need)."
.to_string(),
WriteScope::All => "Writes are ENABLED for every verb, via the two-phase \
plan-then-confirm protocol."
.to_string(),
WriteScope::Only(v) => format!(
"Writes are NARROWLY granted: {} only, via the two-phase plan-then-confirm \
protocol. Any other write verb is absent from this list because it was NOT \
GRANTED, not because ebman lacks it — say so and ask the operator to widen \
the grant rather than reporting it as unsupported.",
v.join(", ")
),
}
}
}
#[cfg(test)]
pub(crate) fn write_verb_names_for_docs() -> Vec<String> {
writes::write_verb_names()
}
pub fn wants_file_logging(args: &[String]) -> bool {
args.get(1).map(String::as_str) == Some("serve")
}
pub(crate) fn should_init_audit(scope: &WriteScope, demo: bool) -> bool {
scope.any() && !demo
}
pub(crate) fn parse_write_scope(value: Option<&str>, known: &[&str]) -> Result<WriteScope, String> {
let Some(v) = value else {
return Ok(WriteScope::All);
};
let mut out = Vec::new();
for raw in v.split(',') {
let name = raw.trim();
if name.is_empty() {
continue;
}
if !known.contains(&name) {
let mut sorted: Vec<&str> = known.to_vec();
sorted.sort_unstable();
return Err(format!(
"unknown write verb '{name}' — known verbs: {}",
sorted.join(", ")
));
}
if !out.iter().any(|e| e == name) {
out.push(name.to_string());
}
}
if out.is_empty() {
return Err("--allow-writes= was given with no verbs — omit the \
`=` to allow all, or name at least one"
.into());
}
Ok(WriteScope::Only(out))
}
fn invalid_request_response(req: &Value) -> Option<String> {
if req.is_object() {
return None;
}
Some(
json!({
"jsonrpc": "2.0",
"id": null,
"error": {"code": -32600, "message": "invalid request: expected a single JSON-RPC object"}
})
.to_string(),
)
}
pub(crate) use crate::util::redact_option_value;
enum Backend {
Aws,
Demo,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExeIdentity {
pub path: String,
pub inode: Option<u64>,
}
impl ExeIdentity {
fn of(path: &std::path::Path) -> Self {
use std::os::unix::fs::MetadataExt;
Self {
path: path.display().to_string(),
inode: std::fs::metadata(path).ok().map(|m| m.ino()),
}
}
pub(crate) fn current() -> Option<Self> {
std::env::current_exe().ok().map(|p| Self::of(&p))
}
#[cfg(test)]
pub(crate) fn for_tests(path: &std::path::Path) -> Self {
Self::of(path)
}
fn is_stale(&self) -> bool {
staleness(self.inode, Self::of(std::path::Path::new(&self.path)).inode)
}
}
pub(crate) fn staleness(at_start: Option<u64>, now: Option<u64>) -> bool {
match (at_start, now) {
(Some(a), Some(b)) => a != b,
_ => false,
}
}
pub(crate) fn stale_binary_notice(path: &str) -> String {
format!(
"[ebman {} is running, but a different build is now installed at {}. \
This server keeps the old one until the connection is re-established \
— reconnect (in Claude Code: /mcp, Reconnect) to pick it up. \
Results below are from the running build.]",
env!("CARGO_PKG_VERSION"),
path
)
}
pub(crate) struct Server {
backend: Backend,
redact: bool,
write_scope: WriteScope,
safety_cfg: crate::config::Config,
writes: tokio::sync::Mutex<writes::WriteState>,
dispatching: std::sync::atomic::AtomicBool,
client_supports_elicitation: std::sync::atomic::AtomicBool,
client_name: std::sync::Mutex<String>,
exe: Option<ExeIdentity>,
#[cfg(test)]
injected_client: Option<std::sync::Arc<aws::AwsClient>>,
}
impl Server {
pub(crate) fn with_scope(demo: bool, no_redact: bool, scope: WriteScope) -> Self {
let safety_cfg = if demo {
crate::config::Config::default()
} else {
crate::config::load()
};
Self::with_config(demo, no_redact, scope, safety_cfg)
}
pub(crate) fn with_config(
demo: bool,
no_redact: bool,
scope: WriteScope,
safety_cfg: crate::config::Config,
) -> Self {
Server {
backend: if demo { Backend::Demo } else { Backend::Aws },
redact: !no_redact,
write_scope: scope,
safety_cfg,
writes: tokio::sync::Mutex::new(writes::WriteState::default()),
dispatching: std::sync::atomic::AtomicBool::new(false),
client_name: std::sync::Mutex::new("unknown".to_string()),
client_supports_elicitation: std::sync::atomic::AtomicBool::new(false),
exe: ExeIdentity::current(),
#[cfg(test)]
injected_client: None,
}
}
#[cfg(test)]
pub(crate) fn watching_exe(mut self, path: &std::path::Path) -> Self {
self.exe = Some(ExeIdentity::for_tests(path));
self
}
#[cfg(test)]
pub(crate) fn with_injected_client(
scope: WriteScope,
safety_cfg: crate::config::Config,
client: aws::AwsClient,
) -> Self {
let mut s = Self::with_config(false, false, scope, safety_cfg);
s.injected_client = Some(std::sync::Arc::new(client));
s
}
pub(crate) async fn handle_request(&self, req: &Value) -> Option<Value> {
let id = req.get("id").cloned();
let method = req.get("method").and_then(Value::as_str).unwrap_or("");
match id {
None | Some(Value::Null) => return None,
Some(_) => {}
}
match method {
"initialize" => {
if let Some(name) = req
.get("params")
.and_then(|p| p.get("clientInfo"))
.and_then(|c| c.get("name"))
.and_then(Value::as_str)
{
if let Ok(mut cn) = self.client_name.lock() {
*cn = name.to_string();
}
}
let elicits = req
.get("params")
.and_then(|p| p.get("capabilities"))
.and_then(|c| c.get("elicitation"))
.is_some_and(Value::is_object);
self.client_supports_elicitation
.store(elicits, std::sync::atomic::Ordering::Relaxed);
tracing::info!(
target: "ebman::mcp",
client = %self.client_name.lock().map(|c| c.clone()).unwrap_or_default(),
elicitation = elicits,
"MCP client connected"
);
let client_version = req
.get("params")
.and_then(|p| p.get("protocolVersion"))
.and_then(Value::as_str)
.unwrap_or("");
let version = if client_version == PROTOCOL_VERSION {
client_version
} else {
PROTOCOL_VERSION
};
Some(json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": version,
"capabilities": {"tools": {}},
"serverInfo": {"name": "ebman", "version": env!("CARGO_PKG_VERSION")},
"instructions": format!(
"{}{}\n\n{}",
concat!(
"ebman ", env!("CARGO_PKG_VERSION"),
" — a fleet console for AWS Elastic Beanstalk. This surface exposes reads, ",
"plus two-phase writes when the server was started with --allow-writes.\n\n"),
self.write_scope.agent_summary(),
concat!(
"Check this against the latest release before reporting a capability as missing — ",
"this list describes THIS build.\n\n",
"Capabilities ebman HAS that this surface does NOT expose — ask the operator to run them, ",
"or ask for them to be exposed here:\n",
"- Nothing queue-related: depth and peek are `worker_queues`, and resend / delete / ",
"purge are `dlq_resend` / `dlq_delete` / `dlq_purge` under --allow-writes.\n",
"- A LIVE log tail (streaming, follows new lines): TUI, Detail view, Logs tab. ",
"Point-in-time log queries ARE exposed here, as `recent_logs`.\n\n",
"Tool descriptions carry CAVEATS naming what each tool cannot see. They are accurate and ",
"worth reading: a clean result from a tool does not clear what that tool never checked."
))
}
}))
}
"ping" => Some(json!({"jsonrpc": "2.0", "id": id, "result": {}})),
"tools/list" => Some(json!({
"jsonrpc": "2.0",
"id": id,
"result": {"tools": tool_table(&self.write_scope, self.safety_cfg.mcp_peek_bodies)}
})),
"tools/call" => {
let params = req.get("params").cloned().unwrap_or_else(|| json!({}));
let name = params
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let args = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
let advertised = tool_table(&self.write_scope, self.safety_cfg.mcp_peek_bodies)
.as_array()
.is_some_and(|t| t.iter().any(|d| d["name"] == name.as_str()));
if !advertised && !writes::write_verb_names().contains(&name) {
return Some(json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": -32602, "message": format!("unknown tool '{name}'")}
}));
}
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(TOOL_TIMEOUT_SECS),
self.call_tool(&name, &args),
)
.await
.unwrap_or_else(|_| {
Err(format!(
"tool '{name}' timed out after {TOOL_TIMEOUT_SECS}s"
))
});
let (text, is_error) = match outcome {
Ok(body) => (body, false),
Err(msg) => (msg, true),
};
let text = match self.exe.as_ref().filter(|e| e.is_stale()) {
Some(e) => format!("{}\n{text}", stale_binary_notice(&e.path)),
None => text,
};
Some(json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{"type": "text", "text": text}],
"isError": is_error
}
}))
}
_ => Some(json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": -32601, "message": format!("method '{method}' not found")}
})),
}
}
}
pub async fn run(args: &[String]) -> Result<()> {
match args.get(1).map(String::as_str) {
Some("setup") => return setup::run(args),
Some("serve") => {}
_ => {
eprintln!("{MCP_USAGE}");
std::process::exit(2);
}
}
let McpArgs {
demo,
no_redact,
write_scope,
} = match parse_mcp_args(args) {
Ok(parsed) => parsed,
Err(msg) => {
eprintln!("{msg}");
std::process::exit(2);
}
};
if should_init_audit(&write_scope, demo) {
crate::audit::init_from_config_disk();
}
let server = Arc::new(Server::with_scope(demo, no_redact, write_scope.clone()));
let tool_slots = Arc::new(tokio::sync::Semaphore::new(16));
let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::<String>(256);
let writer = tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let mut stdout = tokio::io::stdout();
while let Some(line) = out_rx.recv().await {
if stdout.write_all(line.as_bytes()).await.is_err()
|| stdout.write_all(b"\n").await.is_err()
|| stdout.flush().await.is_err()
{
eprintln!("ebman mcp: stdout closed — dropping remaining frames");
break;
}
}
});
use futures::StreamExt;
use tokio_util::codec::{FramedRead, LinesCodec};
const MAX_LINE_BYTES: usize = 16 * 1024 * 1024;
let mut lines = FramedRead::new(
tokio::io::stdin(),
LinesCodec::new_with_max_length(MAX_LINE_BYTES),
);
let mut consecutive_read_errors: u32 = 0;
let mut last_was_error = false;
loop {
let line = match lines.next().await {
Some(Ok(line)) => {
consecutive_read_errors = 0;
last_was_error = false;
line
}
None => {
if last_was_error {
last_was_error = false;
continue;
}
break;
}
Some(Err(e)) => {
consecutive_read_errors += 1;
last_was_error = true;
eprintln!("ebman mcp: stdin read error (skipping line): {e}");
if consecutive_read_errors >= 5 {
eprintln!(
"ebman mcp: {consecutive_read_errors} consecutive read errors — exiting"
);
break;
}
continue;
}
};
let line = line.trim();
if line.is_empty() {
continue;
}
let req: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => {
let _ = out_tx
.send(
json!({
"jsonrpc": "2.0",
"id": null,
"error": {"code": -32700, "message": "parse error"}
})
.to_string(),
)
.await;
continue;
}
};
if let Some(resp) = invalid_request_response(&req) {
let _ = out_tx.send(resp).await;
continue;
}
if req.get("method").and_then(Value::as_str) == Some("tools/call") {
let permit = Arc::clone(&tool_slots).acquire_owned().await;
let server = Arc::clone(&server);
let out_tx = out_tx.clone();
tokio::spawn(async move {
let _permit = permit;
if let Some(resp) = server.handle_request(&req).await {
let _ = out_tx.send(resp.to_string()).await;
}
});
} else if let Some(resp) = server.handle_request(&req).await {
let _ = out_tx.send(resp.to_string()).await;
}
}
drop(out_tx);
let _ = writer.await;
if write_scope.any() {
crate::audit::drain_webhooks(std::time::Duration::from_secs(12)).await;
}
Ok(())
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn the_elicitation_capability_is_detected_per_client() {
use std::sync::atomic::Ordering;
async fn declares(caps: serde_json::Value) -> bool {
let s = Server::with_scope(true, false, WriteScope::None);
let _ = s
.handle_request(&json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": caps,
"clientInfo": {"name": "probe", "version": "1"}
}
}))
.await;
s.client_supports_elicitation.load(Ordering::Relaxed)
}
assert!(
declares(json!({"elicitation": {}})).await,
"a client declaring elicitation must be detected, or `ask` \
degrades to a refusal for everyone"
);
for explicit_no in [json!({"elicitation": false}), json!({"elicitation": null})] {
assert!(
!declares(explicit_no.clone()).await,
"{explicit_no} explicitly declines elicitation; treating \
mere presence as support would let `ask` believe a human \
is watching when none is"
);
}
assert!(
!declares(json!({})).await,
"a client declaring nothing must NOT be treated as able to ask"
);
assert!(
!declares(json!({"sampling": {}, "roots": {}})).await,
"other capabilities are not elicitation"
);
}
use super::*;
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
fn demo_server() -> Server {
Server::with_scope(true, false, WriteScope::None)
}
fn demo_writes_server() -> Server {
Server::with_scope(true, false, WriteScope::All)
}
async fn rpc(server: &Server, frame: Value) -> Option<Value> {
server.handle_request(&frame).await
}
async fn call(server: &Server, name: &str, args: Value) -> (bool, Value) {
let frame = json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":name,"arguments":args}});
let resp = server.handle_request(&frame).await.expect("response");
let result = &resp["result"];
let is_error = result["isError"].as_bool().unwrap_or(false);
let text = result["content"][0]["text"].as_str().unwrap_or("");
let parsed = serde_json::from_str(text).unwrap_or(Value::String(text.to_string()));
(is_error, parsed)
}
#[tokio::test]
async fn write_tools_appear_only_under_allow_writes() {
let list = |s: &Server| {
let arr = tool_table(&s.write_scope, true);
arr.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap().to_string())
.collect::<Vec<_>>()
};
let reads = list(&demo_server());
assert!(!reads.contains(&"deploy".to_string()));
assert!(!reads.contains(&"confirm_action".to_string()));
let writes = list(&demo_writes_server());
for t in [
"deploy",
"restart",
"rebuild",
"terminate",
"set_option",
"confirm_action",
] {
assert!(writes.contains(&t.to_string()), "missing {t}");
}
}
#[tokio::test]
async fn two_phase_happy_path_demo() {
let s = demo_writes_server();
let envs = demo_fixture::envs();
let env = &envs[0].name;
let (err, plan) = call(&s, "restart", json!({"env": env})).await;
assert!(!err);
assert_eq!(plan["pending"], true);
assert_eq!(plan["plan"]["action"], "Restart");
let token = plan["confirm_token"].as_str().unwrap().to_string();
let (err2, out) = call(&s, "confirm_action", json!({"confirm_token": token})).await;
assert!(!err2);
assert_eq!(out["dispatched"], true);
assert_eq!(out["demo"], true);
}
#[tokio::test]
async fn confirm_token_single_use_and_unknown() {
let s = demo_writes_server();
let env = &demo_fixture::envs()[0].name;
let (_, plan) = call(&s, "restart", json!({"env": env})).await;
let token = plan["confirm_token"].as_str().unwrap().to_string();
assert!(
!call(
&s,
"confirm_action",
json!({"confirm_token": token.clone()})
)
.await
.0
);
assert!(
call(&s, "confirm_action", json!({"confirm_token": token}))
.await
.0
);
assert!(
call(&s, "confirm_action", json!({"confirm_token": "deadbeef"}))
.await
.0
);
assert!(
call(&s, "confirm_action", json!({"confirm_token": "x"}))
.await
.0
);
}
#[tokio::test]
async fn terminate_requires_matching_confirm_name_with_one_retry() {
let s = demo_writes_server();
let env = demo_fixture::envs()[0].name.clone();
let (_, plan) = call(&s, "terminate", json!({"env": env})).await;
let token = plan["confirm_token"].as_str().unwrap().to_string();
assert!(
call(
&s,
"confirm_action",
json!({"confirm_token": token.clone(), "confirm_name": "wrong"})
)
.await
.0
);
let (err, out) = call(
&s,
"confirm_action",
json!({"confirm_token": token, "confirm_name": env}),
)
.await;
assert!(!err);
assert_eq!(out["dispatched"], true);
}
#[tokio::test]
async fn terminate_second_wrong_name_drops_the_plan() {
let s = demo_writes_server();
let env = demo_fixture::envs()[0].name.clone();
let (_, plan) = call(&s, "terminate", json!({"env": env})).await;
let token = plan["confirm_token"].as_str().unwrap().to_string();
assert!(
call(
&s,
"confirm_action",
json!({"confirm_token": token.clone(), "confirm_name": "wrong"})
)
.await
.0
);
assert!(
call(
&s,
"confirm_action",
json!({"confirm_token": token.clone(), "confirm_name": "wrong"})
)
.await
.0
);
let env2 = demo_fixture::envs()[0].name.clone();
assert!(
call(
&s,
"confirm_action",
json!({"confirm_token": token, "confirm_name": env2})
)
.await
.0
);
}
#[tokio::test]
async fn deploy_rejects_unknown_version_and_plan_carries_versions() {
let s = demo_writes_server();
let envs = demo_fixture::envs();
let env = &envs[0];
let known = &demo_fixture::deploys_for_app(&env.application)[0].label;
let (err, plan) = call(&s, "deploy", json!({"env": env.name, "version": known})).await;
assert!(!err);
assert_eq!(plan["plan"]["target_version"], known.as_str());
assert!(plan["plan"]["current_version"].is_string());
let (err2, _) = call(
&s,
"deploy",
json!({"env": env.name, "version": "no-such-999"}),
)
.await;
assert!(err2, "unknown version must refuse");
}
#[tokio::test]
async fn set_option_caps_and_gates_namespaces_and_redacts_old() {
let s = demo_writes_server();
let env = &demo_fixture::envs()[0].name;
let big: Vec<Value> = (0..11)
.map(|i| json!({"namespace": "aws:autoscaling:asg", "name": format!("n{i}"), "value": "1"}))
.collect();
assert!(
call(&s, "set_option", json!({"env": env, "settings": big}))
.await
.0
);
assert!(
call(
&s,
"set_option",
json!({"env": env, "settings": [{"namespace":"made:up","name":"X","value":"1"}]})
)
.await
.0
);
let (err, plan) = call(
&s,
"set_option",
json!({"env": env, "settings": [{"namespace":"aws:autoscaling:asg","name":"MinSize","value":"9"}]}),
)
.await;
assert!(!err);
assert_eq!(plan["plan"]["changes"][0]["new"], "9");
}
#[tokio::test]
async fn dispatching_flag_clears_after_dispatch() {
let s = demo_writes_server();
let env = &demo_fixture::envs()[0].name;
let (_, p1) = call(&s, "restart", json!({"env": env})).await;
let t1 = p1["confirm_token"].as_str().unwrap().to_string();
assert!(
!call(&s, "confirm_action", json!({"confirm_token": t1}))
.await
.0
);
assert!(
!s.dispatching.load(std::sync::atomic::Ordering::SeqCst),
"guard must clear dispatching after dispatch"
);
let (_, p2) = call(&s, "restart", json!({"env": env})).await;
let t2 = p2["confirm_token"].as_str().unwrap().to_string();
assert!(
!call(&s, "confirm_action", json!({"confirm_token": t2}))
.await
.0
);
}
#[tokio::test]
async fn write_serialization_blocks_second_dispatch_slot() {
let s = demo_writes_server();
let env = &demo_fixture::envs()[0].name;
let (_, plan) = call(&s, "restart", json!({"env": env})).await;
let token = plan["confirm_token"].as_str().unwrap().to_string();
s.dispatching
.store(true, std::sync::atomic::Ordering::SeqCst);
assert!(
call(&s, "confirm_action", json!({"confirm_token": token}))
.await
.0,
"confirm must refuse while a dispatch is in flight"
);
}
#[tokio::test]
async fn write_pin_refusal() {
let mut cfg = crate::config::Config::default();
cfg.safety_envs
.insert(demo_fixture::envs()[0].name.clone(), true);
let s = Server::with_config(true, false, WriteScope::All, cfg);
let env = demo_fixture::envs()[0].name.clone();
let (err, plan) = call(&s, "restart", json!({"env": env})).await;
assert!(err, "pinned env must refuse");
assert!(
plan.as_str().unwrap_or("").contains("pinned"),
"refusal names the pin: {plan:?}"
);
}
#[test]
fn mcp_args_require_serve_and_reject_unknown_flags() {
assert!(parse_mcp_args(&argv(&["mcp"])).is_err());
assert!(parse_mcp_args(&argv(&["mcp", "listen"])).is_err());
assert!(parse_mcp_args(&argv(&["mcp", "serve", "--port"])).is_err());
let p = parse_mcp_args(&argv(&["mcp", "serve", "--demo", "--no-redact"])).unwrap();
assert!(p.demo && p.no_redact);
let p = parse_mcp_args(&argv(&["mcp", "serve"])).unwrap();
assert!(!p.demo && !p.no_redact);
}
#[tokio::test]
async fn golden_initialize_frame() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{}}}),
)
.await
.expect("initialize answers");
assert_eq!(resp["id"], 1);
assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
assert_eq!(resp["result"]["serverInfo"]["name"], "ebman");
assert!(resp["result"]["capabilities"]["tools"].is_object());
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":2,"method":"initialize",
"params":{"protocolVersion":"2024-11-05"}}),
)
.await
.unwrap();
assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
}
#[tokio::test]
async fn golden_tools_list_frame() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":3,"method":"tools/list"}),
)
.await
.expect("tools/list answers");
let tools = resp["result"]["tools"].as_array().expect("array");
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert_eq!(
names,
vec![
"list_environments",
"worker_queues",
"recent_logs",
"why",
"lint",
"get_option_settings",
"drift",
"audit_log",
"recent_events",
"list_versions",
"fleet_cost",
],
"tool registry changed — update docs/headless.md's table"
);
let lint_desc = tools
.iter()
.find(|t| t["name"] == "lint")
.and_then(|t| t["description"].as_str())
.expect("lint is advertised");
assert!(lint_desc.contains("EBL011") && lint_desc.contains("EBL016"));
assert!(
lint_desc.contains("worker_queues"),
"the EBL011 caveat must name the tool that DOES see queues: {lint_desc}"
);
for t in tools {
assert!(t["inputSchema"]["type"] == "object", "schema shape");
assert!(
!t["description"].as_str().unwrap().is_empty(),
"empty description"
);
}
}
#[tokio::test]
async fn notifications_and_unknown_methods_route_correctly() {
let s = demo_server();
assert!(rpc(
&s,
json!({"jsonrpc":"2.0","method":"notifications/initialized"})
)
.await
.is_none());
let resp = rpc(&s, json!({"jsonrpc":"2.0","id":4,"method":"ping"}))
.await
.unwrap();
assert!(resp["result"].is_object());
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":5,"method":"resources/list"}),
)
.await
.unwrap();
assert_eq!(resp["error"]["code"], -32601);
assert!(
rpc(&s, json!({"jsonrpc":"2.0","method":"resources/changed"}))
.await
.is_none()
);
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":6,"method":"tools/call",
"params":{"name":"terminate_env","arguments":{}}}),
)
.await
.unwrap();
assert_eq!(resp["error"]["code"], -32602);
}
#[tokio::test]
async fn demo_e2e_list_environments_and_lint() {
let s = demo_server();
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"list_environments","arguments":{}}}),
)
.await
.unwrap();
assert_eq!(resp["result"]["isError"], false);
let body = resp["result"]["content"][0]["text"].as_str().unwrap();
let parsed: Value = serde_json::from_str(body).expect("tool body is valid JSON");
assert!(
!parsed.as_array().unwrap().is_empty(),
"demo fleet non-empty"
);
assert!(parsed[0]["name"].is_string() && parsed[0]["health"].is_string());
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":8,"method":"tools/call",
"params":{"name":"lint","arguments":{"rules":"EBL014"}}}),
)
.await
.unwrap();
assert_eq!(resp["result"]["isError"], false);
let body = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(body.contains("EBL014"), "planted finding surfaced: {body}");
}
#[tokio::test]
async fn demo_e2e_option_settings_redacts_env_vars_by_default() {
let s = demo_server();
let env_name = demo_fixture::envs()[0].name.clone();
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":9,"method":"tools/call",
"params":{"name":"get_option_settings","arguments":{"env": env_name}}}),
)
.await
.unwrap();
let body = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
!body.contains("hunter2"),
"secret env-var value must not leak: {body}"
);
assert!(body.contains("DATABASE_URL"), "keys stay visible");
assert!(body.contains("(redacted)"));
assert!(body.contains("\"redacted\":true"));
let open = Server::with_scope(true, true, WriteScope::None);
let resp = rpc(
&open,
json!({"jsonrpc":"2.0","id":10,"method":"tools/call",
"params":{"name":"get_option_settings",
"arguments":{"env": demo_fixture::envs()[0].name.clone()}}}),
)
.await
.unwrap();
let body = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(body.contains("hunter2") && body.contains("\"redacted\":false"));
}
#[tokio::test]
async fn tool_errors_come_back_as_is_error_results_not_rpc_errors() {
let s = demo_server();
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":11,"method":"tools/call",
"params":{"name":"get_option_settings","arguments":{"env":"no-such-env"}}}),
)
.await
.unwrap();
assert_eq!(resp["result"]["isError"], true);
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("not found"), "got: {text}");
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":12,"method":"tools/call",
"params":{"name":"list_versions","arguments":{}}}),
)
.await
.unwrap();
assert_eq!(resp["result"]["isError"], true);
}
#[test]
fn drift_reports_redact_env_var_secrets() {
let mut reports = vec![(
"prod".to_string(),
true,
vec![
terraform::DriftField {
kind: "option_setting".into(),
namespace: Some("aws:elasticbeanstalk:application:environment".into()),
name: Some("DATABASE_URL".into()),
tf_value: "postgres://u:hunter2@old".into(),
live_value: "postgres://u:hunter2@new".into(),
},
terraform::DriftField {
kind: "option_setting".into(),
namespace: Some("aws:autoscaling:asg".into()),
name: Some("MaxSize".into()),
tf_value: "4".into(),
live_value: "6".into(),
},
terraform::DriftField {
kind: "version_label".into(),
namespace: None,
name: None,
tf_value: "v1".into(),
live_value: "v2".into(),
},
],
)];
redact_drift_reports(&mut reports);
let fields = &reports[0].2;
assert_eq!(fields[0].tf_value, "(redacted)");
assert_eq!(fields[0].live_value, "(redacted)");
assert_eq!(fields[1].live_value, "6", "non-secret options untouched");
assert_eq!(fields[2].tf_value, "v1", "non-option kinds untouched");
let rendered = terraform::render_drift_json(None, None, &reports);
assert!(!rendered.contains("hunter2"), "no secret in the payload");
}
#[test]
fn skipped_envs_spliced_only_when_present() {
let clean = append_skipped_envs("{\"issues\":[]}".to_string(), &[]);
assert_eq!(clean, "{\"issues\":[]}", "common case byte-identical");
let degraded = append_skipped_envs(
"{\"issues\":[]}".to_string(),
&["prod: fetch failed".to_string()],
);
assert_eq!(
degraded,
"{\"issues\":[],\"skipped_envs\":[\"prod: fetch failed\"]}"
);
serde_json::from_str::<Value>(°raded).expect("valid JSON");
}
#[test]
fn audit_jsonl_wraps_into_an_array() {
assert_eq!(jsonl_to_array(""), "[]");
assert_eq!(
jsonl_to_array("{\"a\":1}\n{\"b\":2}\n"),
"[{\"a\":1},{\"b\":2}]"
);
serde_json::from_str::<Value>(&jsonl_to_array("{\"a\":1}")).expect("valid JSON");
}
#[tokio::test]
async fn id_less_requests_are_notifications_and_get_no_response() {
let server = Server::with_scope(true, false, WriteScope::None);
let req: Value = serde_json::from_str(
r#"{"jsonrpc":"2.0","method":"tools/call","params":{"name":"nope"}}"#,
)
.unwrap();
assert!(server.handle_request(&req).await.is_none());
let ping: Value = serde_json::from_str(r#"{"jsonrpc":"2.0","method":"ping"}"#).unwrap();
assert!(server.handle_request(&ping).await.is_none());
let null_id: Value =
serde_json::from_str(r#"{"jsonrpc":"2.0","id":null,"method":"ping"}"#).unwrap();
assert!(server.handle_request(&null_id).await.is_none());
}
#[test]
fn non_object_frames_get_invalid_request() {
let arr: Value =
serde_json::from_str(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"}]"#).unwrap();
let resp = invalid_request_response(&arr).expect("array is invalid");
let parsed: Value = serde_json::from_str(&resp).unwrap();
assert_eq!(parsed["error"]["code"], -32600);
assert!(parsed["id"].is_null());
let scalar: Value = serde_json::from_str("42").unwrap();
assert!(invalid_request_response(&scalar).is_some());
let obj: Value =
serde_json::from_str(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).unwrap();
assert!(invalid_request_response(&obj).is_none());
}
#[test]
fn empty_cost_cache_total_formats_positive_zero() {
let costs: std::collections::HashMap<String, f64> = Default::default();
let total: f64 = costs.values().sum::<f64>() + 0.0;
assert_eq!(format!("{total:.2}"), "0.00");
}
#[test]
fn redaction_covers_env_vars_and_db_password_only() {
let r = |ns, n, v| redact_option_value(ns, n, v, true);
assert_eq!(
r(
"aws:elasticbeanstalk:application:environment",
"API_KEY",
"sk-123"
),
"(redacted)"
);
assert_eq!(r("aws:rds:dbinstance", "DBPassword", "pw"), "(redacted)");
assert_eq!(r("aws:autoscaling:asg", "MaxSize", "6"), "6");
assert_eq!(
redact_option_value(
"aws:elasticbeanstalk:application:environment",
"API_KEY",
"sk-123",
false
),
"sk-123"
);
}
#[tokio::test]
async fn credential_errors_are_rewritten_actionably() {
let msg = tool_error(
&Some("prod-admin".into()),
"list_environments",
"The security token included in the request is expired",
);
assert!(
msg.contains("aws sso login --profile prod-admin"),
"got: {msg}"
);
let msg = tool_error(&None, "op", "some unrelated failure");
assert!(msg.contains("op failed"), "got: {msg}");
}
#[tokio::test]
async fn initialize_names_what_this_surface_cannot_do() {
let s = Server::with_scope(true, false, WriteScope::None);
let resp = s
.handle_request(&json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
"clientInfo": {"name": "probe", "version": "1"}}
}))
.await
.expect("initialize responds");
let instructions = resp["result"]["instructions"]
.as_str()
.expect("initialize must carry instructions")
.to_string();
const TUI_ONLY: &[&str] = &["LIVE log tail"];
for needle in TUI_ONLY {
assert!(
instructions.to_lowercase().contains(&needle.to_lowercase()),
"the instructions must name `{needle}` as available elsewhere: {instructions}"
);
}
assert!(
instructions.contains("TUI"),
"and must say where: {instructions}"
);
assert!(
instructions.contains(env!("CARGO_PKG_VERSION")),
"the instructions must name the build they describe: {instructions}"
);
assert_eq!(
resp["result"]["serverInfo"]["version"],
env!("CARGO_PKG_VERSION"),
"and must agree with serverInfo"
);
let tools = s
.handle_request(&json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}))
.await
.expect("tools/list responds");
let names: Vec<String> = tools["result"]["tools"]
.as_array()
.expect("a tool array")
.iter()
.filter_map(|t| t["name"].as_str().map(str::to_string))
.collect();
assert!(
!names
.iter()
.any(|n| n.contains("resend") || n.contains("purge")),
"a dead-letter management tool exists now, so the instructions \
claiming that is TUI-only are stale: {names:?}"
);
}
#[tokio::test]
async fn worker_queues_reports_depth_and_whether_it_looked() {
let s = demo_server();
let envs = rpc(
&s,
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"list_environments","arguments":{}}}),
)
.await
.expect("envs");
let listing = envs["result"]["content"][0]["text"].as_str().expect("text");
let env_name = listing
.split("\"name\":\"")
.nth(1)
.and_then(|r| r.split('"').next())
.expect("an env in the demo fleet")
.to_string();
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"worker_queues","arguments":{"env": env_name}}}),
)
.await
.expect("worker_queues answers");
let body = resp["result"]["content"][0]["text"]
.as_str()
.expect("text payload");
let parsed: Value = serde_json::from_str(body).expect("valid JSON");
assert!(parsed["main_queue"].is_object(), "{body}");
assert!(parsed["dead_letter_queue"].is_object(), "{body}");
assert_eq!(
parsed["peeked"], false,
"peek defaults off, and the response must say so: {body}"
);
assert!(parsed["messages"].is_array(), "{body}");
}
#[tokio::test]
async fn worker_queues_requires_an_env() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"worker_queues","arguments":{}}}),
)
.await
.expect("a response");
let text = serde_json::to_string(&resp).expect("serialisable");
assert!(
text.contains("'env' is required"),
"must refuse rather than guess an env: {text}"
);
}
#[tokio::test]
async fn the_queue_tool_warns_that_a_peek_inflates_the_receive_count() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}),
)
.await
.expect("tools/list");
let desc = resp["result"]["tools"]
.as_array()
.expect("array")
.iter()
.find(|t| t["name"] == "worker_queues")
.and_then(|t| t["description"].as_str())
.expect("worker_queues is advertised")
.to_string();
assert!(
desc.contains("receive_count"),
"the caveat must name the field it is about: {desc}"
);
assert!(
desc.contains("not a retry count") || desc.contains("NOT a retry count"),
"and must say what it is not: {desc}"
);
assert!(
desc.contains("dead_letter_queue.origin"),
"a derived DLQ url that returns nothing is ordinary; a reported \
one that does is an anomaly — the consumer cannot tell without \
this: {desc}"
);
}
#[tokio::test]
async fn recent_logs_says_whether_it_reached_the_newest() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"recent_logs","arguments":{"env":"any"}}}),
)
.await
.expect("recent_logs answers");
let body = resp["result"]["content"][0]["text"]
.as_str()
.expect("text payload");
let parsed: Value = serde_json::from_str(body).expect("valid JSON");
assert!(
parsed["complete"].is_boolean(),
"every answer must say whether the window was fully read: {body}"
);
assert!(parsed["events"].is_array(), "{body}");
}
#[tokio::test]
async fn recent_logs_warns_about_oldest_first() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}),
)
.await
.expect("tools/list");
let desc = resp["result"]["tools"]
.as_array()
.expect("array")
.iter()
.find(|t| t["name"] == "recent_logs")
.and_then(|t| t["description"].as_str())
.expect("recent_logs is advertised")
.to_string();
assert!(
desc.contains("oldest-first") || desc.contains("oldest first"),
"the trap must be named: {desc}"
);
assert!(
desc.contains("complete"),
"and the field that tells you which you have: {desc}"
);
assert!(
desc.contains("NOT REDACTED"),
"the one read tool that cannot be redacted must say so: {desc}"
);
}
#[tokio::test]
async fn why_returns_every_section_in_one_call() {
let s = demo_server();
let envs = rpc(
&s,
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"list_environments","arguments":{}}}),
)
.await
.expect("envs");
let env_name = envs["result"]["content"][0]["text"]
.as_str()
.and_then(|t| t.split("\"name\":\"").nth(1))
.and_then(|r| r.split('"').next())
.expect("an env")
.to_string();
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"why","arguments":{"env": env_name}}}),
)
.await
.expect("why answers");
let body = resp["result"]["content"][0]["text"].as_str().expect("text");
let parsed: Value = serde_json::from_str(body).expect("valid JSON");
for section in ["events", "alarms", "instances", "queues", "recent_versions"] {
assert!(
parsed.get(section).is_some(),
"`{section}` must be present even when null — a missing key \
and an empty one read differently: {body}"
);
}
assert!(
parsed["errors"].is_array(),
"a partial bundle must be visibly partial: {body}"
);
}
#[test]
fn a_failed_why_section_is_null_with_its_reason() {
let bundle = super::tools::render_why_json(
"api-prod",
"[]",
"null",
"[]",
"null",
"[]",
&[
("alarms".to_string(), "AccessDenied".to_string()),
("queues".to_string(), "throttled".to_string()),
],
);
let parsed: Value = serde_json::from_str(&bundle).expect("valid JSON");
assert!(parsed["alarms"].is_null(), "{bundle}");
assert!(parsed["queues"].is_null(), "{bundle}");
assert!(
parsed["events"].is_array() && parsed["instances"].is_array(),
"sections that succeeded must still be there: {bundle}"
);
let errs = parsed["errors"].as_array().expect("errors array");
assert_eq!(errs.len(), 2, "{bundle}");
assert!(
errs.iter()
.any(|e| e["section"] == "alarms" && e["error"] == "AccessDenied"),
"the reason must survive, not just the fact of failure: {bundle}"
);
}
#[test]
fn a_failed_section_is_null_and_recorded() {
let mut errors: Vec<(String, String)> = Vec::new();
let ok = super::tools::section_or_error("events", Ok("[1,2]".into()), &mut errors);
assert_eq!(ok, "[1,2]", "a good section passes through untouched");
assert!(errors.is_empty(), "and records nothing");
let bad = super::tools::section_or_error("alarms", Err("AccessDenied".into()), &mut errors);
assert_eq!(
bad, "null",
"a failed section must be null — `[]` would read as \"no alarms\", \
which is the opposite of \"we could not check\""
);
assert_eq!(
errors,
vec![("alarms".to_string(), "AccessDenied".to_string())],
"and the reason must be kept, not just the fact of failure"
);
}
#[test]
fn a_denied_dlq_peek_is_not_an_empty_queue() {
use crate::aws::QueueMessage;
let msg = || QueueMessage {
id: "m-1".into(),
receipt_handle: String::new(),
body: "b".into(),
receive_count: 1,
sent_at: None,
task: None,
};
let mut errors = Vec::new();
let (msgs, peeked) = super::tools::dlq_peek_outcome(Some(Ok(vec![msg()])), &mut errors);
assert_eq!(msgs.len(), 1);
assert!(peeked);
assert!(errors.is_empty());
let mut errors = Vec::new();
let (msgs, peeked) = super::tools::dlq_peek_outcome(
Some(Err("AccessDenied: sqs:ReceiveMessage".into())),
&mut errors,
);
assert!(msgs.is_empty());
assert!(
!peeked,
"a denied peek must not claim we looked — that turns \
'permission missing' into 'queue is clean'"
);
assert_eq!(
errors,
vec![(
"dlq_peek".to_string(),
"AccessDenied: sqs:ReceiveMessage".to_string()
)],
"and the reason must reach the caller, not just the fact"
);
let mut errors = Vec::new();
let (msgs, peeked) = super::tools::dlq_peek_outcome(None, &mut errors);
assert!(msgs.is_empty() && !peeked);
assert!(
errors.is_empty(),
"an env with no dead-letter queue is ordinary, not an error"
);
}
mod orchestration {
use super::*;
use aws_sdk_elasticbeanstalk::Client as EbClient;
use aws_sdk_sqs::Client as SqsClient;
fn client_with(eb: EbClient, sqs: SqsClient) -> crate::aws::AwsClient {
let cfg = aws_config::SdkConfig::builder()
.region(aws_config::Region::new("us-west-1"))
.behavior_version(aws_config::BehaviorVersion::latest())
.build();
crate::aws::AwsClient::for_tests(
eb,
sqs,
aws_sdk_cloudwatch::Client::new(&cfg),
aws_sdk_cloudwatchlogs::Client::new(&cfg),
aws_sdk_s3::Client::new(&cfg),
aws_sdk_ec2::Client::new(&cfg),
)
}
fn env_listing() -> aws_smithy_mocks::Rule {
use aws_sdk_elasticbeanstalk::operation::describe_environments::DescribeEnvironmentsOutput;
use aws_sdk_elasticbeanstalk::types::EnvironmentDescription;
aws_smithy_mocks::mock!(EbClient::describe_environments).then_output(|| {
DescribeEnvironmentsOutput::builder()
.environments(
EnvironmentDescription::builder()
.environment_name("poly-prod-wk")
.application_name("poly")
.status("Ready".into())
.health("Yellow".into())
.tier(
aws_sdk_elasticbeanstalk::types::EnvironmentTier::builder()
.name("Worker")
.build(),
)
.build(),
)
.build()
})
}
#[tokio::test]
async fn worker_queues_peeks_the_dead_letter_queue() {
use aws_sdk_elasticbeanstalk::operation::describe_environment_resources::DescribeEnvironmentResourcesOutput;
use aws_sdk_elasticbeanstalk::types::{EnvironmentResourceDescription, Queue};
use aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesOutput;
use aws_sdk_sqs::operation::receive_message::ReceiveMessageOutput;
use aws_sdk_sqs::types::{Message, MessageAttributeValue, QueueAttributeName};
let resources = aws_smithy_mocks::mock!(EbClient::describe_environment_resources)
.then_output(|| {
DescribeEnvironmentResourcesOutput::builder()
.environment_resources(
EnvironmentResourceDescription::builder()
.queues(
Queue::builder()
.name("WorkerQueue")
.url("https://sqs/main")
.build(),
)
.queues(
Queue::builder()
.name("WorkerDeadLetterQueue")
.url("https://sqs/main-dlq")
.build(),
)
.build(),
)
.build()
});
let attrs =
aws_smithy_mocks::mock!(SqsClient::get_queue_attributes).then_output(|| {
GetQueueAttributesOutput::builder()
.attributes(QueueAttributeName::ApproximateNumberOfMessages, "1")
.attributes(
QueueAttributeName::ApproximateNumberOfMessagesNotVisible,
"0",
)
.attributes(QueueAttributeName::ApproximateNumberOfMessagesDelayed, "0")
.build()
});
let peek = aws_smithy_mocks::mock!(SqsClient::receive_message)
.match_requests(|req| req.queue_url() == Some("https://sqs/main-dlq"))
.then_output(|| {
let attr = |v: &str| {
MessageAttributeValue::builder()
.data_type("String")
.string_value(v)
.build()
.expect("valid")
};
ReceiveMessageOutput::builder()
.messages(
Message::builder()
.message_id("m-1")
.receipt_handle("rh-1")
.body("elasticbeanstalk scheduled job")
.message_attributes(
"beanstalk.sqsd.task_name",
attr("ORCHCANARY sweep"),
)
.build(),
)
.build()
});
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[&env_listing(), &resources]
);
let sqs = aws_smithy_mocks::mock_client!(
aws_sdk_sqs,
aws_smithy_mocks::RuleMode::MatchAny,
[&attrs, &peek]
);
let s = Server::with_injected_client(
WriteScope::None,
crate::config::Config::default(),
client_with(eb, sqs),
);
let out = s
.call_tool(
"worker_queues",
&json!({"env": "poly-prod-wk", "peek": true}),
)
.await
.expect("worker_queues answers");
let v: Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["dead_letter_queue"]["stats"]["visible"], 1);
assert_eq!(v["peeked"], true);
assert_eq!(
v["messages"][0]["task"]["name"], "ORCHCANARY sweep",
"the peek must read the DEAD-LETTER queue and carry the \
task through: {out}"
);
}
#[tokio::test]
async fn worker_queues_does_not_peek_unless_asked() {
use aws_sdk_elasticbeanstalk::operation::describe_environment_resources::DescribeEnvironmentResourcesOutput;
use aws_sdk_elasticbeanstalk::types::{EnvironmentResourceDescription, Queue};
use aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesOutput;
use aws_sdk_sqs::types::QueueAttributeName;
let resources = aws_smithy_mocks::mock!(EbClient::describe_environment_resources)
.then_output(|| {
DescribeEnvironmentResourcesOutput::builder()
.environment_resources(
EnvironmentResourceDescription::builder()
.queues(
Queue::builder()
.name("WorkerQueue")
.url("https://sqs/main")
.build(),
)
.build(),
)
.build()
});
let attrs =
aws_smithy_mocks::mock!(SqsClient::get_queue_attributes).then_output(|| {
GetQueueAttributesOutput::builder()
.attributes(QueueAttributeName::ApproximateNumberOfMessages, "0")
.build()
});
let settings = aws_smithy_mocks::mock!(EbClient::describe_configuration_settings)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_configuration_settings::DescribeConfigurationSettingsOutput::builder().build()
});
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[&env_listing(), &resources, &settings]
);
let sqs = aws_smithy_mocks::mock_client!(
aws_sdk_sqs,
aws_smithy_mocks::RuleMode::MatchAny,
[&attrs]
);
let s = Server::with_injected_client(
WriteScope::None,
crate::config::Config::default(),
client_with(eb, sqs),
);
let out = s
.call_tool("worker_queues", &json!({"env": "poly-prod-wk"}))
.await
.expect("depth-only must not need a receive_message rule");
let v: Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["peeked"], false, "{out}");
assert!(
v["messages"].as_array().is_some_and(|m| m.is_empty()),
"{out}"
);
}
#[tokio::test]
async fn worker_queues_refuses_an_unknown_env() {
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[&env_listing()]
);
let cfg = aws_config::SdkConfig::builder()
.region(aws_config::Region::new("us-west-1"))
.behavior_version(aws_config::BehaviorVersion::latest())
.build();
let s = Server::with_injected_client(
WriteScope::None,
crate::config::Config::default(),
client_with(eb, SqsClient::new(&cfg)),
);
let err = s
.call_tool("worker_queues", &json!({"env": "no-such-env"}))
.await
.expect_err("an unknown env must be an error");
assert!(err.contains("not found"), "{err}");
}
#[tokio::test]
async fn a_derived_dlq_that_does_not_exist_still_answers() {
use aws_sdk_elasticbeanstalk::operation::describe_environment_resources::DescribeEnvironmentResourcesOutput;
use aws_sdk_elasticbeanstalk::types::{EnvironmentResourceDescription, Queue};
use aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesOutput;
use aws_sdk_sqs::types::QueueAttributeName;
let resources = aws_smithy_mocks::mock!(EbClient::describe_environment_resources)
.then_output(|| {
DescribeEnvironmentResourcesOutput::builder()
.environment_resources(
EnvironmentResourceDescription::builder()
.queues(
Queue::builder()
.name("WorkerQueue")
.url("https://sqs/main")
.build(),
)
.build(),
)
.build()
});
let settings = aws_smithy_mocks::mock!(EbClient::describe_configuration_settings)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_configuration_settings::DescribeConfigurationSettingsOutput::builder().build()
});
let main_attrs = aws_smithy_mocks::mock!(SqsClient::get_queue_attributes)
.match_requests(|req| req.queue_url() == Some("https://sqs/main"))
.then_output(|| {
GetQueueAttributesOutput::builder()
.attributes(QueueAttributeName::ApproximateNumberOfMessages, "3")
.build()
});
let dlq_missing = aws_smithy_mocks::mock!(SqsClient::get_queue_attributes)
.match_requests(|req| req.queue_url() == Some("https://sqs/main-dlq"))
.then_error(|| {
aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesError::generic(
aws_smithy_types::error::ErrorMetadata::builder()
.code("AWS.SimpleQueueService.NonExistentQueue")
.message("The specified queue does not exist")
.build(),
)
});
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[&env_listing(), &resources, &settings]
);
let sqs = aws_smithy_mocks::mock_client!(
aws_sdk_sqs,
aws_smithy_mocks::RuleMode::MatchAny,
[&main_attrs, &dlq_missing]
);
let s = Server::with_injected_client(
WriteScope::None,
crate::config::Config::default(),
client_with(eb, sqs),
);
let out = s
.call_tool(
"worker_queues",
&json!({"env": "poly-prod-wk", "peek": true}),
)
.await
.expect("a missing derived DLQ must not fail the call");
let v: Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(
v["main_queue"]["stats"]["visible"], 3,
"the depth answer we already had must survive: {out}"
);
assert_eq!(
v["peeked"], false,
"there was nothing to peek, so we did not look — saying \
`true` here claims an empty queue that does not exist: {out}"
);
}
#[tokio::test]
async fn why_records_no_dlq_error_when_there_is_no_dlq() {
use aws_sdk_elasticbeanstalk::operation::describe_environment_resources::DescribeEnvironmentResourcesOutput;
use aws_sdk_elasticbeanstalk::types::{EnvironmentResourceDescription, Queue};
use aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesOutput;
use aws_sdk_sqs::types::QueueAttributeName;
let resources = aws_smithy_mocks::mock!(EbClient::describe_environment_resources)
.then_output(|| {
DescribeEnvironmentResourcesOutput::builder()
.environment_resources(
EnvironmentResourceDescription::builder()
.queues(
Queue::builder()
.name("WorkerQueue")
.url("https://sqs/main")
.build(),
)
.build(),
)
.build()
});
let settings = aws_smithy_mocks::mock!(EbClient::describe_configuration_settings)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_configuration_settings::DescribeConfigurationSettingsOutput::builder().build()
});
let main_attrs = aws_smithy_mocks::mock!(SqsClient::get_queue_attributes)
.match_requests(|req| req.queue_url() == Some("https://sqs/main"))
.then_output(|| {
GetQueueAttributesOutput::builder()
.attributes(QueueAttributeName::ApproximateNumberOfMessages, "0")
.build()
});
let dlq_missing = aws_smithy_mocks::mock!(SqsClient::get_queue_attributes)
.match_requests(|req| req.queue_url() == Some("https://sqs/main-dlq"))
.then_error(|| {
aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesError::generic(
aws_smithy_types::error::ErrorMetadata::builder()
.code("AWS.SimpleQueueService.NonExistentQueue")
.message("The specified queue does not exist")
.build(),
)
});
let events = aws_smithy_mocks::mock!(EbClient::describe_events).then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_events::DescribeEventsOutput::builder(
)
.build()
});
let health = aws_smithy_mocks::mock!(EbClient::describe_instances_health)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_instances_health::DescribeInstancesHealthOutput::builder().build()
});
let versions = aws_smithy_mocks::mock!(EbClient::describe_application_versions)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_application_versions::DescribeApplicationVersionsOutput::builder().build()
});
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[
&env_listing(),
&resources,
&settings,
&events,
&health,
&versions
]
);
let sqs = aws_smithy_mocks::mock_client!(
aws_sdk_sqs,
aws_smithy_mocks::RuleMode::MatchAny,
[&main_attrs, &dlq_missing]
);
let s = Server::with_injected_client(
WriteScope::None,
crate::config::Config::default(),
client_with(eb, sqs),
);
let out = s
.call_tool("why", &json!({"env": "poly-prod-wk"}))
.await
.expect("why answers");
let v: Value = serde_json::from_str(&out).expect("valid JSON");
let errors = v["errors"].as_array().expect("errors array");
assert!(
!errors.iter().any(|e| e["section"] == "dlq_peek"),
"an env with no dead-letter queue is ordinary — recording \
`dlq_peek` as a failure is triage noise in the array that \
exists to make partial answers load-bearing: {out}"
);
assert_eq!(
v["queues"]["peeked"], false,
"and we did not look, because there was nothing to look at: {out}"
);
}
#[tokio::test]
async fn a_dlq_delete_refuses_when_the_planned_message_is_gone() {
use aws_sdk_elasticbeanstalk::operation::describe_environment_resources::DescribeEnvironmentResourcesOutput;
use aws_sdk_elasticbeanstalk::types::{EnvironmentResourceDescription, Queue};
use aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesOutput;
use aws_sdk_sqs::operation::receive_message::ReceiveMessageOutput;
use aws_sdk_sqs::types::{Message, QueueAttributeName};
let resources = aws_smithy_mocks::mock!(EbClient::describe_environment_resources)
.then_output(|| {
DescribeEnvironmentResourcesOutput::builder()
.environment_resources(
EnvironmentResourceDescription::builder()
.queues(
Queue::builder()
.name("WorkerQueue")
.url("https://sqs/main")
.build(),
)
.queues(
Queue::builder()
.name("WorkerDeadLetterQueue")
.url("https://sqs/main-dlq")
.build(),
)
.build(),
)
.build()
});
let attrs =
aws_smithy_mocks::mock!(SqsClient::get_queue_attributes).then_output(|| {
GetQueueAttributesOutput::builder()
.attributes(QueueAttributeName::ApproximateNumberOfMessages, "2")
.build()
});
let seq = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seq2 = std::sync::Arc::clone(&seq);
let peek = aws_smithy_mocks::mock!(SqsClient::receive_message).then_output(move || {
let first = seq2.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
let id = if first { "m-1" } else { "m-2" };
ReceiveMessageOutput::builder()
.messages(
Message::builder()
.message_id(id)
.receipt_handle(format!("rh-{id}"))
.body("payload")
.build(),
)
.build()
});
let events = aws_smithy_mocks::mock!(EbClient::describe_events).then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_events::DescribeEventsOutput::builder(
)
.build()
});
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[&env_listing(), &resources, &events]
);
let sqs = aws_smithy_mocks::mock_client!(
aws_sdk_sqs,
aws_smithy_mocks::RuleMode::MatchAny,
[&attrs, &peek]
);
let s = Server::with_injected_client(
WriteScope::All,
crate::config::Config::default(),
client_with(eb, sqs),
);
let plan = s
.call_tool(
"dlq_delete",
&json!({"env": "poly-prod-wk", "message_id": "m-1"}),
)
.await
.expect("m-1 is present at plan time");
let token = plan
.split("\"confirm_token\":\"")
.nth(1)
.and_then(|r| r.split('"').next())
.expect("a token")
.to_string();
assert!(plan.contains("m-1"), "the plan names the message: {plan}");
let err = s
.call_tool("confirm_action", &json!({"confirm_token": token}))
.await
.expect_err("m-1 is gone by confirm time — this must refuse");
assert!(
err.contains("no longer in the dead-letter queue"),
"it must say the planned message is gone, not delete m-2 \
quietly: {err}"
);
assert!(
!err.contains("m-2"),
"and must not have touched the message that IS there: {err}"
);
}
}
#[test]
fn mcp_drift_discovery_starts_from_an_absolute_path() {
let src = std::fs::read_to_string("src/cli/mcp/tools.rs").expect("read source");
let prod = src.split("\n#[cfg(test)]\nmod ").next().unwrap_or_default();
assert!(
prod.contains("std::env::current_dir()"),
"drift discovery must start from the absolute cwd, or it \
cannot walk up at all"
);
assert!(
prod.contains("resolve_state_path("),
"the production slice is not finding the resolution"
);
}
#[test]
fn a_replaced_binary_is_stale_and_an_unreadable_one_is_not() {
assert!(
super::staleness(Some(100), Some(200)),
"a different inode is a different file — that is an upgrade"
);
assert!(
!super::staleness(Some(100), Some(100)),
"the same file is not stale, however often it is checked"
);
assert!(!super::staleness(None, Some(200)), "unknown start");
assert!(!super::staleness(Some(100), None), "unknown now");
assert!(!super::staleness(None, None), "unknown both");
}
#[test]
fn the_stale_notice_says_what_to_do_about_it() {
let n = super::stale_binary_notice("/opt/homebrew/bin/ebman");
assert!(
n.contains(env!("CARGO_PKG_VERSION")),
"it must name the version actually RUNNING — the cached \
instructions block cannot be refreshed mid-connection, so \
this is the only truthful version an agent sees: {n}"
);
assert!(n.contains("/opt/homebrew/bin/ebman"), "and where: {n}");
assert!(
n.contains("reconnect") || n.contains("Reconnect"),
"an agent cannot act on \"you are stale\" — it can act on \
\"reconnect\": {n}"
);
}
#[tokio::test]
async fn a_current_binary_adds_no_notice() {
let s = demo_server();
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"list_environments","arguments":{}}}),
)
.await
.expect("answers");
let text = resp["result"]["content"][0]["text"].as_str().expect("text");
assert!(
!text.contains("is running, but a different build"),
"this binary has not been replaced, so there is nothing to \
report: {text}"
);
assert!(text.starts_with('['), "still JSON: {text}");
}
#[tokio::test]
async fn a_stale_server_prepends_the_notice_to_its_answer() {
let dir = std::env::temp_dir().join(format!("ebman-exe-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let fake = dir.join("ebman");
std::fs::write(&fake, b"v1").expect("write");
let s = Server::with_scope(true, false, WriteScope::None).watching_exe(&fake);
let quiet = rpc(
&s,
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"list_environments","arguments":{}}}),
)
.await
.expect("answers");
assert!(
!quiet["result"]["content"][0]["text"]
.as_str()
.unwrap_or("")
.contains("a different build"),
"nothing has changed yet"
);
let replacement = dir.join("ebman.new");
std::fs::write(&replacement, b"v2").expect("write");
std::fs::rename(&replacement, &fake).expect("rename");
let loud = rpc(
&s,
json!({"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"list_environments","arguments":{}}}),
)
.await
.expect("answers");
let text = loud["result"]["content"][0]["text"].as_str().expect("text");
assert!(
text.contains("a different build is now installed"),
"the server must say the binary changed underneath it: {text}"
);
assert!(
text.contains(env!("CARGO_PKG_VERSION")),
"naming the version actually running, which is the only \
truthful one an agent sees once instructions are cached: {text}"
);
assert!(
text.contains('['),
"and the payload must survive the prepend: {text}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_lint_result_names_the_rules_it_could_not_check() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"lint","arguments":{}}}),
)
.await
.expect("lint answers");
let body = resp["result"]["content"][0]["text"].as_str().expect("text");
let v: Value = serde_json::from_str(body).expect("valid JSON");
let not_checked = v["rules_not_checked"].as_array().unwrap_or_else(|| {
panic!("every lint result must say what it could not check: {body}")
});
let joined = not_checked
.iter()
.filter_map(|x| x.as_str())
.collect::<Vec<_>>()
.join(" ");
assert!(
joined.contains("EBL011"),
"the worker-DLQ rule never fires here and the result must say \
so, not only the description: {body}"
);
assert!(
joined.contains("worker_queues"),
"and must point at the tool that DOES see queues — a caveat \
that points nowhere leaves the reader with \"lint says it is \
fine\": {body}"
);
assert!(v["issues"].is_array(), "{body}");
}
#[test]
fn every_result_that_can_be_partial_says_so() {
let prod = |path: &str| -> String {
std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("{path}: {e}"))
.split("\n#[cfg(test)]\nmod ")
.next()
.unwrap_or_default()
.to_string()
};
let src = format!(
"{}{}",
prod("src/cli/mcp/tools.rs"),
prod("src/cli/mcp/mod.rs")
);
assert!(
src.contains("pub(super) fn append_cannot_fire"),
"the production slice is not finding tools.rs"
);
for (field, what_it_bounds) in [
("rules_not_checked", "lint rules that cannot fire over MCP"),
("skipped_envs", "environments whose input fetch failed"),
("peeked", "whether a dead-letter queue was actually read"),
("complete", "whether a log window was fully consumed"),
("errors", "which sections of a `why` bundle failed"),
] {
assert!(
src.contains(field),
"`{field}` is gone — it bounded {what_it_bounds}, and \
without it that absence reads as an answer. See rule 6 \
in ARCHITECTURE.md before removing it."
);
}
}
#[tokio::test]
async fn a_scoped_grant_advertises_and_dispatches_only_its_verbs() {
let scope = WriteScope::Only(vec!["dlq_delete".into(), "dlq_resend".into()]);
let s = Server::with_config(true, false, scope, crate::config::Config::default());
let resp = rpc(&s, json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}))
.await
.expect("tools/list");
let names: Vec<String> = resp["result"]["tools"]
.as_array()
.expect("array")
.iter()
.filter_map(|t| t["name"].as_str().map(str::to_string))
.collect();
assert!(names.iter().any(|n| n == "dlq_delete"), "{names:?}");
assert!(names.iter().any(|n| n == "dlq_resend"), "{names:?}");
for ungranted in [
"terminate",
"deploy",
"restart",
"rebuild",
"set_option",
"dlq_purge",
] {
assert!(
!names.iter().any(|n| n == ungranted),
"`{ungranted}` was not granted and must not be advertised — a \
client that cannot see a tool does not plan around it: {names:?}"
);
}
assert!(names.iter().any(|n| n == "list_environments"), "{names:?}");
assert!(
names.iter().any(|n| n == "confirm_action"),
"a narrow grant must still be able to confirm what it planned: {names:?}"
);
let init = rpc(
&s,
json!({"jsonrpc":"2.0","id":0,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"t","version":"0"}}}),
)
.await
.expect("initialize");
let instructions = init["result"]["instructions"]
.as_str()
.expect("instructions are the only version/scope signal an agent is guaranteed");
assert!(
instructions.contains("dlq_delete") && instructions.contains("dlq_resend"),
"the instructions must name what WAS granted: {instructions}"
);
assert!(
instructions.contains("NOT GRANTED"),
"and say that the rest is withheld rather than missing: {instructions}"
);
let call = rpc(
&s,
json!({"jsonrpc":"2.0","id":9,"method":"tools/call",
"params":{"name":"terminate","arguments":{"env":"demo-prod"}}}),
)
.await
.expect("tools/call");
assert!(
call.get("error").is_none(),
"an ungranted verb is a tool refusal, not a protocol error: {call}"
);
let text = call["result"]["content"][0]["text"]
.as_str()
.expect("text content");
assert!(
text.contains("not in this server's write scope"),
"the refusal must name the scope: {text}"
);
assert!(
!text.contains("unknown tool"),
"and must not claim the tool does not exist: {text}"
);
assert_eq!(call["result"]["isError"], json!(true));
let bogus = rpc(
&s,
json!({"jsonrpc":"2.0","id":10,"method":"tools/call",
"params":{"name":"no_such_tool","arguments":{}}}),
)
.await
.expect("tools/call");
assert_eq!(bogus["error"]["code"], json!(-32602), "{bogus}");
let err = s
.tool_write_plan_for_tests(writes::WriteVerb::Terminate, &json!({"env": "x"}))
.await
.expect_err("terminate is outside the scope");
assert!(
err.contains("not in this server's write scope"),
"the refusal must name the scope, not read as a missing tool: {err}"
);
assert!(
err.contains("--allow-writes=terminate"),
"and say how to grant it: {err}"
);
let granted = s
.tool_write_plan_for_tests(writes::WriteVerb::DlqDelete, &json!({}))
.await;
if let Err(e) = &granted {
assert!(
!e.contains("not in this server's write scope"),
"`dlq_delete` was granted and must clear the scope gate \
(any later complaint about arguments is fine): {e}"
);
}
}
#[test]
fn a_mistyped_write_verb_is_refused_at_startup() {
let known = ["dlq_delete", "terminate"];
let err = super::parse_write_scope(Some("dlq_delte"), &known)
.expect_err("a typo must not be silently ignored");
assert!(err.contains("dlq_delte"), "name the typo: {err}");
assert!(err.contains("dlq_delete"), "and list what IS known: {err}");
assert_eq!(
super::parse_write_scope(None, &known).expect("bare is valid"),
WriteScope::All
);
assert!(super::parse_write_scope(Some(""), &known).is_err());
assert!(super::parse_write_scope(Some(" , ,"), &known).is_err());
assert_eq!(
super::parse_write_scope(Some("terminate, dlq_delete ,terminate"), &known)
.expect("valid"),
WriteScope::Only(vec!["terminate".into(), "dlq_delete".into()]),
"whitespace tolerated, duplicates collapsed, order kept"
);
}
#[test]
fn every_write_verb_round_trips_through_the_tool_table() {
let nameable = writes::write_verb_names();
assert_eq!(
nameable.len(),
writes::WriteVerb::ALL.len(),
"a write verb exists in one table and not the other: nameable={nameable:?}"
);
let advertised: Vec<String> = tool_table(&WriteScope::All, true)
.as_array()
.expect("array")
.iter()
.filter_map(|t| t["name"].as_str().map(str::to_string))
.collect();
for verb in writes::WriteVerb::ALL {
let name = verb.tool_name();
assert!(
nameable.iter().any(|n| n == name),
"`{name}` is what the dispatch gate checks but not a name \
--allow-writes accepts: {nameable:?}"
);
assert!(
advertised.contains(&name.to_string()),
"`{name}` is checkable but never advertised: {advertised:?}"
);
let only = WriteScope::Only(vec![name.to_string()]);
assert!(only.allows(name), "a grant of `{name}` must allow it");
for other in writes::WriteVerb::ALL {
if other.tool_name() != name {
assert!(
!only.allows(other.tool_name()),
"a grant of `{name}` must not allow `{}`",
other.tool_name()
);
}
}
}
}
#[test]
fn a_repeated_write_flag_cannot_silently_widen_the_grant() {
let args = |v: &[&str]| -> Vec<String> {
std::iter::once("mcp")
.chain(std::iter::once("serve"))
.chain(v.iter().copied())
.map(str::to_string)
.collect()
};
let err = parse_mcp_args(&args(&["--allow-writes=dlq_delete", "--allow-writes"]))
.expect_err("a second flag must not widen the first");
assert!(
err.contains("more than once"),
"the error must name the duplication: {err}"
);
assert!(
err.contains("--allow-writes=a,b"),
"and say what to do instead: {err}"
);
assert!(
!err.contains(" "),
"a wrapped literal left a gap in the rendered message: {err:?}"
);
assert!(parse_mcp_args(&args(&["--allow-writes", "--allow-writes=dlq_delete"])).is_err());
let ok = parse_mcp_args(&args(&["--allow-writes=dlq_delete"])).expect("one flag is valid");
assert_eq!(ok.write_scope, WriteScope::Only(vec!["dlq_delete".into()]));
}
#[test]
fn an_empty_grant_is_not_a_grant() {
let empty = WriteScope::Only(Vec::new());
assert!(
!empty.any(),
"an empty grant must not read as write-capable"
);
let names: Vec<String> = tool_table(&empty, true)
.as_array()
.expect("array")
.iter()
.filter_map(|t| t["name"].as_str().map(str::to_string))
.collect();
assert!(
!names.iter().any(|n| n == "confirm_action"),
"nothing to confirm, so nothing should offer to confirm it: {names:?}"
);
}
#[test]
fn only_a_live_write_capable_server_reads_the_audit_config() {
let narrow = WriteScope::Only(vec!["dlq_delete".into()]);
assert!(should_init_audit(&WriteScope::All, false));
assert!(
should_init_audit(&narrow, false),
"a narrow grant still dispatches writes, so it still audits"
);
assert!(
!should_init_audit(&WriteScope::None, false),
"a reads-only server must not touch the config disk"
);
assert!(
!should_init_audit(&WriteScope::All, true),
"demo must not acquire a webhook — `||` here would give it one"
);
assert!(
!should_init_audit(&WriteScope::None, true),
"and neither half alone is enough — dropping the `!` inverts this"
);
}
#[test]
fn only_the_mcp_daemon_opens_a_log_file() {
let a = |v: &[&str]| -> Vec<String> { v.iter().map(|s| s.to_string()).collect() };
assert!(wants_file_logging(&a(&["mcp", "serve"])));
assert!(wants_file_logging(&a(&[
"mcp",
"serve",
"--demo",
"--allow-writes"
])));
assert!(
!wants_file_logging(&a(&["mcp", "setup"])),
"setup is a pure printer that promises it writes no files"
);
assert!(!wants_file_logging(&a(&["mcp", "setup", "--allow-writes"])));
assert!(!wants_file_logging(&a(&["mcp"])), "a bare usage error");
}
}