use std::sync::Arc;
use apcore::{ErrorCode, Executor, ModuleError};
use apcore_a2a::{APCoreA2AConfig, BackendSource};
use apcore_mcp::ApprovalStore;
use crate::module::{build_executor, ExecutorOptions};
pub struct A2aServerBuilder {
name: String,
url: String,
explorer: bool,
modules_dir: Option<std::path::PathBuf>,
timeout_ms: u64,
acl_path: Option<std::path::PathBuf>,
audit_path: Option<std::path::PathBuf>,
enable_logging: bool,
log_arguments: bool,
enable_approval: bool,
enable_circuit_breaker: bool,
enable_retry: bool,
approval_store: Option<Arc<dyn ApprovalStore>>,
execution_timeout: u64,
cors_origins: Vec<String>,
filter: crate::module::ModuleFilter,
allow_unauthenticated_bind: bool,
}
#[derive(Debug, PartialEq, Eq)]
struct BindAddress {
host: String,
port: u16,
}
const DEFAULT_A2A_URL: &str = "http://127.0.0.1:8000";
#[allow(clippy::result_large_err)] fn refuse_bind_url(url: &str, detail: &str) -> ModuleError {
ModuleError::new(
ErrorCode::GeneralInvalidInput,
format!(
"Refusing to start: --url '{url}' {detail} Give a full \
`http://<host>:<port>` with no path, for example `{DEFAULT_A2A_URL}`."
),
)
}
#[allow(clippy::result_large_err)] fn http_authority(url: &str) -> Result<&str, ModuleError> {
let Some((scheme, authority)) = url.split_once("://") else {
return Err(refuse_bind_url(
url,
"has no scheme, and the A2A server derives its listen address by \
splitting on '://' โ with no scheme it falls back to 0.0.0.0:8000 and \
serves every wrapped binary on every interface, unauthenticated.",
));
};
if scheme != "http" {
return Err(refuse_bind_url(
url,
&format!(
"uses the '{scheme}' scheme, but the A2A server both serves plain HTTP \
and derives its listen address from this value, so '{scheme}' describes \
neither the listener nor a reachable endpoint."
),
));
}
if authority.contains(['/', '?', '#']) {
return Err(refuse_bind_url(
url,
"carries a path, query or fragment. Everything after '://' is passed \
verbatim to the socket bind, so it would fail to start.",
));
}
Ok(authority)
}
#[allow(clippy::result_large_err)] fn bind_address(url: &str, authority: &str) -> Result<BindAddress, ModuleError> {
let (host, port) = split_host_port(authority).ok_or_else(|| {
refuse_bind_url(
url,
"names no port. The whole authority is passed verbatim to the socket \
bind, which needs an explicit `host:port`.",
)
})?;
if host.is_empty() {
return Err(refuse_bind_url(url, "names no host."));
}
let port: u16 = port
.parse()
.map_err(|_| refuse_bind_url(url, &format!("has '{port}' where a port number belongs.")))?;
if port == 0 {
return Err(refuse_bind_url(
url,
"asks for port 0, which binds an arbitrary port the agent card \
would then misreport.",
));
}
Ok(BindAddress {
host: host.to_string(),
port,
})
}
#[allow(clippy::result_large_err)] fn parse_bind_url(url: &str) -> Result<BindAddress, ModuleError> {
bind_address(url, http_authority(url)?)
}
fn split_host_port(authority: &str) -> Option<(&str, &str)> {
if let Some(rest) = authority.strip_prefix('[') {
let (host, after) = rest.split_once(']')?;
return after.strip_prefix(':').map(|port| (host, port));
}
let (host, port) = authority.rsplit_once(':')?;
if host.contains(':') {
return None;
}
Some((host, port))
}
#[allow(clippy::result_large_err)] fn validate_bind_url(url: &str, acknowledged: bool) -> Result<BindAddress, ModuleError> {
let bind = parse_bind_url(url)?;
if crate::auth::is_loopback_host(&bind.host) {
return Ok(bind);
}
if !acknowledged {
return Err(ModuleError::new(
ErrorCode::GeneralInvalidInput,
format!(
"Refusing to start: --url '{url}' binds the non-loopback host '{}', and \
the A2A server has no transport authentication of any kind โ the agent \
card and every wrapped binary would be reachable from the network with \
no credential. Bind to loopback (`{DEFAULT_A2A_URL}`) and put a \
reverse proxy that authenticates in front, or pass \
`--allow-unauthenticated-bind` to state that you mean it.",
bind.host
),
));
}
tracing::warn!(
host = %bind.host,
port = bind.port,
"Serving A2A with NO authentication on a non-loopback bind, as explicitly acknowledged"
);
Ok(bind)
}
impl A2aServerBuilder {
pub fn new() -> Self {
Self {
name: "apexe".to_string(),
url: DEFAULT_A2A_URL.to_string(),
explorer: false,
modules_dir: None,
timeout_ms: 30_000,
acl_path: None,
filter: crate::module::ModuleFilter::default(),
audit_path: None,
enable_logging: true,
log_arguments: true,
enable_approval: false,
enable_circuit_breaker: true,
enable_retry: true,
approval_store: None,
execution_timeout: 300,
cors_origins: vec![],
allow_unauthenticated_bind: false,
}
}
pub fn name(mut self, name: &str) -> Self {
self.name = name.to_string();
self
}
pub fn url(mut self, url: &str) -> Self {
self.url = url.to_string();
self
}
pub fn allow_unauthenticated_bind(mut self, acknowledged: bool) -> Self {
self.allow_unauthenticated_bind = acknowledged;
self
}
pub fn explorer(mut self, enabled: bool) -> Self {
self.explorer = enabled;
self
}
pub fn modules_dir(mut self, dir: impl Into<std::path::PathBuf>) -> Self {
self.modules_dir = Some(dir.into());
self
}
pub fn timeout_ms(mut self, ms: u64) -> Self {
self.timeout_ms = ms;
self
}
pub fn audit_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.audit_path = Some(path.into());
self
}
pub fn acl_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.acl_path = Some(path.into());
self
}
pub fn enable_logging(mut self, enabled: bool) -> Self {
self.enable_logging = enabled;
self
}
pub fn log_arguments(mut self, enabled: bool) -> Self {
self.log_arguments = enabled;
self
}
pub fn enable_approval(mut self, enabled: bool) -> Self {
self.enable_approval = enabled;
self
}
pub fn enable_circuit_breaker(mut self, enabled: bool) -> Self {
self.enable_circuit_breaker = enabled;
self
}
pub fn enable_retry(mut self, enabled: bool) -> Self {
self.enable_retry = enabled;
self
}
pub fn approval_store(mut self, store: Arc<dyn ApprovalStore>) -> Self {
self.approval_store = Some(store);
self
}
pub fn execution_timeout(mut self, secs: u64) -> Self {
self.execution_timeout = secs;
self
}
pub fn cors_origins(mut self, origins: Vec<String>) -> Self {
self.cors_origins = origins;
self
}
pub fn module_filter(&self) -> &crate::module::ModuleFilter {
&self.filter
}
pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
self.filter.prefix = Some(prefix.into());
self
}
pub fn tags(mut self, tags: Vec<String>) -> Self {
self.filter.tags = Some(tags);
self
}
fn executor_options(&self) -> ExecutorOptions<'_> {
ExecutorOptions {
modules_dir: self.modules_dir.as_deref(),
timeout_ms: self.timeout_ms,
acl_path: self.acl_path.as_deref(),
filter: self.filter.clone(),
audit_path: self.audit_path.as_deref(),
enable_logging: self.enable_logging,
log_arguments: self.log_arguments,
enable_approval: self.enable_approval,
enable_circuit_breaker: self.enable_circuit_breaker,
enable_retry: self.enable_retry,
approval_store: self.approval_store.clone(),
}
}
#[allow(clippy::result_large_err)]
pub async fn serve(self) -> Result<(), ModuleError> {
let (executor, config) = self.prepare()?;
apcore_a2a::async_serve(BackendSource::Executor(executor), config)
.await
.map_err(|e| {
ModuleError::new(
ErrorCode::GeneralInternalError,
format!("A2A server error: {e}"),
)
})
}
#[allow(clippy::result_large_err)] pub async fn agent_card(self) -> Result<serde_json::Value, ModuleError> {
let (executor, config) = self.prepare()?;
let (_router, card) = apcore_a2a::build_app(BackendSource::Executor(executor), config)
.await
.map_err(|e| {
ModuleError::new(
ErrorCode::GeneralInternalError,
format!("Failed to build A2A app: {e}"),
)
})?;
serde_json::to_value(&card).map_err(|e| {
ModuleError::new(
ErrorCode::GeneralInternalError,
format!("Failed to serialize agent card: {e}"),
)
})
}
#[allow(clippy::result_large_err)] fn prepare(&self) -> Result<(Arc<Executor>, APCoreA2AConfig), ModuleError> {
validate_bind_url(&self.url, self.allow_unauthenticated_bind)?;
if self.enable_approval && self.approval_store.is_none() {
return Err(ModuleError::new(
ErrorCode::GeneralInvalidInput,
"A2A server has no session/elicitation mechanism, so --enable-approval without \
an approval_store would reject every requires_approval call",
)
.with_retryable(false)
.with_ai_guidance(
"apcore-a2a has no MCP-style session/elicitation to prompt a human for \
approval, so the default ElicitationApprovalHandler can never resolve here. \
Provide `.approval_store(...)` (a persistent ApprovalStore) via the library \
API, or disable `.enable_approval(false)`.",
));
}
let executor = build_executor(&self.executor_options())?;
let config = APCoreA2AConfig {
name: self.name.clone(),
description: format!("apexe A2A agent '{}'", self.name),
version: crate::VERSION.to_string(),
url: self.url.clone(),
execution_timeout: self.execution_timeout,
explorer: self.explorer,
sys_modules: false,
cors_origins: self.cors_origins.clone(),
..APCoreA2AConfig::default()
};
Ok((executor, config))
}
}
impl Default for A2aServerBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_a2a_server_builder_defaults() {
let builder = A2aServerBuilder::new();
assert_eq!(builder.name, "apexe");
assert_eq!(builder.url, "http://127.0.0.1:8000");
assert!(!builder.explorer);
assert!(builder.modules_dir.is_none());
assert_eq!(builder.timeout_ms, 30_000);
assert_eq!(builder.execution_timeout, 300);
assert!(builder.cors_origins.is_empty());
}
#[test]
fn test_a2a_server_builder_chain() {
let builder = A2aServerBuilder::new()
.name("my-agent")
.url("http://0.0.0.0:9090")
.explorer(true)
.modules_dir("/tmp/modules")
.timeout_ms(60_000)
.execution_timeout(600)
.cors_origins(vec!["https://example.com".to_string()]);
assert_eq!(builder.name, "my-agent");
assert_eq!(builder.url, "http://0.0.0.0:9090");
assert!(builder.explorer);
assert_eq!(
builder.modules_dir,
Some(std::path::PathBuf::from("/tmp/modules"))
);
assert_eq!(builder.timeout_ms, 60_000);
assert_eq!(builder.execution_timeout, 600);
assert_eq!(builder.cors_origins, vec!["https://example.com"]);
}
#[test]
fn test_a2a_server_builder_default_impl() {
let builder = A2aServerBuilder::default();
assert_eq!(builder.name, "apexe");
assert_eq!(builder.url, "http://127.0.0.1:8000");
}
#[test]
fn test_a2a_server_builder_logging_default_enabled() {
let builder = A2aServerBuilder::new();
assert!(builder.enable_logging);
assert!(!builder.enable_approval);
}
#[test]
fn test_a2a_prepare_sets_apexe_version_on_the_agent_card() {
let builder = A2aServerBuilder::new();
let (_executor, config) = builder.prepare().expect("prepare should succeed");
assert_eq!(config.version, crate::VERSION);
assert_eq!(config.version, env!("CARGO_PKG_VERSION"));
assert_ne!(
config.version,
APCoreA2AConfig::default().version,
"the card must not report the framework's version as its own"
);
}
#[tokio::test]
async fn test_a2a_server_builder_empty_registry_errors() {
let result = A2aServerBuilder::new().serve().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_a2a_server_builder_enable_approval_without_store_fails_fast() {
let result = A2aServerBuilder::new().enable_approval(true).serve().await;
let err = result.expect_err("enable_approval without approval_store must fail fast");
assert_eq!(err.code, ErrorCode::GeneralInvalidInput);
assert!(err
.ai_guidance
.as_ref()
.expect("guidance should explain the fix")
.contains("approval_store"));
}
#[tokio::test]
async fn test_a2a_server_builder_enable_approval_with_store_does_not_fail_fast() {
use apcore_mcp::InMemoryApprovalStore;
let store: Arc<dyn ApprovalStore> = Arc::new(InMemoryApprovalStore::new());
let result = A2aServerBuilder::new()
.enable_approval(true)
.approval_store(store)
.serve()
.await;
let err = result.expect_err("empty registry should still error");
assert_ne!(err.code, ErrorCode::GeneralInvalidInput);
}
}