1use std::sync::Arc;
18use std::time::Duration;
19
20use http_body_util::{BodyExt, Full, LengthLimitError, Limited};
21use hyper::body::Bytes;
22use hyper::header;
23#[cfg(not(feature = "tls-rustls"))]
24use hyper_util::client::legacy::connect::HttpConnector;
25#[cfg(not(feature = "tls-rustls"))]
26use hyper_util::client::legacy::Client;
27#[cfg(not(feature = "tls-rustls"))]
28use hyper_util::rt::TokioExecutor;
29use tokio::sync::RwLock;
30
31use a2a_protocol_types::AgentCard;
32
33use crate::error::{ClientError, ClientResult};
34
35pub const AGENT_CARD_PATH: &str = "/.well-known/agent-card.json";
37
38pub(crate) const MAX_CARD_BODY_SIZE: u64 = 2 * 1024 * 1024;
42
43pub(crate) const CARD_FETCH_BUDGET: Duration = Duration::from_secs(30);
64
65pub(crate) const fn exceeds_card_body_size(len: u64, max: u64) -> bool {
69 len > max
70}
71
72pub async fn resolve_agent_card(base_url: &str) -> ClientResult<AgentCard> {
87 trace_info!(base_url, "resolving agent card");
88 let url = build_card_url(base_url, AGENT_CARD_PATH)?;
89 fetch_card(&url, None).await
90}
91
92pub async fn resolve_agent_card_with_path(base_url: &str, path: &str) -> ClientResult<AgentCard> {
101 let url = build_card_url(base_url, path)?;
102 fetch_card(&url, None).await
103}
104
105pub async fn fetch_card_from_url(url: &str) -> ClientResult<AgentCard> {
114 fetch_card(url, None).await
115}
116
117#[derive(Debug, Clone)]
121struct CachedCard {
122 card: AgentCard,
123 etag: Option<String>,
124 last_modified: Option<String>,
125}
126
127#[derive(Debug, Clone)]
133pub struct CachingCardResolver {
134 url: String,
135 cache: Arc<RwLock<Option<CachedCard>>>,
136}
137
138impl CachingCardResolver {
139 pub fn new(base_url: &str) -> ClientResult<Self> {
146 let url = build_card_url(base_url, AGENT_CARD_PATH)?;
147 Ok(Self {
148 url,
149 cache: Arc::new(RwLock::new(None)),
150 })
151 }
152
153 pub fn with_path(base_url: &str, path: &str) -> ClientResult<Self> {
159 let url = build_card_url(base_url, path)?;
160 Ok(Self {
161 url,
162 cache: Arc::new(RwLock::new(None)),
163 })
164 }
165
166 pub async fn resolve(&self) -> ClientResult<AgentCard> {
176 trace_info!(url = %self.url, "resolving agent card (cached)");
177 let cached = self.cache.read().await.clone();
178 let (card, etag, last_modified) =
179 fetch_card_with_metadata(&self.url, cached.as_ref()).await?;
180
181 {
183 let mut guard = self.cache.write().await;
184 *guard = Some(CachedCard {
185 card: card.clone(),
186 etag,
187 last_modified,
188 });
189 }
190
191 Ok(card)
192 }
193
194 pub async fn invalidate(&self) {
196 let mut cache = self.cache.write().await;
197 *cache = None;
198 }
199}
200
201fn build_card_url(base_url: &str, path: &str) -> ClientResult<String> {
204 if base_url.is_empty() {
205 return Err(ClientError::InvalidEndpoint(
206 "base URL must not be empty".into(),
207 ));
208 }
209 if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
210 return Err(ClientError::InvalidEndpoint(format!(
211 "base URL must start with http:// or https://: {base_url}"
212 )));
213 }
214 let base = base_url.trim_end_matches('/');
215 let path = if path.starts_with('/') {
216 path.to_owned()
217 } else {
218 format!("/{path}")
219 };
220 Ok(format!("{base}{path}"))
221}
222
223async fn fetch_card(url: &str, cached: Option<&CachedCard>) -> ClientResult<AgentCard> {
224 let (card, _, _) = fetch_card_with_metadata(url, cached).await?;
225 Ok(card)
226}
227
228#[allow(clippy::too_many_lines)]
229async fn fetch_card_with_metadata(
230 url: &str,
231 cached: Option<&CachedCard>,
232) -> ClientResult<(AgentCard, Option<String>, Option<String>)> {
233 #[cfg(not(feature = "tls-rustls"))]
234 let client: Client<HttpConnector, Full<Bytes>> = {
235 let mut connector = HttpConnector::new();
236 connector.set_connect_timeout(Some(Duration::from_secs(10)));
237 connector.set_nodelay(true);
238 Client::builder(TokioExecutor::new()).build(connector)
239 };
240
241 #[cfg(feature = "tls-rustls")]
242 let client = crate::tls::build_https_client();
243
244 let mut builder = hyper::Request::builder()
245 .method(hyper::Method::GET)
246 .uri(url)
247 .header(header::ACCEPT, "application/json");
248
249 if let Some(cached) = cached {
251 if let Some(ref etag) = cached.etag {
252 builder = builder.header("if-none-match", etag.as_str());
253 }
254 if let Some(ref lm) = cached.last_modified {
255 builder = builder.header("if-modified-since", lm.as_str());
256 }
257 }
258
259 let req = builder
260 .body(Full::new(Bytes::new()))
261 .map_err(|e| ClientError::Transport(e.to_string()))?;
262
263 let deadline = tokio::time::Instant::now() + CARD_FETCH_BUDGET;
265
266 let resp = tokio::time::timeout_at(deadline, client.request(req))
267 .await
268 .map_err(|_| ClientError::Transport("agent card fetch timed out".into()))?
269 .map_err(|e| ClientError::HttpClient(e.to_string()))?;
270
271 let status = resp.status();
272 let retry_after = crate::error::parse_retry_after(resp.headers());
273
274 if status == hyper::StatusCode::NOT_MODIFIED {
276 if let Some(cached) = cached {
277 return Ok((
278 cached.card.clone(),
279 cached.etag.clone(),
280 cached.last_modified.clone(),
281 ));
282 }
283 }
285
286 let etag = resp
288 .headers()
289 .get("etag")
290 .and_then(|v| v.to_str().ok())
291 .map(str::to_owned);
292 let last_modified = resp
293 .headers()
294 .get("last-modified")
295 .and_then(|v| v.to_str().ok())
296 .map(str::to_owned);
297
298 let max_card_body_size: u64 = MAX_CARD_BODY_SIZE;
302 if let Some(cl) = resp.headers().get(header::CONTENT_LENGTH) {
303 if let Ok(len) = cl.to_str().unwrap_or("0").parse::<u64>() {
304 if exceeds_card_body_size(len, max_card_body_size) {
305 return Err(ClientError::Transport(format!(
306 "agent card response too large: {len} bytes exceeds {max_card_body_size} byte limit"
307 )));
308 }
309 }
310 }
311
312 let cap = usize::try_from(max_card_body_size).unwrap_or(usize::MAX);
319 let body_bytes = match tokio::time::timeout_at(
321 deadline,
322 Limited::new(resp.into_body(), cap).collect(),
323 )
324 .await
325 {
326 Err(_) => {
327 return Err(ClientError::Transport(
328 "agent card body read timed out".into(),
329 ))
330 }
331 Ok(Ok(collected)) => collected.to_bytes(),
332 Ok(Err(err)) => {
333 return Err(if err.downcast_ref::<LengthLimitError>().is_some() {
334 ClientError::Transport(format!(
335 "agent card response too large: exceeds {max_card_body_size} byte limit"
336 ))
337 } else {
338 ClientError::Transport(format!("agent card body read failed: {err}"))
339 });
340 }
341 };
342
343 if !status.is_success() {
344 let body_str = String::from_utf8_lossy(&body_bytes).into_owned();
345 return Err(ClientError::UnexpectedStatus {
346 status: status.as_u16(),
347 body: body_str,
348 retry_after,
349 });
350 }
351
352 let card =
353 serde_json::from_slice::<AgentCard>(&body_bytes).map_err(ClientError::Serialization)?;
354 Ok((card, etag, last_modified))
355}
356
357#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn build_card_url_standard() {
365 let url = build_card_url("http://localhost:8080", AGENT_CARD_PATH).unwrap();
366 assert_eq!(url, "http://localhost:8080/.well-known/agent-card.json");
367 }
368
369 #[test]
370 fn build_card_url_trailing_slash() {
371 let url = build_card_url("http://localhost:8080/", AGENT_CARD_PATH).unwrap();
372 assert_eq!(url, "http://localhost:8080/.well-known/agent-card.json");
373 }
374
375 #[test]
376 fn build_card_url_custom_path() {
377 let url = build_card_url("http://localhost:8080", "/api/card.json").unwrap();
378 assert_eq!(url, "http://localhost:8080/api/card.json");
379 }
380
381 #[test]
382 fn build_card_url_rejects_empty() {
383 assert!(build_card_url("", AGENT_CARD_PATH).is_err());
384 }
385
386 #[test]
387 fn build_card_url_rejects_non_http() {
388 assert!(build_card_url("ftp://example.com", AGENT_CARD_PATH).is_err());
389 }
390
391 #[test]
392 fn caching_resolver_new() {
393 let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
394 assert_eq!(
395 resolver.url,
396 "http://localhost:8080/.well-known/agent-card.json"
397 );
398 }
399
400 #[test]
401 fn caching_resolver_new_rejects_invalid_url() {
402 assert!(CachingCardResolver::new("").is_err());
403 assert!(CachingCardResolver::new("ftp://example.com").is_err());
404 }
405
406 #[test]
407 fn caching_resolver_with_path() {
408 let resolver =
409 CachingCardResolver::with_path("http://localhost:8080", "/custom/card.json").unwrap();
410 assert_eq!(resolver.url, "http://localhost:8080/custom/card.json");
411 }
412
413 #[tokio::test]
414 async fn caching_resolver_invalidate_empty() {
415 let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
416 assert!(resolver.cache.read().await.is_none());
418 resolver.invalidate().await;
419 assert!(resolver.cache.read().await.is_none());
420 }
421
422 #[tokio::test]
423 async fn caching_resolver_invalidate_clears_populated_cache() {
424 use a2a_protocol_types::{AgentCapabilities, AgentCard};
425
426 let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
427
428 {
430 let mut guard = resolver.cache.write().await;
431 *guard = Some(CachedCard {
432 card: AgentCard {
433 url: None,
434 name: "cached".into(),
435 version: "1.0".into(),
436 description: "Cached agent".into(),
437 supported_interfaces: vec![],
438 provider: None,
439 icon_url: None,
440 documentation_url: None,
441 capabilities: AgentCapabilities::none(),
442 security_schemes: None,
443 security_requirements: None,
444 default_input_modes: vec![],
445 default_output_modes: vec![],
446 skills: vec![],
447 signatures: None,
448 },
449 etag: Some("test-etag".into()),
450 last_modified: None,
451 });
452 }
453
454 {
456 let cached = resolver.cache.read().await;
457 let entry = cached.as_ref().expect("cache should be populated");
458 assert_eq!(entry.card.name, "cached");
459 assert_eq!(entry.etag, Some("test-etag".into()));
460 drop(cached);
461 }
462
463 resolver.invalidate().await;
465 assert!(
466 resolver.cache.read().await.is_none(),
467 "invalidate must clear a populated cache"
468 );
469 }
470
471 #[tokio::test]
474 async fn fetch_card_with_metadata_non_success_status() {
475 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
477 let addr = listener.local_addr().unwrap();
478
479 tokio::spawn(async move {
480 loop {
481 let (stream, _) = listener.accept().await.unwrap();
482 let io = hyper_util::rt::TokioIo::new(stream);
483 tokio::spawn(async move {
484 let service = hyper::service::service_fn(|_req| async {
485 Ok::<_, hyper::Error>(
486 hyper::Response::builder()
487 .status(404)
488 .body(http_body_util::Full::new(hyper::body::Bytes::from(
489 "Not Found",
490 )))
491 .unwrap(),
492 )
493 });
494 let _ = hyper_util::server::conn::auto::Builder::new(
495 hyper_util::rt::TokioExecutor::new(),
496 )
497 .serve_connection(io, service)
498 .await;
499 });
500 }
501 });
502
503 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
504 let result = fetch_card_with_metadata(&url, None).await;
505 assert!(result.is_err());
506 match result.unwrap_err() {
507 ClientError::UnexpectedStatus { status, body, .. } => {
508 assert_eq!(status, 404);
509 assert!(body.contains("Not Found"));
510 }
511 other => panic!("expected UnexpectedStatus, got {other:?}"),
512 }
513 }
514
515 #[tokio::test]
517 async fn fetch_card_with_metadata_304_returns_cached() {
518 use a2a_protocol_types::{AgentCapabilities, AgentCard};
519
520 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
522 let addr = listener.local_addr().unwrap();
523
524 tokio::spawn(async move {
525 loop {
526 let (stream, _) = listener.accept().await.unwrap();
527 let io = hyper_util::rt::TokioIo::new(stream);
528 tokio::spawn(async move {
529 let service = hyper::service::service_fn(|_req| async {
530 Ok::<_, hyper::Error>(
531 hyper::Response::builder()
532 .status(304)
533 .body(http_body_util::Full::new(hyper::body::Bytes::new()))
534 .unwrap(),
535 )
536 });
537 let _ = hyper_util::server::conn::auto::Builder::new(
538 hyper_util::rt::TokioExecutor::new(),
539 )
540 .serve_connection(io, service)
541 .await;
542 });
543 }
544 });
545
546 let cached = CachedCard {
547 card: AgentCard {
548 url: None,
549 name: "cached-agent".into(),
550 version: "2.0".into(),
551 description: "Cached".into(),
552 supported_interfaces: vec![],
553 provider: None,
554 icon_url: None,
555 documentation_url: None,
556 capabilities: AgentCapabilities::none(),
557 security_schemes: None,
558 security_requirements: None,
559 default_input_modes: vec![],
560 default_output_modes: vec![],
561 skills: vec![],
562 signatures: None,
563 },
564 etag: Some("\"abc123\"".into()),
565 last_modified: None,
566 };
567
568 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
569 let (card, etag, _) = fetch_card_with_metadata(&url, Some(&cached)).await.unwrap();
570 assert_eq!(card.name, "cached-agent");
571 assert_eq!(etag, Some("\"abc123\"".into()));
572 }
573
574 #[tokio::test]
576 async fn fetch_card_with_metadata_200_parses_card() {
577 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
578
579 let card = AgentCard {
580 url: None,
581 name: "test-agent".into(),
582 version: "1.0".into(),
583 description: "A test".into(),
584 supported_interfaces: vec![AgentInterface {
585 url: "http://localhost:9090".into(),
586 protocol_binding: "JSONRPC".into(),
587 protocol_version: "1.0.0".into(),
588 tenant: None,
589 }],
590 provider: None,
591 icon_url: None,
592 documentation_url: None,
593 capabilities: AgentCapabilities::none(),
594 security_schemes: None,
595 security_requirements: None,
596 default_input_modes: vec![],
597 default_output_modes: vec![],
598 skills: vec![],
599 signatures: None,
600 };
601 let card_json = serde_json::to_string(&card).unwrap();
602
603 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
604 let addr = listener.local_addr().unwrap();
605
606 tokio::spawn(async move {
607 loop {
608 let (stream, _) = listener.accept().await.unwrap();
609 let io = hyper_util::rt::TokioIo::new(stream);
610 let body = card_json.clone();
611 tokio::spawn(async move {
612 let service = hyper::service::service_fn(move |_req| {
613 let body = body.clone();
614 async move {
615 Ok::<_, hyper::Error>(
616 hyper::Response::builder()
617 .status(200)
618 .header("etag", "\"xyz\"")
619 .header("last-modified", "Mon, 01 Jan 2026 00:00:00 GMT")
620 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
621 .unwrap(),
622 )
623 }
624 });
625 let _ = hyper_util::server::conn::auto::Builder::new(
626 hyper_util::rt::TokioExecutor::new(),
627 )
628 .serve_connection(io, service)
629 .await;
630 });
631 }
632 });
633
634 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
635 let (parsed_card, etag, last_modified) =
636 fetch_card_with_metadata(&url, None).await.unwrap();
637 assert_eq!(parsed_card.name, "test-agent");
638 assert_eq!(etag, Some("\"xyz\"".into()));
639 assert_eq!(last_modified, Some("Mon, 01 Jan 2026 00:00:00 GMT".into()));
640 }
641
642 #[allow(clippy::too_many_lines)]
644 #[tokio::test]
645 async fn caching_resolver_resolve_fetches_and_caches() {
646 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
647
648 let card = AgentCard {
649 url: None,
650 name: "resolver-test".into(),
651 version: "1.0".into(),
652 description: "Resolver test agent".into(),
653 supported_interfaces: vec![AgentInterface {
654 url: "http://localhost:9090".into(),
655 protocol_binding: "JSONRPC".into(),
656 protocol_version: "1.0.0".into(),
657 tenant: None,
658 }],
659 provider: None,
660 icon_url: None,
661 documentation_url: None,
662 capabilities: AgentCapabilities::none(),
663 security_schemes: None,
664 security_requirements: None,
665 default_input_modes: vec![],
666 default_output_modes: vec![],
667 skills: vec![],
668 signatures: None,
669 };
670 let card_json = serde_json::to_string(&card).unwrap();
671
672 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
673 let addr = listener.local_addr().unwrap();
674
675 tokio::spawn(async move {
676 loop {
677 let (stream, _) = listener.accept().await.unwrap();
678 let io = hyper_util::rt::TokioIo::new(stream);
679 let body = card_json.clone();
680 tokio::spawn(async move {
681 let service = hyper::service::service_fn(move |_req| {
682 let body = body.clone();
683 async move {
684 Ok::<_, hyper::Error>(
685 hyper::Response::builder()
686 .status(200)
687 .header("etag", "\"res-etag\"")
688 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
689 .unwrap(),
690 )
691 }
692 });
693 let _ = hyper_util::server::conn::auto::Builder::new(
694 hyper_util::rt::TokioExecutor::new(),
695 )
696 .serve_connection(io, service)
697 .await;
698 });
699 }
700 });
701
702 let base_url = format!("http://127.0.0.1:{}", addr.port());
703 let resolver = CachingCardResolver::with_path(&base_url, "/agent.json").unwrap();
704 assert!(
705 resolver.cache.read().await.is_none(),
706 "cache should start empty"
707 );
708
709 let fetched = resolver.resolve().await.unwrap();
710 assert_eq!(fetched.name, "resolver-test");
711
712 let cached = resolver.cache.read().await;
714 let entry = cached
715 .as_ref()
716 .expect("cache should be populated after resolve");
717 assert_eq!(entry.card.name, "resolver-test");
718 assert_eq!(entry.etag, Some("\"res-etag\"".into()));
719 drop(cached);
720 }
721
722 #[tokio::test]
724 async fn caching_resolver_resolve_returns_error_on_failure() {
725 let resolver = CachingCardResolver::with_path("http://127.0.0.1:1", "/agent.json").unwrap();
727 let result = resolver.resolve().await;
728 assert!(
729 result.is_err(),
730 "resolve should fail with unreachable server"
731 );
732 }
733
734 #[test]
736 fn build_card_url_path_without_leading_slash() {
737 let url = build_card_url("http://localhost:8080", "custom/card.json").unwrap();
738 assert_eq!(url, "http://localhost:8080/custom/card.json");
739 }
740
741 #[tokio::test]
743 async fn fetch_card_from_url_success() {
744 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
745
746 let card = AgentCard {
747 url: None,
748 name: "url-fetch-test".into(),
749 version: "1.0".into(),
750 description: "URL fetch test".into(),
751 supported_interfaces: vec![AgentInterface {
752 url: "http://localhost:9090".into(),
753 protocol_binding: "JSONRPC".into(),
754 protocol_version: "1.0.0".into(),
755 tenant: None,
756 }],
757 provider: None,
758 icon_url: None,
759 documentation_url: None,
760 capabilities: AgentCapabilities::none(),
761 security_schemes: None,
762 security_requirements: None,
763 default_input_modes: vec![],
764 default_output_modes: vec![],
765 skills: vec![],
766 signatures: None,
767 };
768 let card_json = serde_json::to_string(&card).unwrap();
769
770 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
771 let addr = listener.local_addr().unwrap();
772
773 tokio::spawn(async move {
774 loop {
775 let (stream, _) = listener.accept().await.unwrap();
776 let io = hyper_util::rt::TokioIo::new(stream);
777 let body = card_json.clone();
778 tokio::spawn(async move {
779 let service = hyper::service::service_fn(move |_req| {
780 let body = body.clone();
781 async move {
782 Ok::<_, hyper::Error>(
783 hyper::Response::builder()
784 .status(200)
785 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
786 .unwrap(),
787 )
788 }
789 });
790 let _ = hyper_util::server::conn::auto::Builder::new(
791 hyper_util::rt::TokioExecutor::new(),
792 )
793 .serve_connection(io, service)
794 .await;
795 });
796 }
797 });
798
799 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
800 let fetched = fetch_card_from_url(&url).await.unwrap();
801 assert_eq!(fetched.name, "url-fetch-test");
802 }
803
804 #[tokio::test]
806 async fn resolve_agent_card_with_path_success() {
807 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
808
809 let card = AgentCard {
810 url: None,
811 name: "path-resolve-test".into(),
812 version: "2.0".into(),
813 description: "Path resolve test".into(),
814 supported_interfaces: vec![AgentInterface {
815 url: "http://localhost:9090".into(),
816 protocol_binding: "JSONRPC".into(),
817 protocol_version: "1.0.0".into(),
818 tenant: None,
819 }],
820 provider: None,
821 icon_url: None,
822 documentation_url: None,
823 capabilities: AgentCapabilities::none(),
824 security_schemes: None,
825 security_requirements: None,
826 default_input_modes: vec![],
827 default_output_modes: vec![],
828 skills: vec![],
829 signatures: None,
830 };
831 let card_json = serde_json::to_string(&card).unwrap();
832
833 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
834 let addr = listener.local_addr().unwrap();
835
836 tokio::spawn(async move {
837 loop {
838 let (stream, _) = listener.accept().await.unwrap();
839 let io = hyper_util::rt::TokioIo::new(stream);
840 let body = card_json.clone();
841 tokio::spawn(async move {
842 let service = hyper::service::service_fn(move |_req| {
843 let body = body.clone();
844 async move {
845 Ok::<_, hyper::Error>(
846 hyper::Response::builder()
847 .status(200)
848 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
849 .unwrap(),
850 )
851 }
852 });
853 let _ = hyper_util::server::conn::auto::Builder::new(
854 hyper_util::rt::TokioExecutor::new(),
855 )
856 .serve_connection(io, service)
857 .await;
858 });
859 }
860 });
861
862 let base_url = format!("http://127.0.0.1:{}", addr.port());
863 let fetched = resolve_agent_card_with_path(&base_url, "/custom.json")
864 .await
865 .unwrap();
866 assert_eq!(fetched.name, "path-resolve-test");
867 }
868
869 #[tokio::test]
871 async fn fetch_card_rejects_oversized_content_length() {
872 use tokio::io::AsyncWriteExt;
873
874 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
875 let addr = listener.local_addr().unwrap();
876
877 tokio::spawn(async move {
880 loop {
881 let (mut stream, _) = listener.accept().await.unwrap();
882 tokio::spawn(async move {
883 let mut buf = [0u8; 4096];
885 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
886 let response = "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 10000000\r\n\r\nsmall";
888 let _ = stream.write_all(response.as_bytes()).await;
889 drop(stream);
891 });
892 }
893 });
894
895 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
896 let result = fetch_card_with_metadata(&url, None).await;
897 match result {
898 Err(ClientError::Transport(msg)) => {
899 assert!(
900 msg.contains("too large"),
901 "should mention size limit: {msg}"
902 );
903 }
904 other => panic!("expected Transport error about size, got {other:?}"),
905 }
906 }
907
908 #[tokio::test]
915 async fn fetch_card_accepts_content_length_at_exact_limit() {
916 use tokio::io::AsyncWriteExt;
917
918 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
919 let addr = listener.local_addr().unwrap();
920 let max_size: u64 = 2 * 1024 * 1024; tokio::spawn(async move {
923 loop {
924 let (mut stream, _) = listener.accept().await.unwrap();
925 tokio::spawn(async move {
926 let mut buf = [0u8; 4096];
927 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
928 let response = format!(
932 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {max_size}\r\n\r\nsmall"
933 );
934 let _ = stream.write_all(response.as_bytes()).await;
935 drop(stream);
936 });
937 }
938 });
939
940 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
941 let result = fetch_card_with_metadata(&url, None).await;
942
943 match &result {
945 Err(ClientError::Transport(msg)) if msg.contains("too large") => {
946 panic!("Content-Length at exact limit should not be rejected: {msg}");
947 }
948 _ => {} }
950 }
951
952 #[tokio::test]
957 async fn fetch_card_rejects_oversized_body_without_content_length() {
958 use tokio::io::AsyncWriteExt;
959
960 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
961 let addr = listener.local_addr().unwrap();
962 let max_size = 2 * 1024 * 1024_usize; tokio::spawn(async move {
965 loop {
966 let (mut stream, _) = listener.accept().await.unwrap();
967 let body_size = max_size + 1;
968 tokio::spawn(async move {
969 let mut buf = [0u8; 4096];
970 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
971 let header = "HTTP/1.0 200 OK\r\ncontent-type: application/json\r\n\r\n";
973 let _ = stream.write_all(header.as_bytes()).await;
974 let chunk = vec![b'x'; 64 * 1024];
976 let mut remaining = body_size;
977 while remaining > 0 {
978 let n = remaining.min(chunk.len());
979 if stream.write_all(&chunk[..n]).await.is_err() {
980 break;
981 }
982 remaining -= n;
983 }
984 drop(stream);
985 });
986 }
987 });
988
989 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
990 let result = fetch_card_with_metadata(&url, None).await;
991
992 match result {
993 Err(ClientError::Transport(msg)) => {
994 assert!(
995 msg.contains("too large"),
996 "should mention size limit: {msg}"
997 );
998 }
999 other => panic!("expected Transport error about size for body > limit, got {other:?}"),
1000 }
1001 }
1002
1003 #[tokio::test]
1005 async fn fetch_card_with_metadata_304_with_last_modified() {
1006 use a2a_protocol_types::{AgentCapabilities, AgentCard};
1007
1008 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1009 let addr = listener.local_addr().unwrap();
1010
1011 tokio::spawn(async move {
1012 loop {
1013 let (stream, _) = listener.accept().await.unwrap();
1014 let io = hyper_util::rt::TokioIo::new(stream);
1015 tokio::spawn(async move {
1016 let service = hyper::service::service_fn(|_req| async {
1017 Ok::<_, hyper::Error>(
1018 hyper::Response::builder()
1019 .status(304)
1020 .body(http_body_util::Full::new(hyper::body::Bytes::new()))
1021 .unwrap(),
1022 )
1023 });
1024 let _ = hyper_util::server::conn::auto::Builder::new(
1025 hyper_util::rt::TokioExecutor::new(),
1026 )
1027 .serve_connection(io, service)
1028 .await;
1029 });
1030 }
1031 });
1032
1033 let cached = CachedCard {
1034 card: AgentCard {
1035 url: None,
1036 name: "lm-cached".into(),
1037 version: "1.0".into(),
1038 description: "Last-modified cached".into(),
1039 supported_interfaces: vec![],
1040 provider: None,
1041 icon_url: None,
1042 documentation_url: None,
1043 capabilities: AgentCapabilities::none(),
1044 security_schemes: None,
1045 security_requirements: None,
1046 default_input_modes: vec![],
1047 default_output_modes: vec![],
1048 skills: vec![],
1049 signatures: None,
1050 },
1051 etag: None,
1052 last_modified: Some("Mon, 01 Jan 2026 00:00:00 GMT".into()),
1053 };
1054
1055 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
1056 let (card, _, last_modified) = fetch_card_with_metadata(&url, Some(&cached)).await.unwrap();
1057 assert_eq!(card.name, "lm-cached");
1058 assert_eq!(last_modified, Some("Mon, 01 Jan 2026 00:00:00 GMT".into()));
1059 }
1060
1061 #[test]
1064 fn exceeds_card_body_size_over_limit() {
1065 assert!(exceeds_card_body_size(
1066 MAX_CARD_BODY_SIZE + 1,
1067 MAX_CARD_BODY_SIZE
1068 ));
1069 }
1070
1071 #[test]
1074 fn exceeds_card_body_size_exactly_at_limit_is_ok() {
1075 assert!(!exceeds_card_body_size(
1076 MAX_CARD_BODY_SIZE,
1077 MAX_CARD_BODY_SIZE
1078 ));
1079 }
1080
1081 #[test]
1082 fn exceeds_card_body_size_under_limit() {
1083 assert!(!exceeds_card_body_size(0, MAX_CARD_BODY_SIZE));
1084 assert!(!exceeds_card_body_size(1024, MAX_CARD_BODY_SIZE));
1085 assert!(!exceeds_card_body_size(
1086 MAX_CARD_BODY_SIZE - 1,
1087 MAX_CARD_BODY_SIZE
1088 ));
1089 }
1090
1091 #[test]
1092 fn exceeds_card_body_size_custom_limit() {
1093 assert!(exceeds_card_body_size(11, 10));
1094 assert!(!exceeds_card_body_size(10, 10));
1095 assert!(!exceeds_card_body_size(9, 10));
1096 }
1097}