1use std::collections::HashMap;
18use std::sync::Arc;
19use std::time::Duration;
20
21use http_body_util::{BodyExt, Full, LengthLimitError, Limited};
22use hyper::body::Bytes;
23use hyper::header;
24#[cfg(not(feature = "tls-rustls"))]
25use hyper_util::client::legacy::Client;
26#[cfg(not(feature = "tls-rustls"))]
27use hyper_util::client::legacy::connect::HttpConnector;
28#[cfg(not(feature = "tls-rustls"))]
29use hyper_util::rt::TokioExecutor;
30use tokio::sync::RwLock;
31
32use a2a_protocol_types::AgentCard;
33
34use crate::error::{ClientError, ClientResult};
35
36pub const AGENT_CARD_PATH: &str = "/.well-known/agent-card.json";
38
39pub(crate) const MAX_CARD_BODY_SIZE: u64 = 2 * 1024 * 1024;
43
44pub(crate) const CARD_FETCH_BUDGET: Duration = Duration::from_secs(30);
65
66pub(crate) const fn exceeds_card_body_size(len: u64, max: u64) -> bool {
70 len > max
71}
72
73#[derive(Debug, Clone)]
97pub struct CardFetchOptions {
98 headers: HashMap<String, String>,
99 timeout: Duration,
100}
101
102impl Default for CardFetchOptions {
103 fn default() -> Self {
105 Self {
106 headers: HashMap::new(),
107 timeout: CARD_FETCH_BUDGET,
108 }
109 }
110}
111
112impl CardFetchOptions {
113 #[must_use]
116 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
117 self.headers.insert(name.into(), value.into());
118 self
119 }
120
121 #[must_use]
123 pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
124 self.headers = headers;
125 self
126 }
127
128 #[must_use]
130 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
131 self.timeout = timeout;
132 self
133 }
134
135 #[must_use]
137 pub const fn headers(&self) -> &HashMap<String, String> {
138 &self.headers
139 }
140
141 #[must_use]
143 pub const fn timeout(&self) -> Duration {
144 self.timeout
145 }
146}
147
148pub async fn resolve_agent_card(base_url: &str) -> ClientResult<AgentCard> {
162 resolve_agent_card_with_options(base_url, &CardFetchOptions::default()).await
163}
164
165pub async fn resolve_agent_card_with_options(
173 base_url: &str,
174 options: &CardFetchOptions,
175) -> ClientResult<AgentCard> {
176 trace_info!(base_url, "resolving agent card");
177 let url = build_card_url(base_url, AGENT_CARD_PATH)?;
178 fetch_card(&url, None, options).await
179}
180
181pub async fn resolve_agent_card_with_path(base_url: &str, path: &str) -> ClientResult<AgentCard> {
190 let url = build_card_url(base_url, path)?;
191 fetch_card(&url, None, &CardFetchOptions::default()).await
192}
193
194pub async fn fetch_card_from_url(url: &str) -> ClientResult<AgentCard> {
204 fetch_card_from_url_with_options(url, &CardFetchOptions::default()).await
205}
206
207pub async fn fetch_card_from_url_with_options(
213 url: &str,
214 options: &CardFetchOptions,
215) -> ClientResult<AgentCard> {
216 fetch_card(url, None, options).await
217}
218
219#[derive(Debug, Clone)]
223struct CachedCard {
224 card: AgentCard,
225 etag: Option<String>,
226 last_modified: Option<String>,
227}
228
229#[derive(Debug, Clone)]
235pub struct CachingCardResolver {
236 url: String,
237 cache: Arc<RwLock<Option<CachedCard>>>,
238}
239
240impl CachingCardResolver {
241 pub fn new(base_url: &str) -> ClientResult<Self> {
248 let url = build_card_url(base_url, AGENT_CARD_PATH)?;
249 Ok(Self {
250 url,
251 cache: Arc::new(RwLock::new(None)),
252 })
253 }
254
255 pub fn with_path(base_url: &str, path: &str) -> ClientResult<Self> {
261 let url = build_card_url(base_url, path)?;
262 Ok(Self {
263 url,
264 cache: Arc::new(RwLock::new(None)),
265 })
266 }
267
268 pub async fn resolve(&self) -> ClientResult<AgentCard> {
278 trace_info!(url = %self.url, "resolving agent card (cached)");
279 let cached = self.cache.read().await.clone();
280 let (card, etag, last_modified) =
281 fetch_card_with_metadata(&self.url, cached.as_ref(), &CardFetchOptions::default())
282 .await?;
283
284 {
286 let mut guard = self.cache.write().await;
287 *guard = Some(CachedCard {
288 card: card.clone(),
289 etag,
290 last_modified,
291 });
292 }
293
294 Ok(card)
295 }
296
297 pub async fn invalidate(&self) {
299 let mut cache = self.cache.write().await;
300 *cache = None;
301 }
302}
303
304fn build_card_url(base_url: &str, path: &str) -> ClientResult<String> {
307 if base_url.is_empty() {
308 return Err(ClientError::InvalidEndpoint(
309 "base URL must not be empty".into(),
310 ));
311 }
312 if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
313 return Err(ClientError::InvalidEndpoint(format!(
314 "base URL must start with http:// or https://: {base_url}"
315 )));
316 }
317 let base = base_url.trim_end_matches('/');
318 let path = if path.starts_with('/') {
319 path.to_owned()
320 } else {
321 format!("/{path}")
322 };
323 Ok(format!("{base}{path}"))
324}
325
326async fn fetch_card(
327 url: &str,
328 cached: Option<&CachedCard>,
329 options: &CardFetchOptions,
330) -> ClientResult<AgentCard> {
331 let (card, _, _) = fetch_card_with_metadata(url, cached, options).await?;
332 Ok(card)
333}
334
335#[allow(clippy::too_many_lines)]
336async fn fetch_card_with_metadata(
337 url: &str,
338 cached: Option<&CachedCard>,
339 options: &CardFetchOptions,
340) -> ClientResult<(AgentCard, Option<String>, Option<String>)> {
341 #[cfg(not(feature = "tls-rustls"))]
342 let client: Client<HttpConnector, Full<Bytes>> = {
343 let mut connector = HttpConnector::new();
344 connector.set_connect_timeout(Some(Duration::from_secs(10)));
345 connector.set_nodelay(true);
346 Client::builder(TokioExecutor::new()).build(connector)
347 };
348
349 #[cfg(feature = "tls-rustls")]
350 let client = crate::tls::build_https_client();
351
352 let mut builder = hyper::Request::builder()
353 .method(hyper::Method::GET)
354 .uri(url)
355 .header(header::ACCEPT, "application/json");
356
357 for (name, value) in &options.headers {
360 builder = builder.header(name.as_str(), value.as_str());
361 }
362
363 if let Some(cached) = cached {
365 if let Some(ref etag) = cached.etag {
366 builder = builder.header("if-none-match", etag.as_str());
367 }
368 if let Some(ref lm) = cached.last_modified {
369 builder = builder.header("if-modified-since", lm.as_str());
370 }
371 }
372
373 let req = builder
374 .body(Full::new(Bytes::new()))
375 .map_err(|e| ClientError::Transport(e.to_string()))?;
376
377 let deadline = tokio::time::Instant::now() + options.timeout;
380
381 let resp = tokio::time::timeout_at(deadline, client.request(req))
382 .await
383 .map_err(|_| ClientError::Transport("agent card fetch timed out".into()))?
384 .map_err(|e| ClientError::HttpClient(e.to_string()))?;
385
386 let status = resp.status();
387 let retry_after = crate::error::parse_retry_after(resp.headers());
388
389 if status == hyper::StatusCode::NOT_MODIFIED
391 && let Some(cached) = cached
392 {
393 return Ok((
394 cached.card.clone(),
395 cached.etag.clone(),
396 cached.last_modified.clone(),
397 ));
398 }
399 let etag = resp
403 .headers()
404 .get("etag")
405 .and_then(|v| v.to_str().ok())
406 .map(str::to_owned);
407 let last_modified = resp
408 .headers()
409 .get("last-modified")
410 .and_then(|v| v.to_str().ok())
411 .map(str::to_owned);
412
413 let max_card_body_size: u64 = MAX_CARD_BODY_SIZE;
417 if let Some(cl) = resp.headers().get(header::CONTENT_LENGTH)
418 && let Ok(len) = cl.to_str().unwrap_or("0").parse::<u64>()
419 && exceeds_card_body_size(len, max_card_body_size)
420 {
421 return Err(ClientError::Transport(format!(
422 "agent card response too large: {len} bytes exceeds {max_card_body_size} byte limit"
423 )));
424 }
425
426 let cap = usize::try_from(max_card_body_size).unwrap_or(usize::MAX);
433 let body_bytes = match tokio::time::timeout_at(
435 deadline,
436 Limited::new(resp.into_body(), cap).collect(),
437 )
438 .await
439 {
440 Err(_) => {
441 return Err(ClientError::Transport(
442 "agent card body read timed out".into(),
443 ));
444 }
445 Ok(Ok(collected)) => collected.to_bytes(),
446 Ok(Err(err)) => {
447 return Err(if err.downcast_ref::<LengthLimitError>().is_some() {
448 ClientError::Transport(format!(
449 "agent card response too large: exceeds {max_card_body_size} byte limit"
450 ))
451 } else {
452 ClientError::Transport(format!("agent card body read failed: {err}"))
453 });
454 }
455 };
456
457 if !status.is_success() {
458 let body_str = String::from_utf8_lossy(&body_bytes).into_owned();
459 return Err(ClientError::UnexpectedStatus {
460 status: status.as_u16(),
461 body: body_str,
462 retry_after,
463 });
464 }
465
466 let card =
467 serde_json::from_slice::<AgentCard>(&body_bytes).map_err(ClientError::Serialization)?;
468 Ok((card, etag, last_modified))
469}
470
471#[cfg(test)]
474mod tests {
475 use super::*;
476
477 fn no_options() -> CardFetchOptions {
479 CardFetchOptions::default()
480 }
481
482 #[test]
483 fn build_card_url_standard() {
484 let url = build_card_url("http://localhost:8080", AGENT_CARD_PATH).unwrap();
485 assert_eq!(url, "http://localhost:8080/.well-known/agent-card.json");
486 }
487
488 #[test]
489 fn build_card_url_trailing_slash() {
490 let url = build_card_url("http://localhost:8080/", AGENT_CARD_PATH).unwrap();
491 assert_eq!(url, "http://localhost:8080/.well-known/agent-card.json");
492 }
493
494 #[test]
495 fn build_card_url_custom_path() {
496 let url = build_card_url("http://localhost:8080", "/api/card.json").unwrap();
497 assert_eq!(url, "http://localhost:8080/api/card.json");
498 }
499
500 #[test]
501 fn build_card_url_rejects_empty() {
502 assert!(build_card_url("", AGENT_CARD_PATH).is_err());
503 }
504
505 #[test]
506 fn build_card_url_rejects_non_http() {
507 assert!(build_card_url("ftp://example.com", AGENT_CARD_PATH).is_err());
508 }
509
510 #[test]
511 fn caching_resolver_new() {
512 let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
513 assert_eq!(
514 resolver.url,
515 "http://localhost:8080/.well-known/agent-card.json"
516 );
517 }
518
519 #[test]
520 fn caching_resolver_new_rejects_invalid_url() {
521 assert!(CachingCardResolver::new("").is_err());
522 assert!(CachingCardResolver::new("ftp://example.com").is_err());
523 }
524
525 #[test]
526 fn caching_resolver_with_path() {
527 let resolver =
528 CachingCardResolver::with_path("http://localhost:8080", "/custom/card.json").unwrap();
529 assert_eq!(resolver.url, "http://localhost:8080/custom/card.json");
530 }
531
532 #[tokio::test]
533 async fn caching_resolver_invalidate_empty() {
534 let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
535 assert!(resolver.cache.read().await.is_none());
537 resolver.invalidate().await;
538 assert!(resolver.cache.read().await.is_none());
539 }
540
541 #[tokio::test]
542 async fn caching_resolver_invalidate_clears_populated_cache() {
543 use a2a_protocol_types::{AgentCapabilities, AgentCard};
544
545 let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
546
547 {
549 let mut guard = resolver.cache.write().await;
550 *guard = Some(CachedCard {
551 card: AgentCard {
552 url: None,
553 name: "cached".into(),
554 version: "1.0".into(),
555 description: "Cached agent".into(),
556 supported_interfaces: vec![],
557 provider: None,
558 icon_url: None,
559 documentation_url: None,
560 capabilities: AgentCapabilities::none(),
561 security_schemes: None,
562 security_requirements: None,
563 default_input_modes: vec![],
564 default_output_modes: vec![],
565 skills: vec![],
566 signatures: None,
567 },
568 etag: Some("test-etag".into()),
569 last_modified: None,
570 });
571 }
572
573 {
575 let cached = resolver.cache.read().await;
576 let entry = cached.as_ref().expect("cache should be populated");
577 assert_eq!(entry.card.name, "cached");
578 assert_eq!(entry.etag, Some("test-etag".into()));
579 drop(cached);
580 }
581
582 resolver.invalidate().await;
584 assert!(
585 resolver.cache.read().await.is_none(),
586 "invalidate must clear a populated cache"
587 );
588 }
589
590 #[tokio::test]
593 async fn fetch_card_with_metadata_non_success_status() {
594 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
596 let addr = listener.local_addr().unwrap();
597
598 tokio::spawn(async move {
599 loop {
600 let (stream, _) = listener.accept().await.unwrap();
601 let io = hyper_util::rt::TokioIo::new(stream);
602 tokio::spawn(async move {
603 let service = hyper::service::service_fn(|_req| async {
604 Ok::<_, hyper::Error>(
605 hyper::Response::builder()
606 .status(404)
607 .body(http_body_util::Full::new(hyper::body::Bytes::from(
608 "Not Found",
609 )))
610 .unwrap(),
611 )
612 });
613 let _ = hyper_util::server::conn::auto::Builder::new(
614 hyper_util::rt::TokioExecutor::new(),
615 )
616 .serve_connection(io, service)
617 .await;
618 });
619 }
620 });
621
622 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
623 let result = fetch_card_with_metadata(&url, None, &no_options()).await;
624 assert!(result.is_err());
625 match result.unwrap_err() {
626 ClientError::UnexpectedStatus { status, body, .. } => {
627 assert_eq!(status, 404);
628 assert!(body.contains("Not Found"));
629 }
630 other => panic!("expected UnexpectedStatus, got {other:?}"),
631 }
632 }
633
634 #[tokio::test]
636 async fn fetch_card_with_metadata_304_returns_cached() {
637 use a2a_protocol_types::{AgentCapabilities, AgentCard};
638
639 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
641 let addr = listener.local_addr().unwrap();
642
643 tokio::spawn(async move {
644 loop {
645 let (stream, _) = listener.accept().await.unwrap();
646 let io = hyper_util::rt::TokioIo::new(stream);
647 tokio::spawn(async move {
648 let service = hyper::service::service_fn(|_req| async {
649 Ok::<_, hyper::Error>(
650 hyper::Response::builder()
651 .status(304)
652 .body(http_body_util::Full::new(hyper::body::Bytes::new()))
653 .unwrap(),
654 )
655 });
656 let _ = hyper_util::server::conn::auto::Builder::new(
657 hyper_util::rt::TokioExecutor::new(),
658 )
659 .serve_connection(io, service)
660 .await;
661 });
662 }
663 });
664
665 let cached = CachedCard {
666 card: AgentCard {
667 url: None,
668 name: "cached-agent".into(),
669 version: "2.0".into(),
670 description: "Cached".into(),
671 supported_interfaces: vec![],
672 provider: None,
673 icon_url: None,
674 documentation_url: None,
675 capabilities: AgentCapabilities::none(),
676 security_schemes: None,
677 security_requirements: None,
678 default_input_modes: vec![],
679 default_output_modes: vec![],
680 skills: vec![],
681 signatures: None,
682 },
683 etag: Some("\"abc123\"".into()),
684 last_modified: None,
685 };
686
687 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
688 let (card, etag, _) = fetch_card_with_metadata(&url, Some(&cached), &no_options())
689 .await
690 .unwrap();
691 assert_eq!(card.name, "cached-agent");
692 assert_eq!(etag, Some("\"abc123\"".into()));
693 }
694
695 #[tokio::test]
697 async fn fetch_card_with_metadata_200_parses_card() {
698 use a2a_protocol_types::{AgentCard, AgentInterface};
699
700 let card = AgentCard::new(
701 "test-agent",
702 "1.0",
703 AgentInterface::jsonrpc("http://localhost:9090"),
704 )
705 .with_description("A test");
706 let card_json = serde_json::to_string(&card).unwrap();
707
708 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
709 let addr = listener.local_addr().unwrap();
710
711 tokio::spawn(async move {
712 loop {
713 let (stream, _) = listener.accept().await.unwrap();
714 let io = hyper_util::rt::TokioIo::new(stream);
715 let body = card_json.clone();
716 tokio::spawn(async move {
717 let service = hyper::service::service_fn(move |_req| {
718 let body = body.clone();
719 async move {
720 Ok::<_, hyper::Error>(
721 hyper::Response::builder()
722 .status(200)
723 .header("etag", "\"xyz\"")
724 .header("last-modified", "Mon, 01 Jan 2026 00:00:00 GMT")
725 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
726 .unwrap(),
727 )
728 }
729 });
730 let _ = hyper_util::server::conn::auto::Builder::new(
731 hyper_util::rt::TokioExecutor::new(),
732 )
733 .serve_connection(io, service)
734 .await;
735 });
736 }
737 });
738
739 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
740 let (parsed_card, etag, last_modified) =
741 fetch_card_with_metadata(&url, None, &no_options())
742 .await
743 .unwrap();
744 assert_eq!(parsed_card.name, "test-agent");
745 assert_eq!(etag, Some("\"xyz\"".into()));
746 assert_eq!(last_modified, Some("Mon, 01 Jan 2026 00:00:00 GMT".into()));
747 }
748
749 #[allow(clippy::too_many_lines)]
751 #[tokio::test]
752 async fn caching_resolver_resolve_fetches_and_caches() {
753 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
754
755 let card = AgentCard {
756 url: None,
757 name: "resolver-test".into(),
758 version: "1.0".into(),
759 description: "Resolver test agent".into(),
760 supported_interfaces: vec![AgentInterface {
761 url: "http://localhost:9090".into(),
762 protocol_binding: "JSONRPC".into(),
763 protocol_version: "1.0.0".into(),
764 tenant: None,
765 }],
766 provider: None,
767 icon_url: None,
768 documentation_url: None,
769 capabilities: AgentCapabilities::none(),
770 security_schemes: None,
771 security_requirements: None,
772 default_input_modes: vec![],
773 default_output_modes: vec![],
774 skills: vec![],
775 signatures: None,
776 };
777 let card_json = serde_json::to_string(&card).unwrap();
778
779 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
780 let addr = listener.local_addr().unwrap();
781
782 tokio::spawn(async move {
783 loop {
784 let (stream, _) = listener.accept().await.unwrap();
785 let io = hyper_util::rt::TokioIo::new(stream);
786 let body = card_json.clone();
787 tokio::spawn(async move {
788 let service = hyper::service::service_fn(move |_req| {
789 let body = body.clone();
790 async move {
791 Ok::<_, hyper::Error>(
792 hyper::Response::builder()
793 .status(200)
794 .header("etag", "\"res-etag\"")
795 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
796 .unwrap(),
797 )
798 }
799 });
800 let _ = hyper_util::server::conn::auto::Builder::new(
801 hyper_util::rt::TokioExecutor::new(),
802 )
803 .serve_connection(io, service)
804 .await;
805 });
806 }
807 });
808
809 let base_url = format!("http://127.0.0.1:{}", addr.port());
810 let resolver = CachingCardResolver::with_path(&base_url, "/agent.json").unwrap();
811 assert!(
812 resolver.cache.read().await.is_none(),
813 "cache should start empty"
814 );
815
816 let fetched = resolver.resolve().await.unwrap();
817 assert_eq!(fetched.name, "resolver-test");
818
819 let cached = resolver.cache.read().await;
821 let entry = cached
822 .as_ref()
823 .expect("cache should be populated after resolve");
824 assert_eq!(entry.card.name, "resolver-test");
825 assert_eq!(entry.etag, Some("\"res-etag\"".into()));
826 drop(cached);
827 }
828
829 #[tokio::test]
831 async fn caching_resolver_resolve_returns_error_on_failure() {
832 let resolver = CachingCardResolver::with_path("http://127.0.0.1:1", "/agent.json").unwrap();
834 let result = resolver.resolve().await;
835 assert!(
836 result.is_err(),
837 "resolve should fail with unreachable server"
838 );
839 }
840
841 #[test]
843 fn build_card_url_path_without_leading_slash() {
844 let url = build_card_url("http://localhost:8080", "custom/card.json").unwrap();
845 assert_eq!(url, "http://localhost:8080/custom/card.json");
846 }
847
848 #[tokio::test]
850 async fn fetch_card_from_url_success() {
851 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
852
853 let card = AgentCard {
854 url: None,
855 name: "url-fetch-test".into(),
856 version: "1.0".into(),
857 description: "URL fetch test".into(),
858 supported_interfaces: vec![AgentInterface {
859 url: "http://localhost:9090".into(),
860 protocol_binding: "JSONRPC".into(),
861 protocol_version: "1.0.0".into(),
862 tenant: None,
863 }],
864 provider: None,
865 icon_url: None,
866 documentation_url: None,
867 capabilities: AgentCapabilities::none(),
868 security_schemes: None,
869 security_requirements: None,
870 default_input_modes: vec![],
871 default_output_modes: vec![],
872 skills: vec![],
873 signatures: None,
874 };
875 let card_json = serde_json::to_string(&card).unwrap();
876
877 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
878 let addr = listener.local_addr().unwrap();
879
880 tokio::spawn(async move {
881 loop {
882 let (stream, _) = listener.accept().await.unwrap();
883 let io = hyper_util::rt::TokioIo::new(stream);
884 let body = card_json.clone();
885 tokio::spawn(async move {
886 let service = hyper::service::service_fn(move |_req| {
887 let body = body.clone();
888 async move {
889 Ok::<_, hyper::Error>(
890 hyper::Response::builder()
891 .status(200)
892 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
893 .unwrap(),
894 )
895 }
896 });
897 let _ = hyper_util::server::conn::auto::Builder::new(
898 hyper_util::rt::TokioExecutor::new(),
899 )
900 .serve_connection(io, service)
901 .await;
902 });
903 }
904 });
905
906 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
907 let fetched = fetch_card_from_url(&url).await.unwrap();
908 assert_eq!(fetched.name, "url-fetch-test");
909 }
910
911 #[tokio::test]
913 async fn resolve_agent_card_with_path_success() {
914 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
915
916 let card = AgentCard {
917 url: None,
918 name: "path-resolve-test".into(),
919 version: "2.0".into(),
920 description: "Path resolve test".into(),
921 supported_interfaces: vec![AgentInterface {
922 url: "http://localhost:9090".into(),
923 protocol_binding: "JSONRPC".into(),
924 protocol_version: "1.0.0".into(),
925 tenant: None,
926 }],
927 provider: None,
928 icon_url: None,
929 documentation_url: None,
930 capabilities: AgentCapabilities::none(),
931 security_schemes: None,
932 security_requirements: None,
933 default_input_modes: vec![],
934 default_output_modes: vec![],
935 skills: vec![],
936 signatures: None,
937 };
938 let card_json = serde_json::to_string(&card).unwrap();
939
940 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
941 let addr = listener.local_addr().unwrap();
942
943 tokio::spawn(async move {
944 loop {
945 let (stream, _) = listener.accept().await.unwrap();
946 let io = hyper_util::rt::TokioIo::new(stream);
947 let body = card_json.clone();
948 tokio::spawn(async move {
949 let service = hyper::service::service_fn(move |_req| {
950 let body = body.clone();
951 async move {
952 Ok::<_, hyper::Error>(
953 hyper::Response::builder()
954 .status(200)
955 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
956 .unwrap(),
957 )
958 }
959 });
960 let _ = hyper_util::server::conn::auto::Builder::new(
961 hyper_util::rt::TokioExecutor::new(),
962 )
963 .serve_connection(io, service)
964 .await;
965 });
966 }
967 });
968
969 let base_url = format!("http://127.0.0.1:{}", addr.port());
970 let fetched = resolve_agent_card_with_path(&base_url, "/custom.json")
971 .await
972 .unwrap();
973 assert_eq!(fetched.name, "path-resolve-test");
974 }
975
976 #[tokio::test]
978 async fn fetch_card_rejects_oversized_content_length() {
979 use tokio::io::AsyncWriteExt;
980
981 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
982 let addr = listener.local_addr().unwrap();
983
984 tokio::spawn(async move {
987 loop {
988 let (mut stream, _) = listener.accept().await.unwrap();
989 tokio::spawn(async move {
990 let mut buf = [0u8; 4096];
992 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
993 let response = "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 10000000\r\n\r\nsmall";
995 let _ = stream.write_all(response.as_bytes()).await;
996 drop(stream);
998 });
999 }
1000 });
1001
1002 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
1003 let result = fetch_card_with_metadata(&url, None, &no_options()).await;
1004 match result {
1005 Err(ClientError::Transport(msg)) => {
1006 assert!(
1007 msg.contains("too large"),
1008 "should mention size limit: {msg}"
1009 );
1010 }
1011 other => panic!("expected Transport error about size, got {other:?}"),
1012 }
1013 }
1014
1015 #[tokio::test]
1022 async fn fetch_card_accepts_content_length_at_exact_limit() {
1023 use tokio::io::AsyncWriteExt;
1024
1025 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1026 let addr = listener.local_addr().unwrap();
1027 let max_size: u64 = 2 * 1024 * 1024; tokio::spawn(async move {
1030 loop {
1031 let (mut stream, _) = listener.accept().await.unwrap();
1032 tokio::spawn(async move {
1033 let mut buf = [0u8; 4096];
1034 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
1035 let response = format!(
1039 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {max_size}\r\n\r\nsmall"
1040 );
1041 let _ = stream.write_all(response.as_bytes()).await;
1042 drop(stream);
1043 });
1044 }
1045 });
1046
1047 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
1048 let result = fetch_card_with_metadata(&url, None, &no_options()).await;
1049
1050 match &result {
1052 Err(ClientError::Transport(msg)) if msg.contains("too large") => {
1053 panic!("Content-Length at exact limit should not be rejected: {msg}");
1054 }
1055 _ => {} }
1057 }
1058
1059 #[tokio::test]
1064 async fn fetch_card_rejects_oversized_body_without_content_length() {
1065 use tokio::io::AsyncWriteExt;
1066
1067 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1068 let addr = listener.local_addr().unwrap();
1069 let max_size = 2 * 1024 * 1024_usize; tokio::spawn(async move {
1072 loop {
1073 let (mut stream, _) = listener.accept().await.unwrap();
1074 let body_size = max_size + 1;
1075 tokio::spawn(async move {
1076 let mut buf = [0u8; 4096];
1077 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
1078 let header = "HTTP/1.0 200 OK\r\ncontent-type: application/json\r\n\r\n";
1080 let _ = stream.write_all(header.as_bytes()).await;
1081 let chunk = vec![b'x'; 64 * 1024];
1083 let mut remaining = body_size;
1084 while remaining > 0 {
1085 let n = remaining.min(chunk.len());
1086 if stream.write_all(&chunk[..n]).await.is_err() {
1087 break;
1088 }
1089 remaining -= n;
1090 }
1091 drop(stream);
1092 });
1093 }
1094 });
1095
1096 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
1097 let result = fetch_card_with_metadata(&url, None, &no_options()).await;
1098
1099 match result {
1100 Err(ClientError::Transport(msg)) => {
1101 assert!(
1102 msg.contains("too large"),
1103 "should mention size limit: {msg}"
1104 );
1105 }
1106 other => panic!("expected Transport error about size for body > limit, got {other:?}"),
1107 }
1108 }
1109
1110 #[tokio::test]
1112 async fn fetch_card_with_metadata_304_with_last_modified() {
1113 use a2a_protocol_types::{AgentCapabilities, AgentCard};
1114
1115 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1116 let addr = listener.local_addr().unwrap();
1117
1118 tokio::spawn(async move {
1119 loop {
1120 let (stream, _) = listener.accept().await.unwrap();
1121 let io = hyper_util::rt::TokioIo::new(stream);
1122 tokio::spawn(async move {
1123 let service = hyper::service::service_fn(|_req| async {
1124 Ok::<_, hyper::Error>(
1125 hyper::Response::builder()
1126 .status(304)
1127 .body(http_body_util::Full::new(hyper::body::Bytes::new()))
1128 .unwrap(),
1129 )
1130 });
1131 let _ = hyper_util::server::conn::auto::Builder::new(
1132 hyper_util::rt::TokioExecutor::new(),
1133 )
1134 .serve_connection(io, service)
1135 .await;
1136 });
1137 }
1138 });
1139
1140 let cached = CachedCard {
1141 card: AgentCard {
1142 url: None,
1143 name: "lm-cached".into(),
1144 version: "1.0".into(),
1145 description: "Last-modified cached".into(),
1146 supported_interfaces: vec![],
1147 provider: None,
1148 icon_url: None,
1149 documentation_url: None,
1150 capabilities: AgentCapabilities::none(),
1151 security_schemes: None,
1152 security_requirements: None,
1153 default_input_modes: vec![],
1154 default_output_modes: vec![],
1155 skills: vec![],
1156 signatures: None,
1157 },
1158 etag: None,
1159 last_modified: Some("Mon, 01 Jan 2026 00:00:00 GMT".into()),
1160 };
1161
1162 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
1163 let (card, _, last_modified) = fetch_card_with_metadata(&url, Some(&cached), &no_options())
1164 .await
1165 .unwrap();
1166 assert_eq!(card.name, "lm-cached");
1167 assert_eq!(last_modified, Some("Mon, 01 Jan 2026 00:00:00 GMT".into()));
1168 }
1169
1170 #[test]
1173 fn exceeds_card_body_size_over_limit() {
1174 assert!(exceeds_card_body_size(
1175 MAX_CARD_BODY_SIZE + 1,
1176 MAX_CARD_BODY_SIZE
1177 ));
1178 }
1179
1180 #[test]
1183 fn exceeds_card_body_size_exactly_at_limit_is_ok() {
1184 assert!(!exceeds_card_body_size(
1185 MAX_CARD_BODY_SIZE,
1186 MAX_CARD_BODY_SIZE
1187 ));
1188 }
1189
1190 #[test]
1191 fn exceeds_card_body_size_under_limit() {
1192 assert!(!exceeds_card_body_size(0, MAX_CARD_BODY_SIZE));
1193 assert!(!exceeds_card_body_size(1024, MAX_CARD_BODY_SIZE));
1194 assert!(!exceeds_card_body_size(
1195 MAX_CARD_BODY_SIZE - 1,
1196 MAX_CARD_BODY_SIZE
1197 ));
1198 }
1199
1200 #[test]
1201 fn exceeds_card_body_size_custom_limit() {
1202 assert!(exceeds_card_body_size(11, 10));
1203 assert!(!exceeds_card_body_size(10, 10));
1204 assert!(!exceeds_card_body_size(9, 10));
1205 }
1206
1207 async fn spawn_gated_card_server(
1210 card_json: String,
1211 name: &'static str,
1212 value: &'static str,
1213 ) -> std::net::SocketAddr {
1214 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1215 let addr = listener.local_addr().unwrap();
1216 tokio::spawn(async move {
1217 loop {
1218 let (stream, _) = listener.accept().await.unwrap();
1219 let io = hyper_util::rt::TokioIo::new(stream);
1220 let body = card_json.clone();
1221 tokio::spawn(async move {
1222 let service = hyper::service::service_fn(move |req: hyper::Request<_>| {
1223 let body = body.clone();
1224 async move {
1225 let admitted = req
1226 .headers()
1227 .get(name)
1228 .and_then(|v| v.to_str().ok())
1229 .is_some_and(|v| v == value);
1230 let (status, body) = if admitted {
1231 (200, body)
1232 } else {
1233 (401, "{\"error\":\"unauthorized\"}".to_owned())
1234 };
1235 Ok::<_, hyper::Error>(
1236 hyper::Response::builder()
1237 .status(status)
1238 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
1239 .unwrap(),
1240 )
1241 }
1242 });
1243 let _ = hyper_util::server::conn::auto::Builder::new(
1244 hyper_util::rt::TokioExecutor::new(),
1245 )
1246 .serve_connection(io, service)
1247 .await;
1248 });
1249 }
1250 });
1251 addr
1252 }
1253
1254 #[tokio::test]
1259 async fn a_card_behind_a_required_header_needs_the_options_variant() {
1260 use a2a_protocol_types::{AgentCard, AgentInterface};
1261
1262 let card = AgentCard::new(
1263 "gated",
1264 "1.0",
1265 AgentInterface::jsonrpc("http://localhost:9090"),
1266 );
1267 let addr = spawn_gated_card_server(
1268 serde_json::to_string(&card).unwrap(),
1269 "authorization",
1270 "Bearer s3cret",
1271 )
1272 .await;
1273 let base = format!("http://127.0.0.1:{}", addr.port());
1274
1275 let refused = resolve_agent_card(&base).await;
1276 assert!(
1277 matches!(
1278 refused,
1279 Err(ClientError::UnexpectedStatus { status: 401, .. })
1280 ),
1281 "no header, so the server must refuse: {refused:?}"
1282 );
1283
1284 let options = CardFetchOptions::default().with_header("authorization", "Bearer s3cret");
1285 let fetched = resolve_agent_card_with_options(&base, &options)
1286 .await
1287 .expect("the header admits the fetch");
1288 assert_eq!(fetched.name, "gated");
1289
1290 let url = format!("{base}{AGENT_CARD_PATH}");
1292 let fetched = fetch_card_from_url_with_options(&url, &options)
1293 .await
1294 .expect("same header, same door");
1295 assert_eq!(fetched.name, "gated");
1296 }
1297
1298 #[tokio::test]
1304 async fn the_card_fetch_budget_is_the_configured_one() {
1305 let budget = Duration::from_millis(600);
1306 let addr = crate::transport::test_support::spawn_stalling_server(
1307 "HTTP/1.1 200 OK",
1308 budget * 2 / 3,
1309 )
1310 .await;
1311 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
1312 let options = CardFetchOptions::default().with_timeout(budget);
1313
1314 let started = std::time::Instant::now();
1315 let result = fetch_card_from_url_with_options(&url, &options).await;
1316 let elapsed = started.elapsed();
1317
1318 assert!(
1319 matches!(&result, Err(ClientError::Transport(msg)) if msg.contains("timed out")),
1320 "{result:?}"
1321 );
1322 assert!(
1323 elapsed >= budget && elapsed < budget * 3 / 2,
1324 "one budget, not two: took {elapsed:?} against {budget:?}"
1325 );
1326 }
1327
1328 #[test]
1332 fn card_fetch_options_setters_set_their_fields() {
1333 let mut map = HashMap::new();
1334 map.insert("authorization".to_owned(), "Bearer t".to_owned());
1335 let opts = CardFetchOptions::default()
1336 .with_header("x-first", "1")
1337 .with_headers(map)
1338 .with_header("x-second", "2")
1339 .with_timeout(Duration::from_millis(250));
1340 assert_eq!(
1341 opts.headers().len(),
1342 2,
1343 "with_headers replaces, with_header adds"
1344 );
1345 assert_eq!(
1346 opts.headers().get("authorization").map(String::as_str),
1347 Some("Bearer t")
1348 );
1349 assert_eq!(
1350 opts.headers().get("x-second").map(String::as_str),
1351 Some("2")
1352 );
1353 assert_eq!(opts.timeout(), Duration::from_millis(250));
1354 }
1355}