use std::sync::atomic::Ordering;
use std::sync::{Arc, OnceLock};
use anyhow::Context;
use bugwarden::audit::{
policy_hash_of, select_sinks, AuditConfig, AuditSink, AuditState, FailMode, SinkSelection,
TransportKind,
};
use bugwarden::http_auth::{self, HttpEnv};
use bugwarden::http_session::AuditedSessionManager;
use bugwarden::otel::{self, OtelEnv, Pipeline};
use bugwarden::stdio::DiscoverAnswering;
use bugwarden::tracing_fields::CappedFields;
use bugwarden::{config, server};
use bugwarden_core::{guard::Guard, policy::Policy};
use clap::Parser;
use rmcp::{transport::streamable_http_server::StreamableHttpService, ServiceExt};
use tracing_subscriber::layer::SubscriberExt as _;
use tracing_subscriber::util::SubscriberInitExt as _;
use tracing_subscriber::EnvFilter;
use config::{Cli, Transport};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let otel_config = otel::resolve(&OtelEnv::from_env())?;
let otel_slot: Arc<OnceLock<Arc<Pipeline>>> = Arc::new(OnceLock::new());
let registry = tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(
tracing_subscriber::fmt::layer()
.fmt_fields(CappedFields)
.with_writer(std::io::stderr)
.with_ansi(false),
);
if otel_config.is_some() {
registry
.with(Pipeline::diagnostics_layer(otel_slot.clone()))
.init();
} else {
registry.init();
}
bugwarden::panic_hook::install();
let http_auth =
http_auth::resolve_for(cli.transport, &HttpEnv::from_env(), cli.insecure_no_auth)
.map(|auth| auth.map(Arc::new))?;
if let Some(auth) = &http_auth {
auth.log_startup_mode();
}
let sinks = select_sinks(cli.audit_config.as_deref(), otel_config.is_some())?;
let mut policy = match &cli.policy {
Some(path) => Policy::load(path)
.with_context(|| format!("failed to load guard policy from {}", path.display()))?,
None => Policy::default(),
};
policy.global.read_only |= cli.read_only;
let bz = Arc::new(server::bugzilla_client(&cli)?);
let guard = Arc::new(Guard { policy });
let cfg = Arc::new(cli);
let server =
server::BugWarden::new(cfg.clone(), guard, bz).context("failed to build the MCP server")?;
server.preflight().await?;
let otel = match otel_config {
Some(otel_cfg) => Some(Arc::new(Pipeline::start(otel_cfg)?)),
None => None,
};
if let Some(pipeline) = &otel {
let _ = otel_slot.set(pipeline.clone());
pipeline.probe().await?;
tracing::info!(
"OTLP export enabled: audit records are exported to the configured \
collector and gate serving when delivery fails; diagnostics are \
copied best-effort"
);
}
let audit_sink = match sinks {
SinkSelection::NoAudit => None,
SinkSelection::FileOnly | SinkSelection::Both | SinkSelection::OtlpOnly => {
let audit_cfg = match sinks {
SinkSelection::FileOnly | SinkSelection::Both => {
match cfg.audit_config.as_deref() {
Some(path) => AuditConfig::load(path)?,
None => anyhow::bail!(
"internal error: a file-bearing sink selection has no path"
),
}
}
_ => AuditConfig::fileless(),
};
let transport = match cfg.transport {
Transport::Stdio => TransportKind::Stdio,
Transport::Http => TransportKind::Http,
};
let fail_mode = FailMode::resolve(audit_cfg.fail_mode, transport);
let policy_hash = match &cfg.policy {
Some(policy_path) => {
let bytes = std::fs::read(policy_path).with_context(|| {
format!(
"failed to read guard policy from {} for the audit digest",
policy_path.display()
)
})?;
Some(policy_hash_of(&bytes))
}
None => None,
};
let sink = AuditSink::open(audit_cfg).context("failed to open the audit sink")?;
Some((sink, fail_mode, policy_hash))
}
};
let audit = audit_sink.map(|(sink, fail_mode, policy_hash)| {
let sink = match &otel {
Some(pipeline) => sink.with_export(pipeline.audit_exporter()),
None => sink,
};
Arc::new(AuditState::new(sink, fail_mode, policy_hash))
});
if audit.is_none() && cfg.transport == Transport::Http {
tracing::warn!(
"auditing is OFF: remote tool calls over http will leave no audit \
record — pass --audit-config / BUGWARDEN_AUDIT_CONFIG for a file, \
or set OTEL_EXPORTER_OTLP_ENDPOINT with BUGWARDEN_AUDIT_CONFIG=none \
for a collector-only trail"
);
}
let server = match audit {
Some(audit) => server.with_audit(audit),
None => server,
};
let server =
server.with_scope_enforcement(http_auth.as_ref().is_some_and(|auth| !auth.is_insecure()));
let otel_on_signal = otel.clone();
let served: anyhow::Result<()> = async move {
match cfg.transport {
Transport::Stdio => {
let shutdown = shutdown_signal();
tokio::pin!(shutdown);
let (stdin, stdout) = server.stdio_transport();
let over_cap = stdin.over_cap();
let transport = DiscoverAnswering::framed(stdin, stdout, server.clone());
let probed = transport.answered();
tracing::info!("Starting Bugzilla MCP server on stdio");
let service = tokio::select! {
result = server.serve(transport) => match result {
Ok(service) => service,
Err(rmcp::service::ServerInitializeError::ConnectionClosed(_))
if probed.load(Ordering::Acquire)
&& !over_cap.load(Ordering::Acquire) =>
{
tracing::info!("peer hung up after a server/discover probe");
return Ok(());
}
Err(rmcp::service::ServerInitializeError::ConnectionClosed(_))
if over_cap.load(Ordering::Acquire) =>
{
tracing::error!("serving error: {OVER_CAP_CLOSE}");
anyhow::bail!("{OVER_CAP_CLOSE}");
}
Err(e) => {
let what = serve_failure(&e);
tracing::error!("serving error: {what}");
return Err(anyhow::anyhow!("stdio serving failed: {what}"));
}
},
() = &mut shutdown => {
tracing::info!("received shutdown signal");
flush_otel_and_exit(otel_on_signal.as_deref()).await;
}
};
let cancel = service.cancellation_token();
tokio::select! {
result = service.waiting() => {
result?;
if over_cap.load(Ordering::Acquire) {
anyhow::bail!("{OVER_CAP_CLOSE}");
}
}
() = shutdown => {
tracing::info!("received shutdown signal");
cancel.cancel();
flush_otel_and_exit(otel_on_signal.as_deref()).await;
}
}
}
Transport::Http => {
let ct = tokio_util::sync::CancellationToken::new();
let config = server
.http_server_config()?
.with_cancellation_token(ct.child_token());
let service = StreamableHttpService::new(
move || Ok(server.clone()),
AuditedSessionManager::default().into(),
config,
);
let Some(auth) = http_auth else {
anyhow::bail!("internal error: the http transport has no resolved bearer gate");
};
let router = http_auth::guard_router(
axum::Router::new().nest_service("/mcp", service),
auth,
);
let addr = format!("{}:{}", cfg.host, cfg.port);
let shutdown = shutdown_signal();
let tcp_listener = tokio::net::TcpListener::bind(&addr)
.await
.with_context(|| format!("failed to bind {addr}"))?;
let bound = tcp_listener
.local_addr()
.with_context(|| format!("failed to read the local address of {addr}"))?;
tracing::info!("Starting Bugzilla MCP server on {bound}");
axum::serve(
tcp_listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(async move {
shutdown.await;
tracing::info!("received shutdown signal");
ct.cancel();
})
.await?;
}
}
Ok(())
}
.await;
if let Some(pipeline) = &otel {
pipeline.shutdown().await;
}
served
}
const OVER_CAP_CLOSE: &str = "stdio transport closed: an inbound frame exceeded the request cap";
fn serve_failure(error: &rmcp::service::ServerInitializeError) -> &'static str {
use rmcp::model::ClientJsonRpcMessage as Frame;
use rmcp::service::ServerInitializeError as Failure;
match error {
Failure::ExpectedInitializeRequest(Some(frame)) => match frame {
Frame::Request(_) => "the first frame was a request other than initialize",
Frame::Notification(_) => "the first frame was a notification, not initialize",
Frame::Response(_) => "the first frame was a response, not initialize",
Frame::Error(_) => "the first frame was an error reply, not initialize",
},
Failure::ExpectedInitializeRequest(None) => "no first frame was received",
Failure::ConnectionClosed(_) => "the stream ended before initialize",
Failure::UnexpectedInitializeResponse(_) => {
"this server answered initialize with the wrong result"
}
Failure::InitializeFailed(_) => "this server refused initialize",
Failure::TransportError { .. } => "the stdio transport failed during the handshake",
Failure::Cancelled => "the handshake was cancelled",
_ => "the handshake failed",
}
}
async fn flush_otel_and_exit(otel: Option<&Pipeline>) -> ! {
if let Some(pipeline) = otel {
pipeline.shutdown().await;
}
std::process::exit(0);
}
#[cfg(unix)]
fn shutdown_signal() -> impl std::future::Future<Output = ()> {
use tokio::signal::unix::{signal, Signal, SignalKind};
fn arm(kind: SignalKind, name: &str) -> Option<Signal> {
signal(kind)
.inspect_err(|err| {
tracing::error!(
error = %err,
"failed to install the {name} handler; {name} will not stop the process"
);
})
.ok()
}
let interrupt = arm(SignalKind::interrupt(), "SIGINT");
let terminate = arm(SignalKind::terminate(), "SIGTERM");
async move {
match (interrupt, terminate) {
(Some(mut interrupt), Some(mut terminate)) => {
tokio::select! {
_ = interrupt.recv() => {}
_ = terminate.recv() => {}
}
}
(Some(mut only), None) | (None, Some(mut only)) => {
only.recv().await;
}
(None, None) => std::future::pending::<()>().await,
}
}
}
#[cfg(not(unix))]
fn shutdown_signal() -> impl std::future::Future<Output = ()> {
async {
let _ = tokio::signal::ctrl_c().await;
}
}