use super::WriteScope;
use color_eyre::eyre::Result;
const SETUP_USAGE: &str = "usage: ebman mcp setup [--allow-writes[=verb,verb]]";
fn wrapped_verbs(verbs: &[String], width: usize) -> String {
let mut lines: Vec<String> = Vec::new();
let mut cur = String::new();
for (i, v) in verbs.iter().enumerate() {
let piece = if i + 1 == verbs.len() {
v.clone()
} else {
format!("{v},")
};
if !cur.is_empty() && cur.chars().count() + 1 + piece.chars().count() > width {
lines.push(std::mem::take(&mut cur));
}
if cur.is_empty() {
cur = piece;
} else {
cur.push(' ');
cur.push_str(&piece);
}
}
if !cur.is_empty() {
lines.push(cur);
}
lines
.iter()
.map(|l| format!(" {l}\n"))
.collect::<Vec<_>>()
.join("")
}
pub(super) fn render(scope: &WriteScope) -> String {
let flag = match scope {
WriteScope::None => String::new(),
WriteScope::All => " --allow-writes".into(),
WriteScope::Only(v) => format!(" --allow-writes={}", v.join(",")),
};
let serve = format!("ebman mcp serve{flag}");
let json_args = match scope {
WriteScope::None => "[\"mcp\", \"serve\"]".to_string(),
WriteScope::All => "[\"mcp\", \"serve\", \"--allow-writes\"]".to_string(),
WriteScope::Only(v) => format!("[\"mcp\", \"serve\", \"--allow-writes={}\"]", v.join(",")),
};
let mut s = String::new();
s.push_str("Wire ebman into your coding agent over MCP.\n");
s.push_str("ebman is already installed locally, so every command below is\n");
s.push_str("local and inspectable — nothing is fetched or auto-executed.\n\n");
s.push_str("Claude Code:\n");
s.push_str(&format!(" claude mcp add ebman -- {serve}\n\n"));
s.push_str("Any other MCP client — register a stdio server that runs the\n");
s.push_str("command below. As a project-scoped .mcp.json:\n\n");
s.push_str(" {\n");
s.push_str(" \"mcpServers\": {\n");
s.push_str(&format!(
" \"ebman\": {{ \"command\": \"ebman\", \"args\": {json_args} }}\n"
));
s.push_str(" }\n");
s.push_str(" }\n\n");
match scope {
WriteScope::All => {
s.push_str("Writes are ON for every verb, each two-phase (a plan, then an\n");
s.push_str("explicit confirm) and behind the same pins / read-only / incident\n");
s.push_str("freeze as the TUI. Every dispatch is audit-logged. The verbs:\n\n");
s.push_str(&wrapped_verbs(&super::writes::write_verb_names(), 62));
s.push('\n');
s.push_str("To grant less, name the verbs you need:\n");
s.push_str(" ebman mcp setup --allow-writes=dlq_resend,dlq_delete\n\n");
}
WriteScope::Only(v) => {
s.push_str("Writes are ON for these verbs ONLY. Every other write verb is\n");
s.push_str("neither advertised nor dispatchable by this server. Each is\n");
s.push_str("two-phase (a plan, then an explicit confirm), behind the same\n");
s.push_str("pins / read-only / incident freeze as the TUI, and audit-logged.\n\n");
s.push_str(&wrapped_verbs(v, 62));
s.push('\n');
}
WriteScope::None => {
s.push_str("Reads only by default (list_environments, lint, drift, cost, …).\n");
s.push_str("Re-run with --allow-writes for the opt-in two-phase write tools,\n");
s.push_str("or name just the ones you want:\n");
s.push_str(" ebman mcp setup --allow-writes=dlq_resend,dlq_delete\n\n");
s.push_str("The verbs you can name:\n\n");
s.push_str(&wrapped_verbs(&super::writes::write_verb_names(), 62));
s.push('\n');
}
}
s.push_str("If your shell exports AWS_REGION, pin it at registration — the\n");
s.push_str("server takes the environment's region, not any project's:\n");
s.push_str(&format!(
" claude mcp add ebman --env AWS_REGION=eu-west-1 -- {serve}\n\n"
));
s.push_str("Full tool list and the writes contract: docs/headless.md (MCP section).\n");
s
}
pub(super) fn run(args: &[String]) -> Result<()> {
let known: Vec<String> = super::writes::write_verb_names();
let known_refs: Vec<&str> = known.iter().map(String::as_str).collect();
let mut scope = WriteScope::None;
let mut saw_write_flag = false;
for arg in args.iter().skip(2) {
if let Some(rest) = arg.strip_prefix("--allow-writes") {
if saw_write_flag {
eprintln!(
"ebman mcp setup: --allow-writes given more than once — a second \
one would silently widen the first. Name every verb in one flag: \
--allow-writes=a,b"
);
std::process::exit(2);
}
saw_write_flag = true;
let value = match rest {
"" => None,
v => match v.strip_prefix('=') {
Some(v) => Some(v),
None => {
eprintln!("ebman mcp setup: unknown flag '{arg}' — {SETUP_USAGE}");
std::process::exit(2);
}
},
};
match super::parse_write_scope(value, &known_refs) {
Ok(s) => scope = s,
Err(e) => {
eprintln!("ebman mcp setup: {e}");
std::process::exit(2);
}
}
continue;
}
eprintln!("ebman mcp setup: unknown flag '{arg}' — {SETUP_USAGE}");
std::process::exit(2);
}
print!("{}", render(&scope));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_only_by_default() {
let s = render(&WriteScope::None);
assert!(s.contains("claude mcp add ebman -- ebman mcp serve\n"));
assert!(s.contains("Reads only by default"));
assert!(s.contains("\"args\": [\"mcp\", \"serve\"]"));
assert!(!s.contains("serve --allow-writes"));
}
#[test]
fn allow_writes_switches_command_json_and_note() {
let s = render(&WriteScope::All);
assert!(s.contains("claude mcp add ebman -- ebman mcp serve --allow-writes"));
assert!(s.contains("\"args\": [\"mcp\", \"serve\", \"--allow-writes\"]"));
assert!(s.contains("Writes are ON"));
}
fn all_scopes() -> Vec<WriteScope> {
vec![
WriteScope::None,
WriteScope::All,
WriteScope::Only(vec!["dlq_delete".into(), "dlq_resend".into()]),
]
}
#[test]
fn never_instructs_a_remote_fetch_or_auto_execute() {
for scope in all_scopes() {
let lower = render(&scope).to_lowercase();
assert!(!lower.contains("http"), "no URLs / remote fetch");
assert!(!lower.contains("follow it"), "no fetch-and-obey framing");
assert!(!lower.contains("curl"), "no piped-remote-script install");
}
}
#[test]
fn region_pinning_is_documented() {
assert!(render(&WriteScope::None).contains("AWS_REGION=eu-west-1"));
}
#[test]
fn setup_never_asks_anyone_to_fetch_and_run() {
for scope in all_scopes() {
let s = render(&scope);
for pattern in [
"curl",
"wget",
"| sh",
"|sh",
"| bash",
"|bash",
"iwr",
"Invoke-WebRequest",
"source <(",
"eval $(",
"http://",
"https://",
] {
assert!(
!s.contains(pattern),
"`mcp setup` output contains {pattern:?}, which breaks its own \
promise that nothing is fetched or auto-executed \
(scope={scope:?}):\n{s}"
);
}
assert!(
s.contains("nothing is fetched or auto-executed"),
"the promise must be stated, not merely kept: {s}"
);
assert!(
s.contains("already installed locally"),
"and the reason it holds — the binary is already here: {s}"
);
}
}
#[test]
fn the_fetch_and_run_scan_can_see_one() {
let bad = "install with: curl https://example.test/i.sh | sh";
assert!(bad.contains("curl"), "detector sees the fetcher");
assert!(bad.contains("https://"), "detector sees the URL");
assert!(bad.contains("| sh"), "detector sees the pipe-to-shell");
let good = "claude mcp add ebman -- ebman mcp serve";
for pattern in ["curl", "wget", "| sh", "https://"] {
assert!(
!good.contains(pattern),
"the legitimate form must not trip the detector"
);
}
}
#[test]
fn a_narrow_grant_is_narrow_in_both_the_command_and_the_json() {
let scope = WriteScope::Only(vec!["dlq_resend".into(), "dlq_delete".into()]);
let s = render(&scope);
assert!(
s.contains("ebman mcp serve --allow-writes=dlq_resend,dlq_delete"),
"the headline command must carry the scope: {s}"
);
assert!(
s.contains("\"--allow-writes=dlq_resend,dlq_delete\""),
"and so must the .mcp.json args, which is what actually runs: {s}"
);
assert!(
!s.contains("\"--allow-writes\""),
"the bare flag would silently widen the grant to everything: {s}"
);
assert!(
s.contains("ONLY"),
"and the prose must say the rest is unavailable: {s}"
);
for ungranted in ["terminate", "set_option"] {
assert!(
!s.contains(ungranted),
"`{ungranted}` was not granted and must not be offered: {s}"
);
}
}
#[test]
fn the_full_grant_lists_every_verb_the_server_advertises() {
let s = render(&WriteScope::All);
for verb in super::super::writes::write_verb_names() {
assert!(
s.contains(&verb),
"`{verb}` is advertised by the server but missing from setup: {s}"
);
}
}
#[test]
fn wrapped_verbs_wraps_without_splitting_a_verb() {
let verbs: Vec<String> = ["deploy", "restart", "rebuild", "terminate", "set_option"]
.iter()
.map(|s| s.to_string())
.collect();
let out = wrapped_verbs(&verbs, 24);
for line in out.lines() {
assert!(
line.chars().count() <= 26,
"line over the budget (2 indent + 24): {line:?}"
);
}
let flat = out.replace('\n', " ");
for v in &verbs {
assert_eq!(flat.matches(v.as_str()).count(), 1, "{v} in {flat:?}");
}
assert!(
!flat.trim_end().ends_with(','),
"no trailing comma: {flat:?}"
);
assert!(out.ends_with('\n'), "each line is terminated: {out:?}");
assert!(
out.lines().all(|l| l.starts_with(" ")),
"every line is indented: {out:?}"
);
assert!(
out.lines().count() > 1,
"this list must have wrapped: {out:?}"
);
let tight = wrapped_verbs(&verbs, 1);
assert_eq!(tight.lines().count(), verbs.len(), "{tight:?}");
let pair: Vec<String> = ["ab", "cd"].iter().map(|s| s.to_string()).collect();
assert_eq!(
wrapped_verbs(&pair, 6),
" ab, cd\n",
"`ab, cd` is exactly 6 wide and must stay on one line"
);
assert_eq!(
wrapped_verbs(&pair, 5),
" ab,\n cd\n",
"one narrower, and it must wrap"
);
assert_eq!(wrapped_verbs(&verbs[..1], 62), " deploy\n");
assert_eq!(wrapped_verbs(&[], 62), "");
}
}