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]
981 async fn resolve_agent_card_fetches_the_well_known_card() {
982 use a2a_protocol_types::{AgentCard, AgentInterface};
983
984 let card = AgentCard::new(
985 "well-known-test",
986 "1.0.0",
987 AgentInterface::jsonrpc("http://localhost:9090"),
988 );
989 let card_json = serde_json::to_string(&card).unwrap();
990
991 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
992 let addr = listener.local_addr().unwrap();
993 tokio::spawn(async move {
994 while let Ok((stream, _)) = listener.accept().await {
995 let io = hyper_util::rt::TokioIo::new(stream);
996 let body = card_json.clone();
997 tokio::spawn(async move {
998 let service = hyper::service::service_fn(
999 move |req: hyper::Request<hyper::body::Incoming>| {
1000 let (status, body) =
1001 if req.uri().path() == "/.well-known/agent-card.json" {
1002 (200, body.clone())
1003 } else {
1004 (404, String::new())
1005 };
1006 async move {
1007 Ok::<_, hyper::Error>(
1008 hyper::Response::builder()
1009 .status(status)
1010 .body(http_body_util::Full::new(hyper::body::Bytes::from(
1011 body,
1012 )))
1013 .unwrap(),
1014 )
1015 }
1016 },
1017 );
1018 let _ = hyper_util::server::conn::auto::Builder::new(
1019 hyper_util::rt::TokioExecutor::new(),
1020 )
1021 .serve_connection(io, service)
1022 .await;
1023 });
1024 }
1025 });
1026
1027 let fetched = resolve_agent_card(&format!("http://127.0.0.1:{}", addr.port()))
1028 .await
1029 .expect("the well-known card");
1030 assert_eq!(
1031 (fetched.name.as_str(), fetched.version.as_str()),
1032 ("well-known-test", "1.0.0")
1033 );
1034 assert_eq!(
1035 fetched.supported_interfaces[0].url,
1036 card.supported_interfaces[0].url
1037 );
1038 }
1039
1040 #[tokio::test]
1042 async fn fetch_card_rejects_oversized_content_length() {
1043 use tokio::io::AsyncWriteExt;
1044
1045 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1046 let addr = listener.local_addr().unwrap();
1047
1048 tokio::spawn(async move {
1051 loop {
1052 let (mut stream, _) = listener.accept().await.unwrap();
1053 tokio::spawn(async move {
1054 let mut buf = [0u8; 4096];
1056 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
1057 let response = "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 10000000\r\n\r\nsmall";
1059 let _ = stream.write_all(response.as_bytes()).await;
1060 drop(stream);
1062 });
1063 }
1064 });
1065
1066 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
1067 let result = fetch_card_with_metadata(&url, None, &no_options()).await;
1068 match result {
1069 Err(ClientError::Transport(msg)) => {
1070 assert!(
1071 msg.contains("too large"),
1072 "should mention size limit: {msg}"
1073 );
1074 }
1075 other => panic!("expected Transport error about size, got {other:?}"),
1076 }
1077 }
1078
1079 #[tokio::test]
1086 async fn fetch_card_accepts_content_length_at_exact_limit() {
1087 use tokio::io::AsyncWriteExt;
1088
1089 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1090 let addr = listener.local_addr().unwrap();
1091 let max_size: u64 = 2 * 1024 * 1024; tokio::spawn(async move {
1094 loop {
1095 let (mut stream, _) = listener.accept().await.unwrap();
1096 tokio::spawn(async move {
1097 let mut buf = [0u8; 4096];
1098 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
1099 let response = format!(
1103 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {max_size}\r\n\r\nsmall"
1104 );
1105 let _ = stream.write_all(response.as_bytes()).await;
1106 drop(stream);
1107 });
1108 }
1109 });
1110
1111 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
1112 let result = fetch_card_with_metadata(&url, None, &no_options()).await;
1113
1114 match &result {
1116 Err(ClientError::Transport(msg)) if msg.contains("too large") => {
1117 panic!("Content-Length at exact limit should not be rejected: {msg}");
1118 }
1119 _ => {} }
1121 }
1122
1123 #[tokio::test]
1128 async fn fetch_card_rejects_oversized_body_without_content_length() {
1129 use tokio::io::AsyncWriteExt;
1130
1131 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1132 let addr = listener.local_addr().unwrap();
1133 let max_size = 2 * 1024 * 1024_usize; tokio::spawn(async move {
1136 loop {
1137 let (mut stream, _) = listener.accept().await.unwrap();
1138 let body_size = max_size + 1;
1139 tokio::spawn(async move {
1140 let mut buf = [0u8; 4096];
1141 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
1142 let header = "HTTP/1.0 200 OK\r\ncontent-type: application/json\r\n\r\n";
1144 let _ = stream.write_all(header.as_bytes()).await;
1145 let chunk = vec![b'x'; 64 * 1024];
1147 let mut remaining = body_size;
1148 while remaining > 0 {
1149 let n = remaining.min(chunk.len());
1150 if stream.write_all(&chunk[..n]).await.is_err() {
1151 break;
1152 }
1153 remaining -= n;
1154 }
1155 drop(stream);
1156 });
1157 }
1158 });
1159
1160 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
1161 let result = fetch_card_with_metadata(&url, None, &no_options()).await;
1162
1163 match result {
1164 Err(ClientError::Transport(msg)) => {
1165 assert!(
1166 msg.contains("too large"),
1167 "should mention size limit: {msg}"
1168 );
1169 }
1170 other => panic!("expected Transport error about size for body > limit, got {other:?}"),
1171 }
1172 }
1173
1174 #[tokio::test]
1176 async fn fetch_card_with_metadata_304_with_last_modified() {
1177 use a2a_protocol_types::{AgentCapabilities, AgentCard};
1178
1179 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1180 let addr = listener.local_addr().unwrap();
1181
1182 tokio::spawn(async move {
1183 loop {
1184 let (stream, _) = listener.accept().await.unwrap();
1185 let io = hyper_util::rt::TokioIo::new(stream);
1186 tokio::spawn(async move {
1187 let service = hyper::service::service_fn(|_req| async {
1188 Ok::<_, hyper::Error>(
1189 hyper::Response::builder()
1190 .status(304)
1191 .body(http_body_util::Full::new(hyper::body::Bytes::new()))
1192 .unwrap(),
1193 )
1194 });
1195 let _ = hyper_util::server::conn::auto::Builder::new(
1196 hyper_util::rt::TokioExecutor::new(),
1197 )
1198 .serve_connection(io, service)
1199 .await;
1200 });
1201 }
1202 });
1203
1204 let cached = CachedCard {
1205 card: AgentCard {
1206 url: None,
1207 name: "lm-cached".into(),
1208 version: "1.0".into(),
1209 description: "Last-modified cached".into(),
1210 supported_interfaces: vec![],
1211 provider: None,
1212 icon_url: None,
1213 documentation_url: None,
1214 capabilities: AgentCapabilities::none(),
1215 security_schemes: None,
1216 security_requirements: None,
1217 default_input_modes: vec![],
1218 default_output_modes: vec![],
1219 skills: vec![],
1220 signatures: None,
1221 },
1222 etag: None,
1223 last_modified: Some("Mon, 01 Jan 2026 00:00:00 GMT".into()),
1224 };
1225
1226 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
1227 let (card, _, last_modified) = fetch_card_with_metadata(&url, Some(&cached), &no_options())
1228 .await
1229 .unwrap();
1230 assert_eq!(card.name, "lm-cached");
1231 assert_eq!(last_modified, Some("Mon, 01 Jan 2026 00:00:00 GMT".into()));
1232 }
1233
1234 #[test]
1237 fn exceeds_card_body_size_over_limit() {
1238 assert!(exceeds_card_body_size(
1239 MAX_CARD_BODY_SIZE + 1,
1240 MAX_CARD_BODY_SIZE
1241 ));
1242 }
1243
1244 #[test]
1247 fn exceeds_card_body_size_exactly_at_limit_is_ok() {
1248 assert!(!exceeds_card_body_size(
1249 MAX_CARD_BODY_SIZE,
1250 MAX_CARD_BODY_SIZE
1251 ));
1252 }
1253
1254 #[test]
1255 fn exceeds_card_body_size_under_limit() {
1256 assert!(!exceeds_card_body_size(0, MAX_CARD_BODY_SIZE));
1257 assert!(!exceeds_card_body_size(1024, MAX_CARD_BODY_SIZE));
1258 assert!(!exceeds_card_body_size(
1259 MAX_CARD_BODY_SIZE - 1,
1260 MAX_CARD_BODY_SIZE
1261 ));
1262 }
1263
1264 #[test]
1265 fn exceeds_card_body_size_custom_limit() {
1266 assert!(exceeds_card_body_size(11, 10));
1267 assert!(!exceeds_card_body_size(10, 10));
1268 assert!(!exceeds_card_body_size(9, 10));
1269 }
1270
1271 async fn spawn_gated_card_server(
1274 card_json: String,
1275 name: &'static str,
1276 value: &'static str,
1277 ) -> std::net::SocketAddr {
1278 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1279 let addr = listener.local_addr().unwrap();
1280 tokio::spawn(async move {
1281 loop {
1282 let (stream, _) = listener.accept().await.unwrap();
1283 let io = hyper_util::rt::TokioIo::new(stream);
1284 let body = card_json.clone();
1285 tokio::spawn(async move {
1286 let service = hyper::service::service_fn(move |req: hyper::Request<_>| {
1287 let body = body.clone();
1288 async move {
1289 let admitted = req
1290 .headers()
1291 .get(name)
1292 .and_then(|v| v.to_str().ok())
1293 .is_some_and(|v| v == value);
1294 let (status, body) = if admitted {
1295 (200, body)
1296 } else {
1297 (401, "{\"error\":\"unauthorized\"}".to_owned())
1298 };
1299 Ok::<_, hyper::Error>(
1300 hyper::Response::builder()
1301 .status(status)
1302 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
1303 .unwrap(),
1304 )
1305 }
1306 });
1307 let _ = hyper_util::server::conn::auto::Builder::new(
1308 hyper_util::rt::TokioExecutor::new(),
1309 )
1310 .serve_connection(io, service)
1311 .await;
1312 });
1313 }
1314 });
1315 addr
1316 }
1317
1318 #[tokio::test]
1323 async fn a_card_behind_a_required_header_needs_the_options_variant() {
1324 use a2a_protocol_types::{AgentCard, AgentInterface};
1325
1326 let card = AgentCard::new(
1327 "gated",
1328 "1.0",
1329 AgentInterface::jsonrpc("http://localhost:9090"),
1330 );
1331 let addr = spawn_gated_card_server(
1332 serde_json::to_string(&card).unwrap(),
1333 "authorization",
1334 "Bearer s3cret",
1335 )
1336 .await;
1337 let base = format!("http://127.0.0.1:{}", addr.port());
1338
1339 let refused = resolve_agent_card(&base).await;
1340 assert!(
1341 matches!(
1342 refused,
1343 Err(ClientError::UnexpectedStatus { status: 401, .. })
1344 ),
1345 "no header, so the server must refuse: {refused:?}"
1346 );
1347
1348 let options = CardFetchOptions::default().with_header("authorization", "Bearer s3cret");
1349 let fetched = resolve_agent_card_with_options(&base, &options)
1350 .await
1351 .expect("the header admits the fetch");
1352 assert_eq!(fetched.name, "gated");
1353
1354 let url = format!("{base}{AGENT_CARD_PATH}");
1356 let fetched = fetch_card_from_url_with_options(&url, &options)
1357 .await
1358 .expect("same header, same door");
1359 assert_eq!(fetched.name, "gated");
1360 }
1361
1362 #[tokio::test]
1368 async fn the_card_fetch_budget_is_the_configured_one() {
1369 let budget = Duration::from_millis(600);
1370 let addr = crate::transport::test_support::spawn_stalling_server(
1371 "HTTP/1.1 200 OK",
1372 budget * 2 / 3,
1373 )
1374 .await;
1375 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
1376 let options = CardFetchOptions::default().with_timeout(budget);
1377
1378 let started = std::time::Instant::now();
1379 let result = fetch_card_from_url_with_options(&url, &options).await;
1380 let elapsed = started.elapsed();
1381
1382 assert!(
1383 matches!(&result, Err(ClientError::Transport(msg)) if msg.contains("timed out")),
1384 "{result:?}"
1385 );
1386 assert!(
1387 elapsed >= budget && elapsed < budget * 3 / 2,
1388 "one budget, not two: took {elapsed:?} against {budget:?}"
1389 );
1390 }
1391
1392 #[test]
1396 fn card_fetch_options_setters_set_their_fields() {
1397 let mut map = HashMap::new();
1398 map.insert("authorization".to_owned(), "Bearer t".to_owned());
1399 let opts = CardFetchOptions::default()
1400 .with_header("x-first", "1")
1401 .with_headers(map)
1402 .with_header("x-second", "2")
1403 .with_timeout(Duration::from_millis(250));
1404 assert_eq!(
1405 opts.headers().len(),
1406 2,
1407 "with_headers replaces, with_header adds"
1408 );
1409 assert_eq!(
1410 opts.headers().get("authorization").map(String::as_str),
1411 Some("Bearer t")
1412 );
1413 assert_eq!(
1414 opts.headers().get("x-second").map(String::as_str),
1415 Some("2")
1416 );
1417 assert_eq!(opts.timeout(), Duration::from_millis(250));
1418 }
1419}