#[cfg(test)]
mod tests;
use std::collections::BTreeMap;
use std::time::Duration;
use serde::Deserialize;
use url::Url;
use super::name::{A2aAgentNameError, validate_a2a_agent_name};
use crate::mcp::SecretString;
use crate::mcp::sse::endpoint::is_loopback;
pub const WELL_KNOWN_AGENT_CARD_PATH: &str = "/.well-known/agent-card.json";
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
pub const DEFAULT_MAX_WAIT: Duration = Duration::from_secs(300);
pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
pub const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
pub const DEFAULT_MAX_EVENT_BYTES: usize = DEFAULT_MAX_RESPONSE_BYTES;
pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 256 * 1024;
const ALLOWED_SCHEMES: [&str; 2] = ["http", "https"];
const RESERVED_HEADERS: [&str; 1] = ["a2a-version"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct A2aLimits {
pub connect_timeout: Duration,
pub request_timeout: Duration,
pub max_response_bytes: usize,
pub max_wait: Duration,
pub poll_interval: Duration,
pub max_output_bytes: usize,
pub stream_idle_timeout: Duration,
pub max_event_bytes: usize,
}
impl Default for A2aLimits {
fn default() -> Self {
Self {
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
request_timeout: DEFAULT_REQUEST_TIMEOUT,
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
max_wait: DEFAULT_MAX_WAIT,
poll_interval: DEFAULT_POLL_INTERVAL,
max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
stream_idle_timeout: DEFAULT_STREAM_IDLE_TIMEOUT,
max_event_bytes: DEFAULT_MAX_EVENT_BYTES,
}
}
}
#[derive(Clone, Deserialize)]
pub struct A2aAgentConfig {
pub name: String,
pub url: String,
#[serde(default)]
pub headers: BTreeMap<String, SecretString>,
#[serde(default)]
pub allow_plaintext_credentials: bool,
#[serde(skip)]
pub limits: A2aLimits,
}
impl std::fmt::Debug for A2aAgentConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("A2aAgentConfig")
.field("name", &self.name)
.field("url", &redacted_url(&self.url))
.field("headers", &self.headers)
.field(
"allow_plaintext_credentials",
&self.allow_plaintext_credentials,
)
.field("limits", &self.limits)
.finish()
}
}
fn redacted_url(url: &str) -> String {
let (base, query) = match url.split_once('?') {
Some((base, _)) => (base, "?[redacted]"),
None => (url, ""),
};
let base = match base.split_once("://") {
Some((scheme, rest)) => {
let authority_end = rest.find('/').unwrap_or(rest.len());
match rest[..authority_end].rfind('@') {
Some(at) => format!("{scheme}://[redacted]@{}", &rest[at + 1..]),
None => base.to_string(),
}
}
None => base.to_string(),
};
format!("{base}{query}")
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum A2aConfigError {
#[error(transparent)]
InvalidName(#[from] A2aAgentNameError),
#[error("invalid A2A agent URL: {0}")]
Url(A2aUrlError),
#[error("invalid A2A header name '{name}'")]
InvalidHeaderName { name: String },
#[error("A2A header '{name}' has a value that is not valid for HTTP")]
InvalidHeaderValue { name: String },
#[error("A2A header '{name}' is set by the client and must not be configured")]
ReservedHeader { name: String },
#[error(
"refusing to send configured headers to '{host}' over plaintext http; \
use https, a loopback host, or set allow_plaintext_credentials"
)]
PlaintextCredentials { host: String },
#[error("A2A limit '{name}' must be greater than zero")]
ZeroLimit { name: &'static str },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum A2aUrlError {
#[error("the URL could not be parsed")]
Malformed,
#[error("only http and https URLs are allowed")]
UnsupportedScheme,
#[error("the URL has no host")]
MissingHost,
#[error("the URL must not embed credentials")]
CredentialsInUrl,
}
impl A2aAgentConfig {
pub fn new(name: impl Into<String>, url: impl Into<String>) -> Self {
Self {
name: name.into(),
url: url.into(),
headers: BTreeMap::new(),
allow_plaintext_credentials: false,
limits: A2aLimits::default(),
}
}
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<SecretString>) -> Self {
self.headers.insert(name.into(), value.into());
self
}
pub fn with_bearer_token(self, token: impl Into<String>) -> Self {
self.with_header(
"authorization",
SecretString::new(format!("Bearer {}", token.into())),
)
}
pub fn with_limits(mut self, limits: A2aLimits) -> Self {
self.limits = limits;
self
}
pub fn allowing_plaintext_credentials(mut self) -> Self {
self.allow_plaintext_credentials = true;
self
}
pub fn card_url(&self) -> Result<Url, A2aConfigError> {
let url = check_url(&self.url).map_err(A2aConfigError::Url)?;
if url.path() == "/" && url.query().is_none() {
return url
.join(WELL_KNOWN_AGENT_CARD_PATH)
.map_err(|_| A2aConfigError::Url(A2aUrlError::Malformed));
}
Ok(url)
}
pub fn validate(&self) -> Result<Url, A2aConfigError> {
validate_a2a_agent_name(&self.name)?;
let url = self.card_url()?;
for (name, value) in &self.headers {
if reqwest::header::HeaderName::try_from(name.as_str()).is_err() {
return Err(A2aConfigError::InvalidHeaderName { name: name.clone() });
}
if RESERVED_HEADERS
.iter()
.any(|reserved| name.eq_ignore_ascii_case(reserved))
{
return Err(A2aConfigError::ReservedHeader { name: name.clone() });
}
if reqwest::header::HeaderValue::try_from(value.expose_secret()).is_err() {
return Err(A2aConfigError::InvalidHeaderValue { name: name.clone() });
}
}
if self.refuses_plaintext_to(&url) {
return Err(A2aConfigError::PlaintextCredentials {
host: url.host_str().unwrap_or_default().to_string(),
});
}
for (name, value) in [
("connect_timeout", self.limits.connect_timeout),
("request_timeout", self.limits.request_timeout),
("poll_interval", self.limits.poll_interval),
("stream_idle_timeout", self.limits.stream_idle_timeout),
] {
if value.is_zero() {
return Err(A2aConfigError::ZeroLimit { name });
}
}
for (name, value) in [
("max_response_bytes", self.limits.max_response_bytes),
("max_output_bytes", self.limits.max_output_bytes),
("max_event_bytes", self.limits.max_event_bytes),
] {
if value == 0 {
return Err(A2aConfigError::ZeroLimit { name });
}
}
Ok(url)
}
pub(crate) fn refuses_plaintext_to(&self, url: &Url) -> bool {
!self.headers.is_empty() && self.refuses_credentials_in_clear_to(url)
}
pub(crate) fn refuses_credentials_in_clear_to(&self, url: &Url) -> bool {
url.scheme() == "http" && !self.allow_plaintext_credentials && !is_loopback(url)
}
}
pub(crate) fn check_url(raw: &str) -> Result<Url, A2aUrlError> {
let url = Url::parse(raw.trim()).map_err(|_| A2aUrlError::Malformed)?;
if !ALLOWED_SCHEMES.contains(&url.scheme()) {
return Err(A2aUrlError::UnsupportedScheme);
}
if !url.username().is_empty() || url.password().is_some() {
return Err(A2aUrlError::CredentialsInUrl);
}
if url.host_str().is_none() {
return Err(A2aUrlError::MissingHost);
}
Ok(url)
}