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