use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use http_body_util::{BodyExt, Full, LengthLimitError, Limited};
use hyper::body::Bytes;
use hyper::header;
#[cfg(not(feature = "tls-rustls"))]
use hyper_util::client::legacy::Client;
#[cfg(not(feature = "tls-rustls"))]
use hyper_util::client::legacy::connect::HttpConnector;
#[cfg(not(feature = "tls-rustls"))]
use hyper_util::rt::TokioExecutor;
use tokio::sync::RwLock;
use a2a_protocol_types::AgentCard;
use crate::error::{ClientError, ClientResult};
pub const AGENT_CARD_PATH: &str = "/.well-known/agent-card.json";
pub(crate) const MAX_CARD_BODY_SIZE: u64 = 2 * 1024 * 1024;
pub(crate) const CARD_FETCH_BUDGET: Duration = Duration::from_secs(30);
pub(crate) const fn exceeds_card_body_size(len: u64, max: u64) -> bool {
len > max
}
#[derive(Debug, Clone)]
pub struct CardFetchOptions {
headers: HashMap<String, String>,
timeout: Duration,
}
impl Default for CardFetchOptions {
fn default() -> Self {
Self {
headers: HashMap::new(),
timeout: CARD_FETCH_BUDGET,
}
}
}
impl CardFetchOptions {
#[must_use]
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(name.into(), value.into());
self
}
#[must_use]
pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
self.headers = headers;
self
}
#[must_use]
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub const fn headers(&self) -> &HashMap<String, String> {
&self.headers
}
#[must_use]
pub const fn timeout(&self) -> Duration {
self.timeout
}
}
pub async fn resolve_agent_card(base_url: &str) -> ClientResult<AgentCard> {
resolve_agent_card_with_options(base_url, &CardFetchOptions::default()).await
}
pub async fn resolve_agent_card_with_options(
base_url: &str,
options: &CardFetchOptions,
) -> ClientResult<AgentCard> {
trace_info!(base_url, "resolving agent card");
let url = build_card_url(base_url, AGENT_CARD_PATH)?;
fetch_card(&url, None, options).await
}
pub async fn resolve_agent_card_with_path(base_url: &str, path: &str) -> ClientResult<AgentCard> {
let url = build_card_url(base_url, path)?;
fetch_card(&url, None, &CardFetchOptions::default()).await
}
pub async fn fetch_card_from_url(url: &str) -> ClientResult<AgentCard> {
fetch_card_from_url_with_options(url, &CardFetchOptions::default()).await
}
pub async fn fetch_card_from_url_with_options(
url: &str,
options: &CardFetchOptions,
) -> ClientResult<AgentCard> {
fetch_card(url, None, options).await
}
#[derive(Debug, Clone)]
struct CachedCard {
card: AgentCard,
etag: Option<String>,
last_modified: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CachingCardResolver {
url: String,
cache: Arc<RwLock<Option<CachedCard>>>,
}
impl CachingCardResolver {
pub fn new(base_url: &str) -> ClientResult<Self> {
let url = build_card_url(base_url, AGENT_CARD_PATH)?;
Ok(Self {
url,
cache: Arc::new(RwLock::new(None)),
})
}
pub fn with_path(base_url: &str, path: &str) -> ClientResult<Self> {
let url = build_card_url(base_url, path)?;
Ok(Self {
url,
cache: Arc::new(RwLock::new(None)),
})
}
pub async fn resolve(&self) -> ClientResult<AgentCard> {
trace_info!(url = %self.url, "resolving agent card (cached)");
let cached = self.cache.read().await.clone();
let (card, etag, last_modified) =
fetch_card_with_metadata(&self.url, cached.as_ref(), &CardFetchOptions::default())
.await?;
{
let mut guard = self.cache.write().await;
*guard = Some(CachedCard {
card: card.clone(),
etag,
last_modified,
});
}
Ok(card)
}
pub async fn invalidate(&self) {
let mut cache = self.cache.write().await;
*cache = None;
}
}
fn build_card_url(base_url: &str, path: &str) -> ClientResult<String> {
if base_url.is_empty() {
return Err(ClientError::InvalidEndpoint(
"base URL must not be empty".into(),
));
}
if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
return Err(ClientError::InvalidEndpoint(format!(
"base URL must start with http:// or https://: {base_url}"
)));
}
let base = base_url.trim_end_matches('/');
let path = if path.starts_with('/') {
path.to_owned()
} else {
format!("/{path}")
};
Ok(format!("{base}{path}"))
}
async fn fetch_card(
url: &str,
cached: Option<&CachedCard>,
options: &CardFetchOptions,
) -> ClientResult<AgentCard> {
let (card, _, _) = fetch_card_with_metadata(url, cached, options).await?;
Ok(card)
}
#[allow(clippy::too_many_lines)]
async fn fetch_card_with_metadata(
url: &str,
cached: Option<&CachedCard>,
options: &CardFetchOptions,
) -> ClientResult<(AgentCard, Option<String>, Option<String>)> {
#[cfg(not(feature = "tls-rustls"))]
let client: Client<HttpConnector, Full<Bytes>> = {
let mut connector = HttpConnector::new();
connector.set_connect_timeout(Some(Duration::from_secs(10)));
connector.set_nodelay(true);
Client::builder(TokioExecutor::new()).build(connector)
};
#[cfg(feature = "tls-rustls")]
let client = crate::tls::build_https_client();
let mut builder = hyper::Request::builder()
.method(hyper::Method::GET)
.uri(url)
.header(header::ACCEPT, "application/json");
for (name, value) in &options.headers {
builder = builder.header(name.as_str(), value.as_str());
}
if let Some(cached) = cached {
if let Some(ref etag) = cached.etag {
builder = builder.header("if-none-match", etag.as_str());
}
if let Some(ref lm) = cached.last_modified {
builder = builder.header("if-modified-since", lm.as_str());
}
}
let req = builder
.body(Full::new(Bytes::new()))
.map_err(|e| ClientError::Transport(e.to_string()))?;
let deadline = tokio::time::Instant::now() + options.timeout;
let resp = tokio::time::timeout_at(deadline, client.request(req))
.await
.map_err(|_| ClientError::Transport("agent card fetch timed out".into()))?
.map_err(|e| ClientError::HttpClient(e.to_string()))?;
let status = resp.status();
let retry_after = crate::error::parse_retry_after(resp.headers());
if status == hyper::StatusCode::NOT_MODIFIED
&& let Some(cached) = cached
{
return Ok((
cached.card.clone(),
cached.etag.clone(),
cached.last_modified.clone(),
));
}
let etag = resp
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let last_modified = resp
.headers()
.get("last-modified")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let max_card_body_size: u64 = MAX_CARD_BODY_SIZE;
if let Some(cl) = resp.headers().get(header::CONTENT_LENGTH)
&& let Ok(len) = cl.to_str().unwrap_or("0").parse::<u64>()
&& exceeds_card_body_size(len, max_card_body_size)
{
return Err(ClientError::Transport(format!(
"agent card response too large: {len} bytes exceeds {max_card_body_size} byte limit"
)));
}
let cap = usize::try_from(max_card_body_size).unwrap_or(usize::MAX);
let body_bytes = match tokio::time::timeout_at(
deadline,
Limited::new(resp.into_body(), cap).collect(),
)
.await
{
Err(_) => {
return Err(ClientError::Transport(
"agent card body read timed out".into(),
));
}
Ok(Ok(collected)) => collected.to_bytes(),
Ok(Err(err)) => {
return Err(if err.downcast_ref::<LengthLimitError>().is_some() {
ClientError::Transport(format!(
"agent card response too large: exceeds {max_card_body_size} byte limit"
))
} else {
ClientError::Transport(format!("agent card body read failed: {err}"))
});
}
};
if !status.is_success() {
let body_str = String::from_utf8_lossy(&body_bytes).into_owned();
return Err(ClientError::UnexpectedStatus {
status: status.as_u16(),
body: body_str,
retry_after,
});
}
let card =
serde_json::from_slice::<AgentCard>(&body_bytes).map_err(ClientError::Serialization)?;
Ok((card, etag, last_modified))
}
#[cfg(test)]
mod tests {
use super::*;
fn no_options() -> CardFetchOptions {
CardFetchOptions::default()
}
#[test]
fn build_card_url_standard() {
let url = build_card_url("http://localhost:8080", AGENT_CARD_PATH).unwrap();
assert_eq!(url, "http://localhost:8080/.well-known/agent-card.json");
}
#[test]
fn build_card_url_trailing_slash() {
let url = build_card_url("http://localhost:8080/", AGENT_CARD_PATH).unwrap();
assert_eq!(url, "http://localhost:8080/.well-known/agent-card.json");
}
#[test]
fn build_card_url_custom_path() {
let url = build_card_url("http://localhost:8080", "/api/card.json").unwrap();
assert_eq!(url, "http://localhost:8080/api/card.json");
}
#[test]
fn build_card_url_rejects_empty() {
assert!(build_card_url("", AGENT_CARD_PATH).is_err());
}
#[test]
fn build_card_url_rejects_non_http() {
assert!(build_card_url("ftp://example.com", AGENT_CARD_PATH).is_err());
}
#[test]
fn caching_resolver_new() {
let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
assert_eq!(
resolver.url,
"http://localhost:8080/.well-known/agent-card.json"
);
}
#[test]
fn caching_resolver_new_rejects_invalid_url() {
assert!(CachingCardResolver::new("").is_err());
assert!(CachingCardResolver::new("ftp://example.com").is_err());
}
#[test]
fn caching_resolver_with_path() {
let resolver =
CachingCardResolver::with_path("http://localhost:8080", "/custom/card.json").unwrap();
assert_eq!(resolver.url, "http://localhost:8080/custom/card.json");
}
#[tokio::test]
async fn caching_resolver_invalidate_empty() {
let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
assert!(resolver.cache.read().await.is_none());
resolver.invalidate().await;
assert!(resolver.cache.read().await.is_none());
}
#[tokio::test]
async fn caching_resolver_invalidate_clears_populated_cache() {
use a2a_protocol_types::{AgentCapabilities, AgentCard};
let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
{
let mut guard = resolver.cache.write().await;
*guard = Some(CachedCard {
card: AgentCard {
url: None,
name: "cached".into(),
version: "1.0".into(),
description: "Cached agent".into(),
supported_interfaces: vec![],
provider: None,
icon_url: None,
documentation_url: None,
capabilities: AgentCapabilities::none(),
security_schemes: None,
security_requirements: None,
default_input_modes: vec![],
default_output_modes: vec![],
skills: vec![],
signatures: None,
},
etag: Some("test-etag".into()),
last_modified: None,
});
}
{
let cached = resolver.cache.read().await;
let entry = cached.as_ref().expect("cache should be populated");
assert_eq!(entry.card.name, "cached");
assert_eq!(entry.etag, Some("test-etag".into()));
drop(cached);
}
resolver.invalidate().await;
assert!(
resolver.cache.read().await.is_none(),
"invalidate must clear a populated cache"
);
}
#[tokio::test]
async fn fetch_card_with_metadata_non_success_status() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let io = hyper_util::rt::TokioIo::new(stream);
tokio::spawn(async move {
let service = hyper::service::service_fn(|_req| async {
Ok::<_, hyper::Error>(
hyper::Response::builder()
.status(404)
.body(http_body_util::Full::new(hyper::body::Bytes::from(
"Not Found",
)))
.unwrap(),
)
});
let _ = hyper_util::server::conn::auto::Builder::new(
hyper_util::rt::TokioExecutor::new(),
)
.serve_connection(io, service)
.await;
});
}
});
let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
let result = fetch_card_with_metadata(&url, None, &no_options()).await;
assert!(result.is_err());
match result.unwrap_err() {
ClientError::UnexpectedStatus { status, body, .. } => {
assert_eq!(status, 404);
assert!(body.contains("Not Found"));
}
other => panic!("expected UnexpectedStatus, got {other:?}"),
}
}
#[tokio::test]
async fn fetch_card_with_metadata_304_returns_cached() {
use a2a_protocol_types::{AgentCapabilities, AgentCard};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let io = hyper_util::rt::TokioIo::new(stream);
tokio::spawn(async move {
let service = hyper::service::service_fn(|_req| async {
Ok::<_, hyper::Error>(
hyper::Response::builder()
.status(304)
.body(http_body_util::Full::new(hyper::body::Bytes::new()))
.unwrap(),
)
});
let _ = hyper_util::server::conn::auto::Builder::new(
hyper_util::rt::TokioExecutor::new(),
)
.serve_connection(io, service)
.await;
});
}
});
let cached = CachedCard {
card: AgentCard {
url: None,
name: "cached-agent".into(),
version: "2.0".into(),
description: "Cached".into(),
supported_interfaces: vec![],
provider: None,
icon_url: None,
documentation_url: None,
capabilities: AgentCapabilities::none(),
security_schemes: None,
security_requirements: None,
default_input_modes: vec![],
default_output_modes: vec![],
skills: vec![],
signatures: None,
},
etag: Some("\"abc123\"".into()),
last_modified: None,
};
let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
let (card, etag, _) = fetch_card_with_metadata(&url, Some(&cached), &no_options())
.await
.unwrap();
assert_eq!(card.name, "cached-agent");
assert_eq!(etag, Some("\"abc123\"".into()));
}
#[tokio::test]
async fn fetch_card_with_metadata_200_parses_card() {
use a2a_protocol_types::{AgentCard, AgentInterface};
let card = AgentCard::new(
"test-agent",
"1.0",
AgentInterface::jsonrpc("http://localhost:9090"),
)
.with_description("A test");
let card_json = serde_json::to_string(&card).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let io = hyper_util::rt::TokioIo::new(stream);
let body = card_json.clone();
tokio::spawn(async move {
let service = hyper::service::service_fn(move |_req| {
let body = body.clone();
async move {
Ok::<_, hyper::Error>(
hyper::Response::builder()
.status(200)
.header("etag", "\"xyz\"")
.header("last-modified", "Mon, 01 Jan 2026 00:00:00 GMT")
.body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
.unwrap(),
)
}
});
let _ = hyper_util::server::conn::auto::Builder::new(
hyper_util::rt::TokioExecutor::new(),
)
.serve_connection(io, service)
.await;
});
}
});
let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
let (parsed_card, etag, last_modified) =
fetch_card_with_metadata(&url, None, &no_options())
.await
.unwrap();
assert_eq!(parsed_card.name, "test-agent");
assert_eq!(etag, Some("\"xyz\"".into()));
assert_eq!(last_modified, Some("Mon, 01 Jan 2026 00:00:00 GMT".into()));
}
#[allow(clippy::too_many_lines)]
#[tokio::test]
async fn caching_resolver_resolve_fetches_and_caches() {
use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
let card = AgentCard {
url: None,
name: "resolver-test".into(),
version: "1.0".into(),
description: "Resolver test agent".into(),
supported_interfaces: vec![AgentInterface {
url: "http://localhost:9090".into(),
protocol_binding: "JSONRPC".into(),
protocol_version: "1.0.0".into(),
tenant: None,
}],
provider: None,
icon_url: None,
documentation_url: None,
capabilities: AgentCapabilities::none(),
security_schemes: None,
security_requirements: None,
default_input_modes: vec![],
default_output_modes: vec![],
skills: vec![],
signatures: None,
};
let card_json = serde_json::to_string(&card).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let io = hyper_util::rt::TokioIo::new(stream);
let body = card_json.clone();
tokio::spawn(async move {
let service = hyper::service::service_fn(move |_req| {
let body = body.clone();
async move {
Ok::<_, hyper::Error>(
hyper::Response::builder()
.status(200)
.header("etag", "\"res-etag\"")
.body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
.unwrap(),
)
}
});
let _ = hyper_util::server::conn::auto::Builder::new(
hyper_util::rt::TokioExecutor::new(),
)
.serve_connection(io, service)
.await;
});
}
});
let base_url = format!("http://127.0.0.1:{}", addr.port());
let resolver = CachingCardResolver::with_path(&base_url, "/agent.json").unwrap();
assert!(
resolver.cache.read().await.is_none(),
"cache should start empty"
);
let fetched = resolver.resolve().await.unwrap();
assert_eq!(fetched.name, "resolver-test");
let cached = resolver.cache.read().await;
let entry = cached
.as_ref()
.expect("cache should be populated after resolve");
assert_eq!(entry.card.name, "resolver-test");
assert_eq!(entry.etag, Some("\"res-etag\"".into()));
drop(cached);
}
#[tokio::test]
async fn caching_resolver_resolve_returns_error_on_failure() {
let resolver = CachingCardResolver::with_path("http://127.0.0.1:1", "/agent.json").unwrap();
let result = resolver.resolve().await;
assert!(
result.is_err(),
"resolve should fail with unreachable server"
);
}
#[test]
fn build_card_url_path_without_leading_slash() {
let url = build_card_url("http://localhost:8080", "custom/card.json").unwrap();
assert_eq!(url, "http://localhost:8080/custom/card.json");
}
#[tokio::test]
async fn fetch_card_from_url_success() {
use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
let card = AgentCard {
url: None,
name: "url-fetch-test".into(),
version: "1.0".into(),
description: "URL fetch test".into(),
supported_interfaces: vec![AgentInterface {
url: "http://localhost:9090".into(),
protocol_binding: "JSONRPC".into(),
protocol_version: "1.0.0".into(),
tenant: None,
}],
provider: None,
icon_url: None,
documentation_url: None,
capabilities: AgentCapabilities::none(),
security_schemes: None,
security_requirements: None,
default_input_modes: vec![],
default_output_modes: vec![],
skills: vec![],
signatures: None,
};
let card_json = serde_json::to_string(&card).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let io = hyper_util::rt::TokioIo::new(stream);
let body = card_json.clone();
tokio::spawn(async move {
let service = hyper::service::service_fn(move |_req| {
let body = body.clone();
async move {
Ok::<_, hyper::Error>(
hyper::Response::builder()
.status(200)
.body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
.unwrap(),
)
}
});
let _ = hyper_util::server::conn::auto::Builder::new(
hyper_util::rt::TokioExecutor::new(),
)
.serve_connection(io, service)
.await;
});
}
});
let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
let fetched = fetch_card_from_url(&url).await.unwrap();
assert_eq!(fetched.name, "url-fetch-test");
}
#[tokio::test]
async fn resolve_agent_card_with_path_success() {
use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
let card = AgentCard {
url: None,
name: "path-resolve-test".into(),
version: "2.0".into(),
description: "Path resolve test".into(),
supported_interfaces: vec![AgentInterface {
url: "http://localhost:9090".into(),
protocol_binding: "JSONRPC".into(),
protocol_version: "1.0.0".into(),
tenant: None,
}],
provider: None,
icon_url: None,
documentation_url: None,
capabilities: AgentCapabilities::none(),
security_schemes: None,
security_requirements: None,
default_input_modes: vec![],
default_output_modes: vec![],
skills: vec![],
signatures: None,
};
let card_json = serde_json::to_string(&card).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let io = hyper_util::rt::TokioIo::new(stream);
let body = card_json.clone();
tokio::spawn(async move {
let service = hyper::service::service_fn(move |_req| {
let body = body.clone();
async move {
Ok::<_, hyper::Error>(
hyper::Response::builder()
.status(200)
.body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
.unwrap(),
)
}
});
let _ = hyper_util::server::conn::auto::Builder::new(
hyper_util::rt::TokioExecutor::new(),
)
.serve_connection(io, service)
.await;
});
}
});
let base_url = format!("http://127.0.0.1:{}", addr.port());
let fetched = resolve_agent_card_with_path(&base_url, "/custom.json")
.await
.unwrap();
assert_eq!(fetched.name, "path-resolve-test");
}
#[tokio::test]
async fn resolve_agent_card_fetches_the_well_known_card() {
use a2a_protocol_types::{AgentCard, AgentInterface};
let card = AgentCard::new(
"well-known-test",
"1.0.0",
AgentInterface::jsonrpc("http://localhost:9090"),
);
let card_json = serde_json::to_string(&card).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let io = hyper_util::rt::TokioIo::new(stream);
let body = card_json.clone();
tokio::spawn(async move {
let service = hyper::service::service_fn(
move |req: hyper::Request<hyper::body::Incoming>| {
let (status, body) =
if req.uri().path() == "/.well-known/agent-card.json" {
(200, body.clone())
} else {
(404, String::new())
};
async move {
Ok::<_, hyper::Error>(
hyper::Response::builder()
.status(status)
.body(http_body_util::Full::new(hyper::body::Bytes::from(
body,
)))
.unwrap(),
)
}
},
);
let _ = hyper_util::server::conn::auto::Builder::new(
hyper_util::rt::TokioExecutor::new(),
)
.serve_connection(io, service)
.await;
});
}
});
let fetched = resolve_agent_card(&format!("http://127.0.0.1:{}", addr.port()))
.await
.expect("the well-known card");
assert_eq!(
(fetched.name.as_str(), fetched.version.as_str()),
("well-known-test", "1.0.0")
);
assert_eq!(
fetched.supported_interfaces[0].url,
card.supported_interfaces[0].url
);
}
#[tokio::test]
async fn fetch_card_rejects_oversized_content_length() {
use tokio::io::AsyncWriteExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 4096];
let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
let response = "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 10000000\r\n\r\nsmall";
let _ = stream.write_all(response.as_bytes()).await;
drop(stream);
});
}
});
let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
let result = fetch_card_with_metadata(&url, None, &no_options()).await;
match result {
Err(ClientError::Transport(msg)) => {
assert!(
msg.contains("too large"),
"should mention size limit: {msg}"
);
}
other => panic!("expected Transport error about size, got {other:?}"),
}
}
#[tokio::test]
async fn fetch_card_accepts_content_length_at_exact_limit() {
use tokio::io::AsyncWriteExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let max_size: u64 = 2 * 1024 * 1024;
tokio::spawn(async move {
loop {
let (mut stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 4096];
let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {max_size}\r\n\r\nsmall"
);
let _ = stream.write_all(response.as_bytes()).await;
drop(stream);
});
}
});
let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
let result = fetch_card_with_metadata(&url, None, &no_options()).await;
match &result {
Err(ClientError::Transport(msg)) if msg.contains("too large") => {
panic!("Content-Length at exact limit should not be rejected: {msg}");
}
_ => {} }
}
#[tokio::test]
async fn fetch_card_rejects_oversized_body_without_content_length() {
use tokio::io::AsyncWriteExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let max_size = 2 * 1024 * 1024_usize;
tokio::spawn(async move {
loop {
let (mut stream, _) = listener.accept().await.unwrap();
let body_size = max_size + 1;
tokio::spawn(async move {
let mut buf = [0u8; 4096];
let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
let header = "HTTP/1.0 200 OK\r\ncontent-type: application/json\r\n\r\n";
let _ = stream.write_all(header.as_bytes()).await;
let chunk = vec![b'x'; 64 * 1024];
let mut remaining = body_size;
while remaining > 0 {
let n = remaining.min(chunk.len());
if stream.write_all(&chunk[..n]).await.is_err() {
break;
}
remaining -= n;
}
drop(stream);
});
}
});
let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
let result = fetch_card_with_metadata(&url, None, &no_options()).await;
match result {
Err(ClientError::Transport(msg)) => {
assert!(
msg.contains("too large"),
"should mention size limit: {msg}"
);
}
other => panic!("expected Transport error about size for body > limit, got {other:?}"),
}
}
#[tokio::test]
async fn fetch_card_with_metadata_304_with_last_modified() {
use a2a_protocol_types::{AgentCapabilities, AgentCard};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let io = hyper_util::rt::TokioIo::new(stream);
tokio::spawn(async move {
let service = hyper::service::service_fn(|_req| async {
Ok::<_, hyper::Error>(
hyper::Response::builder()
.status(304)
.body(http_body_util::Full::new(hyper::body::Bytes::new()))
.unwrap(),
)
});
let _ = hyper_util::server::conn::auto::Builder::new(
hyper_util::rt::TokioExecutor::new(),
)
.serve_connection(io, service)
.await;
});
}
});
let cached = CachedCard {
card: AgentCard {
url: None,
name: "lm-cached".into(),
version: "1.0".into(),
description: "Last-modified cached".into(),
supported_interfaces: vec![],
provider: None,
icon_url: None,
documentation_url: None,
capabilities: AgentCapabilities::none(),
security_schemes: None,
security_requirements: None,
default_input_modes: vec![],
default_output_modes: vec![],
skills: vec![],
signatures: None,
},
etag: None,
last_modified: Some("Mon, 01 Jan 2026 00:00:00 GMT".into()),
};
let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
let (card, _, last_modified) = fetch_card_with_metadata(&url, Some(&cached), &no_options())
.await
.unwrap();
assert_eq!(card.name, "lm-cached");
assert_eq!(last_modified, Some("Mon, 01 Jan 2026 00:00:00 GMT".into()));
}
#[test]
fn exceeds_card_body_size_over_limit() {
assert!(exceeds_card_body_size(
MAX_CARD_BODY_SIZE + 1,
MAX_CARD_BODY_SIZE
));
}
#[test]
fn exceeds_card_body_size_exactly_at_limit_is_ok() {
assert!(!exceeds_card_body_size(
MAX_CARD_BODY_SIZE,
MAX_CARD_BODY_SIZE
));
}
#[test]
fn exceeds_card_body_size_under_limit() {
assert!(!exceeds_card_body_size(0, MAX_CARD_BODY_SIZE));
assert!(!exceeds_card_body_size(1024, MAX_CARD_BODY_SIZE));
assert!(!exceeds_card_body_size(
MAX_CARD_BODY_SIZE - 1,
MAX_CARD_BODY_SIZE
));
}
#[test]
fn exceeds_card_body_size_custom_limit() {
assert!(exceeds_card_body_size(11, 10));
assert!(!exceeds_card_body_size(10, 10));
assert!(!exceeds_card_body_size(9, 10));
}
async fn spawn_gated_card_server(
card_json: String,
name: &'static str,
value: &'static str,
) -> std::net::SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let io = hyper_util::rt::TokioIo::new(stream);
let body = card_json.clone();
tokio::spawn(async move {
let service = hyper::service::service_fn(move |req: hyper::Request<_>| {
let body = body.clone();
async move {
let admitted = req
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v == value);
let (status, body) = if admitted {
(200, body)
} else {
(401, "{\"error\":\"unauthorized\"}".to_owned())
};
Ok::<_, hyper::Error>(
hyper::Response::builder()
.status(status)
.body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
.unwrap(),
)
}
});
let _ = hyper_util::server::conn::auto::Builder::new(
hyper_util::rt::TokioExecutor::new(),
)
.serve_connection(io, service)
.await;
});
}
});
addr
}
#[tokio::test]
async fn a_card_behind_a_required_header_needs_the_options_variant() {
use a2a_protocol_types::{AgentCard, AgentInterface};
let card = AgentCard::new(
"gated",
"1.0",
AgentInterface::jsonrpc("http://localhost:9090"),
);
let addr = spawn_gated_card_server(
serde_json::to_string(&card).unwrap(),
"authorization",
"Bearer s3cret",
)
.await;
let base = format!("http://127.0.0.1:{}", addr.port());
let refused = resolve_agent_card(&base).await;
assert!(
matches!(
refused,
Err(ClientError::UnexpectedStatus { status: 401, .. })
),
"no header, so the server must refuse: {refused:?}"
);
let options = CardFetchOptions::default().with_header("authorization", "Bearer s3cret");
let fetched = resolve_agent_card_with_options(&base, &options)
.await
.expect("the header admits the fetch");
assert_eq!(fetched.name, "gated");
let url = format!("{base}{AGENT_CARD_PATH}");
let fetched = fetch_card_from_url_with_options(&url, &options)
.await
.expect("same header, same door");
assert_eq!(fetched.name, "gated");
}
#[tokio::test]
async fn the_card_fetch_budget_is_the_configured_one() {
let budget = Duration::from_millis(600);
let addr = crate::transport::test_support::spawn_stalling_server(
"HTTP/1.1 200 OK",
budget * 2 / 3,
)
.await;
let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
let options = CardFetchOptions::default().with_timeout(budget);
let started = std::time::Instant::now();
let result = fetch_card_from_url_with_options(&url, &options).await;
let elapsed = started.elapsed();
assert!(
matches!(&result, Err(ClientError::Transport(msg)) if msg.contains("timed out")),
"{result:?}"
);
assert!(
elapsed >= budget && elapsed < budget * 3 / 2,
"one budget, not two: took {elapsed:?} against {budget:?}"
);
}
#[test]
fn card_fetch_options_setters_set_their_fields() {
let mut map = HashMap::new();
map.insert("authorization".to_owned(), "Bearer t".to_owned());
let opts = CardFetchOptions::default()
.with_header("x-first", "1")
.with_headers(map)
.with_header("x-second", "2")
.with_timeout(Duration::from_millis(250));
assert_eq!(
opts.headers().len(),
2,
"with_headers replaces, with_header adds"
);
assert_eq!(
opts.headers().get("authorization").map(String::as_str),
Some("Bearer t")
);
assert_eq!(
opts.headers().get("x-second").map(String::as_str),
Some("2")
);
assert_eq!(opts.timeout(), Duration::from_millis(250));
}
}