use std::path::{Path, PathBuf};
use anyhow::Context as _;
use clap::builder::TypedValueParser as _;
use clap::{Parser, ValueEnum};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Transport {
Http,
Stdio,
}
#[derive(Parser)]
#[command(name = "bugwarden", version, about)]
pub struct Cli {
#[arg(long, env = "BUGZILLA_SERVER")]
pub bugzilla_server: String,
#[arg(long, env = "MCP_TRANSPORT", value_enum, default_value = "http")]
pub transport: Transport,
#[arg(long, env = "MCP_HOST", default_value = "127.0.0.1")]
pub host: String,
#[arg(long, env = "MCP_PORT", default_value_t = 8000)]
pub port: u16,
#[arg(long, env = "MCP_ALLOWED_HOSTS", value_name = "HOST")]
pub allowed_hosts: Vec<String>,
#[arg(long, env = "MCP_API_KEY_HEADER", default_value = "ApiKey")]
pub api_key_header: String,
#[arg(long, env = "BUGZILLA_API_KEY", hide_env_values = true)]
pub api_key: Option<String>,
#[arg(
long,
env = "BUGZILLA_API_KEY_FILE",
value_hint = clap::ValueHint::FilePath,
value_parser = clap::builder::OsStringValueParser::new().map(PathBuf::from)
)]
pub api_key_file: Option<PathBuf>,
#[arg(long, env = "BUGZILLA_USE_AUTH_HEADER")]
pub use_auth_header: bool,
#[arg(long, env = "MCP_READ_ONLY")]
pub read_only: bool,
#[arg(long, env = "BUGWARDEN_POLICY", value_hint = clap::ValueHint::FilePath)]
pub policy: Option<PathBuf>,
#[arg(long, env = "BUGWARDEN_AUDIT_CONFIG", value_hint = clap::ValueHint::FilePath)]
pub audit_config: Option<PathBuf>,
#[arg(long)]
pub insecure_no_auth: bool,
}
impl std::fmt::Debug for Cli {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Cli")
.field("bugzilla_server", &self.bugzilla_server)
.field("transport", &self.transport)
.field("host", &self.host)
.field("port", &self.port)
.field("allowed_hosts", &self.allowed_hosts)
.field("api_key_header", &self.api_key_header)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("api_key_file", &self.api_key_file)
.field("use_auth_header", &self.use_auth_header)
.field("read_only", &self.read_only)
.field("policy", &self.policy)
.field("audit_config", &self.audit_config)
.field("insecure_no_auth", &self.insecure_no_auth)
.finish()
}
}
pub fn command() -> clap::Command {
use clap::CommandFactory as _;
Cli::command()
}
#[derive(Clone)]
pub enum KeyCustody {
Server(String),
PerRequest,
}
impl Cli {
pub fn resolved_allowed_hosts(&self) -> Vec<&str> {
self.allowed_hosts
.iter()
.flat_map(|entry| entry.split(','))
.map(str::trim)
.filter(|host| !host.is_empty())
.collect()
}
pub fn checked_allowed_hosts(&self) -> anyhow::Result<Option<Vec<&str>>> {
let hosts = self.resolved_allowed_hosts();
if hosts.is_empty() {
return Ok(None);
}
let bad: Vec<&str> = hosts
.iter()
.copied()
.filter(|host| !is_matchable_host_authority(host))
.collect();
if !bad.is_empty() {
anyhow::bail!(
"--allowed-hosts / MCP_ALLOWED_HOSTS contains entries that are \
not a hostname or host:port authority: {bad:?}"
);
}
Ok(Some(hosts))
}
pub fn resolve_key_custody(&self) -> anyhow::Result<KeyCustody> {
let startup_key = self.api_key.as_deref().filter(|k| !k.is_empty());
let key_file = self
.api_key_file
.as_deref()
.filter(|p| !p.as_os_str().is_empty());
if startup_key.is_some() && key_file.is_some() {
anyhow::bail!(
"--api-key (BUGZILLA_API_KEY) and --api-key-file (BUGZILLA_API_KEY_FILE) \
are mutually exclusive; pass exactly one"
);
}
if let Some(path) = key_file {
let key = read_key_file(path)?;
match self.transport {
Transport::Stdio => tracing::info!("API key: startup key (stdio)"),
Transport::Http => tracing::info!(
"API key: server-held (from {}); the per-request '{}' header \
is not consulted",
path.display(),
self.api_key_header
),
}
return Ok(KeyCustody::Server(key));
}
match self.transport {
Transport::Stdio => match startup_key {
Some(key) => {
tracing::info!("API key: startup key (stdio)");
Ok(KeyCustody::Server(key.to_owned()))
}
None => anyhow::bail!(
"--transport stdio requires --api-key or the BUGZILLA_API_KEY environment variable"
),
},
Transport::Http => {
if self.api_key.is_some() {
tracing::warn!(
"--api-key / BUGZILLA_API_KEY is set but ignored with --transport http \
(clients send the key per-request via the API key header; use \
--api-key-file for a server-held key). Unset it to clean the config."
);
}
tracing::info!(
"API key: per-request via the '{}' header",
self.api_key_header
);
Ok(KeyCustody::PerRequest)
}
}
}
}
fn is_matchable_host_authority(entry: &str) -> bool {
if let Ok(auth) = http::uri::Authority::try_from(entry) {
return is_matchable_parsed_authority(entry, &auth);
}
if entry.parse::<std::net::Ipv6Addr>().is_err() {
return false;
}
let wrapped = format!("[{entry}]");
http::uri::Authority::try_from(wrapped.as_str()).is_ok()
}
fn is_matchable_parsed_authority(entry: &str, auth: &http::uri::Authority) -> bool {
if entry.contains('@') {
return false;
}
let host = auth.host();
if entry.len() > host.len()
&& entry.as_bytes().get(host.len()) == Some(&b':')
&& auth.port_u16().is_none()
{
return false;
}
let host = host
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host);
host.parse::<std::net::Ipv6Addr>().is_ok() || is_dns_or_ipv4_host(host)
}
fn is_dns_or_ipv4_host(host: &str) -> bool {
!host.is_empty()
&& host != "*"
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'))
&& host.bytes().any(|b| b.is_ascii_alphanumeric())
}
fn read_key_file(path: &Path) -> anyhow::Result<String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
if let Ok(meta) = std::fs::metadata(path) {
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
tracing::warn!(
path = %path.display(),
mode = format!("{:o}", mode & 0o777),
"API key file is accessible by group or others; \
restrict it to 0600"
);
}
}
}
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read the API key file {}", path.display()))?;
let key = raw.trim();
if key.is_empty() {
anyhow::bail!("the API key file {} is empty", path.display());
}
Ok(key.to_owned())
}
#[cfg(test)]
mod tests {
use std::io::Write as _;
use clap::Parser as _;
use super::*;
fn base_cli(transport: &str) -> Cli {
let mut cli = Cli::parse_from([
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--transport",
transport,
]);
cli.api_key = None;
cli.api_key_file = None;
cli
}
fn key_file(content: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::NamedTempFile::new().expect("temp key file");
file.write_all(content.as_bytes()).expect("write key file");
file
}
fn server_key(custody: KeyCustody) -> String {
match custody {
KeyCustody::Server(key) => key,
KeyCustody::PerRequest => panic!("expected the server-held key"),
}
}
fn resolve_err(cli: &Cli) -> anyhow::Error {
match cli.resolve_key_custody() {
Err(e) => e,
Ok(_) => panic!("key custody resolution must fail"),
}
}
#[test]
fn both_key_sources_are_a_startup_error_naming_both_flags() {
for transport in ["stdio", "http"] {
let file = key_file("file-key\n");
let mut cli = base_cli(transport);
cli.api_key = Some("flag-key".into());
cli.api_key_file = Some(file.path().to_path_buf());
let msg = format!("{:#}", resolve_err(&cli));
assert!(
msg.contains("--api-key (BUGZILLA_API_KEY)"),
"unexpected error: {msg}"
);
assert!(
msg.contains("--api-key-file (BUGZILLA_API_KEY_FILE)"),
"unexpected error: {msg}"
);
}
}
#[test]
fn stdio_without_any_key_source_fails_at_resolution() {
let cli = base_cli("stdio");
let msg = format!("{:#}", resolve_err(&cli));
assert!(msg.contains("--transport stdio requires"), "{msg}");
let mut cli = base_cli("stdio");
cli.api_key = Some(String::new());
assert!(cli.resolve_key_custody().is_err());
}
#[test]
fn empty_or_whitespace_key_file_is_an_error_naming_the_path() {
for content in ["", " \n\t \n"] {
let file = key_file(content);
let mut cli = base_cli("http");
cli.api_key_file = Some(file.path().to_path_buf());
let msg = format!("{:#}", resolve_err(&cli));
assert!(
msg.contains(&file.path().display().to_string()),
"the error must name the path: {msg}"
);
}
}
#[test]
fn key_file_content_is_trimmed() {
let file = key_file("the-key\n");
let mut cli = base_cli("http");
cli.api_key_file = Some(file.path().to_path_buf());
let custody = cli.resolve_key_custody().expect("key file resolves");
assert_eq!(server_key(custody), "the-key");
}
#[test]
fn unreadable_key_file_is_an_error_naming_the_path() {
let mut cli = base_cli("http");
cli.api_key_file = Some(PathBuf::from("/nonexistent/bugwarden-key"));
let msg = format!("{:#}", resolve_err(&cli));
assert!(msg.contains("/nonexistent/bugwarden-key"), "{msg}");
}
#[test]
fn stdio_takes_the_key_from_the_file_too() {
let file = key_file("stdio-file-key\n");
let mut cli = base_cli("stdio");
cli.api_key_file = Some(file.path().to_path_buf());
let custody = cli.resolve_key_custody().expect("key file resolves");
assert_eq!(server_key(custody), "stdio-file-key");
}
#[test]
fn http_with_startup_key_alone_stays_per_request() {
let mut cli = base_cli("http");
cli.api_key = Some("flag-key".into());
let custody = cli.resolve_key_custody().expect("http resolves");
assert!(
matches!(custody, KeyCustody::PerRequest),
"http + --api-key must stay per-request"
);
}
#[test]
fn stdio_startup_key_is_server_custody() {
let mut cli = base_cli("stdio");
cli.api_key = Some("startup-key".into());
let custody = cli.resolve_key_custody().expect("stdio resolves");
assert_eq!(server_key(custody), "startup-key");
}
#[test]
fn empty_api_key_file_counts_as_absent() {
let mut cli = Cli::parse_from([
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--transport",
"http",
"--api-key-file",
"",
]);
cli.api_key = None;
let custody = cli.resolve_key_custody().expect("http resolves");
assert!(
matches!(custody, KeyCustody::PerRequest),
"an empty key file path must not select server-held mode"
);
let mut cli = base_cli("stdio");
cli.api_key_file = Some(PathBuf::new());
let msg = format!("{:#}", resolve_err(&cli));
assert!(msg.contains("--transport stdio requires"), "{msg}");
}
const ENV_LESS_BY_DESIGN: &[&str] = &["insecure_no_auth"];
#[test]
fn every_flag_declares_an_environment_fallback() {
let mut cmd = command();
cmd.build();
let env_less: Vec<String> = cmd
.get_arguments()
.filter(|arg| !matches!(arg.get_id().as_str(), "help" | "version"))
.filter(|arg| !ENV_LESS_BY_DESIGN.contains(&arg.get_id().as_str()))
.filter(|arg| arg.get_env().is_none())
.map(|arg| arg.get_id().to_string())
.collect();
assert!(
env_less.is_empty(),
"every flag needs an environment fallback, these have none: {env_less:?}"
);
for id in ENV_LESS_BY_DESIGN {
let arg = cmd
.get_arguments()
.find(|arg| arg.get_id().as_str() == *id)
.unwrap_or_else(|| panic!("{id} is exempted but does not exist"));
assert!(
arg.get_env().is_none(),
"{id} has an environment fallback and must leave the exception list"
);
}
let env_of = |id: &str| {
cmd.get_arguments()
.find(|arg| arg.get_id().as_str() == id)
.and_then(clap::Arg::get_env)
.map(|env| env.to_string_lossy().into_owned())
};
assert_eq!(
env_of("allowed_hosts").as_deref(),
Some("MCP_ALLOWED_HOSTS")
);
assert_eq!(
env_of("use_auth_header").as_deref(),
Some("BUGZILLA_USE_AUTH_HEADER")
);
}
#[test]
fn allowed_hosts_split_on_commas_dropping_empties() {
let cli = Cli::parse_from([
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--allowed-hosts",
"a.example:8000, b.example",
"--allowed-hosts",
" c.example ",
"--allowed-hosts",
"",
]);
assert_eq!(
cli.resolved_allowed_hosts(),
["a.example:8000", "b.example", "c.example"]
);
let cli = Cli::parse_from([
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--allowed-hosts",
" ,, ",
]);
assert!(
cli.resolved_allowed_hosts().is_empty(),
"an entry naming no host must leave Host validation off"
);
assert!(
cli.checked_allowed_hosts()
.expect("empty is unset, not an error")
.is_none(),
"MCP_ALLOWED_HOSTS= must leave Host validation off"
);
}
#[test]
fn a_space_containing_allowed_hosts_entry_stays_one_unparsable_authority() {
let cli = Cli::parse_from([
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--allowed-hosts",
"a b.example",
]);
assert_eq!(cli.resolved_allowed_hosts(), ["a b.example"]);
let err = cli
.checked_allowed_hosts()
.expect_err("a space-containing typo is a startup error");
let msg = format!("{err:#}");
assert!(
msg.contains("a b.example"),
"the error must name the entry: {msg}"
);
}
#[test]
fn unparsable_allowed_hosts_are_a_startup_error_named_in_the_message() {
for entry in [
"*",
"http://a.example",
"a;b",
"foo%2cbar",
"\u{200b}example.com",
"a b.example",
] {
let mut cli = base_cli("http");
cli.allowed_hosts = vec![entry.to_owned()];
let err = cli
.checked_allowed_hosts()
.expect_err("unparsable Host authorities are a startup error");
let msg = format!("{err:#}");
assert!(
msg.contains("--allowed-hosts"),
"the error must name the flag: {msg}"
);
assert!(
msg.contains(&format!("{entry:?}")) || msg.contains(entry),
"the error must name the entry {entry:?}: {msg}"
);
}
let mut cli = base_cli("http");
cli.allowed_hosts = vec!["good.example".into(), "*".into()];
let err = cli
.checked_allowed_hosts()
.expect_err("a mixed list must not drop the unparsable entry");
assert!(format!("{err:#}").contains('*'), "{err:#}");
let mut cli = base_cli("http");
cli.allowed_hosts = vec![
"localhost".into(),
"127.0.0.1:8000".into(),
"[::1]:8000".into(),
"mcp.example.org".into(),
];
assert_eq!(
cli.checked_allowed_hosts().expect("matchable hosts"),
Some(vec![
"localhost",
"127.0.0.1:8000",
"[::1]:8000",
"mcp.example.org",
])
);
}
#[test]
fn unbracketed_ipv6_loopback_is_a_matchable_allowed_host() {
for entry in ["::1", "[::1]", "[::1]:8000", "2001:db8::1"] {
let mut cli = base_cli("http");
cli.allowed_hosts = vec![entry.to_owned()];
assert_eq!(
cli.checked_allowed_hosts()
.unwrap_or_else(|e| panic!("{entry} must be matchable: {e:#}")),
Some(vec![entry]),
"{entry}"
);
}
let mut cli = base_cli("http");
cli.allowed_hosts = vec!["localhost,127.0.0.1,::1".into()];
assert_eq!(
cli.checked_allowed_hosts()
.expect("rmcp's loopback set must stay one valid list"),
Some(vec!["localhost", "127.0.0.1", "::1"])
);
}
#[test]
fn cli_debug_never_prints_the_startup_key_i12() {
let mut cli = base_cli("stdio");
cli.api_key = Some("SUPERSECRETKEY123".into());
let printed = format!("{cli:?}");
assert!(
!printed.contains("SUPERSECRETKEY123"),
"Debug must redact the key: {printed}"
);
assert!(printed.contains("<redacted>"), "{printed}");
}
#[test]
fn startup_log_states_mode_and_source_never_key_material_i12() {
let file = key_file("hush-key\n");
let mut cli = base_cli("http");
cli.api_key_file = Some(file.path().to_path_buf());
let (custody, logs) = crate::testlog::capture_logs(|| cli.resolve_key_custody());
drop(custody.expect("key file resolves"));
crate::testlog::assert_logged(&logs, "server-held");
crate::testlog::assert_logged(&logs, &file.path().display().to_string());
logs.assert_not_contains("hush-key");
let cli = base_cli("http");
let (custody, logs) = crate::testlog::capture_logs(|| cli.resolve_key_custody());
drop(custody.expect("http resolves"));
crate::testlog::assert_logged(&logs, "per-request via the 'ApiKey' header");
}
}