1use std::sync::Arc;
18use std::time::Duration;
19
20use http_body_util::{BodyExt, Full};
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
248 if status == hyper::StatusCode::NOT_MODIFIED {
250 if let Some(cached) = cached {
251 return Ok((
252 cached.card.clone(),
253 cached.etag.clone(),
254 cached.last_modified.clone(),
255 ));
256 }
257 }
259
260 let etag = resp
262 .headers()
263 .get("etag")
264 .and_then(|v| v.to_str().ok())
265 .map(str::to_owned);
266 let last_modified = resp
267 .headers()
268 .get("last-modified")
269 .and_then(|v| v.to_str().ok())
270 .map(str::to_owned);
271
272 let max_card_body_size: u64 = MAX_CARD_BODY_SIZE;
276 if let Some(cl) = resp.headers().get(header::CONTENT_LENGTH) {
277 if let Ok(len) = cl.to_str().unwrap_or("0").parse::<u64>() {
278 if exceeds_card_body_size(len, max_card_body_size) {
279 return Err(ClientError::Transport(format!(
280 "agent card response too large: {len} bytes exceeds {max_card_body_size} byte limit"
281 )));
282 }
283 }
284 }
285
286 let body_bytes = tokio::time::timeout(Duration::from_secs(30), resp.collect())
287 .await
288 .map_err(|_| ClientError::Transport("agent card body read timed out".into()))?
289 .map_err(ClientError::Http)?
290 .to_bytes();
291
292 if exceeds_card_body_size(body_bytes.len() as u64, max_card_body_size) {
295 return Err(ClientError::Transport(format!(
296 "agent card response too large: {} bytes exceeds {max_card_body_size} byte limit",
297 body_bytes.len()
298 )));
299 }
300
301 if !status.is_success() {
302 let body_str = String::from_utf8_lossy(&body_bytes).into_owned();
303 return Err(ClientError::UnexpectedStatus {
304 status: status.as_u16(),
305 body: body_str,
306 });
307 }
308
309 let card =
310 serde_json::from_slice::<AgentCard>(&body_bytes).map_err(ClientError::Serialization)?;
311 Ok((card, etag, last_modified))
312}
313
314#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn build_card_url_standard() {
322 let url = build_card_url("http://localhost:8080", AGENT_CARD_PATH).unwrap();
323 assert_eq!(url, "http://localhost:8080/.well-known/agent-card.json");
324 }
325
326 #[test]
327 fn build_card_url_trailing_slash() {
328 let url = build_card_url("http://localhost:8080/", AGENT_CARD_PATH).unwrap();
329 assert_eq!(url, "http://localhost:8080/.well-known/agent-card.json");
330 }
331
332 #[test]
333 fn build_card_url_custom_path() {
334 let url = build_card_url("http://localhost:8080", "/api/card.json").unwrap();
335 assert_eq!(url, "http://localhost:8080/api/card.json");
336 }
337
338 #[test]
339 fn build_card_url_rejects_empty() {
340 assert!(build_card_url("", AGENT_CARD_PATH).is_err());
341 }
342
343 #[test]
344 fn build_card_url_rejects_non_http() {
345 assert!(build_card_url("ftp://example.com", AGENT_CARD_PATH).is_err());
346 }
347
348 #[test]
349 fn caching_resolver_new() {
350 let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
351 assert_eq!(
352 resolver.url,
353 "http://localhost:8080/.well-known/agent-card.json"
354 );
355 }
356
357 #[test]
358 fn caching_resolver_new_rejects_invalid_url() {
359 assert!(CachingCardResolver::new("").is_err());
360 assert!(CachingCardResolver::new("ftp://example.com").is_err());
361 }
362
363 #[test]
364 fn caching_resolver_with_path() {
365 let resolver =
366 CachingCardResolver::with_path("http://localhost:8080", "/custom/card.json").unwrap();
367 assert_eq!(resolver.url, "http://localhost:8080/custom/card.json");
368 }
369
370 #[tokio::test]
371 async fn caching_resolver_invalidate_empty() {
372 let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
373 assert!(resolver.cache.read().await.is_none());
375 resolver.invalidate().await;
376 assert!(resolver.cache.read().await.is_none());
377 }
378
379 #[tokio::test]
380 async fn caching_resolver_invalidate_clears_populated_cache() {
381 use a2a_protocol_types::{AgentCapabilities, AgentCard};
382
383 let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
384
385 {
387 let mut guard = resolver.cache.write().await;
388 *guard = Some(CachedCard {
389 card: AgentCard {
390 url: None,
391 name: "cached".into(),
392 version: "1.0".into(),
393 description: "Cached agent".into(),
394 supported_interfaces: vec![],
395 provider: None,
396 icon_url: None,
397 documentation_url: None,
398 capabilities: AgentCapabilities::none(),
399 security_schemes: None,
400 security_requirements: None,
401 default_input_modes: vec![],
402 default_output_modes: vec![],
403 skills: vec![],
404 signatures: None,
405 },
406 etag: Some("test-etag".into()),
407 last_modified: None,
408 });
409 }
410
411 {
413 let cached = resolver.cache.read().await;
414 let entry = cached.as_ref().expect("cache should be populated");
415 assert_eq!(entry.card.name, "cached");
416 assert_eq!(entry.etag, Some("test-etag".into()));
417 drop(cached);
418 }
419
420 resolver.invalidate().await;
422 assert!(
423 resolver.cache.read().await.is_none(),
424 "invalidate must clear a populated cache"
425 );
426 }
427
428 #[tokio::test]
431 async fn fetch_card_with_metadata_non_success_status() {
432 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
434 let addr = listener.local_addr().unwrap();
435
436 tokio::spawn(async move {
437 loop {
438 let (stream, _) = listener.accept().await.unwrap();
439 let io = hyper_util::rt::TokioIo::new(stream);
440 tokio::spawn(async move {
441 let service = hyper::service::service_fn(|_req| async {
442 Ok::<_, hyper::Error>(
443 hyper::Response::builder()
444 .status(404)
445 .body(http_body_util::Full::new(hyper::body::Bytes::from(
446 "Not Found",
447 )))
448 .unwrap(),
449 )
450 });
451 let _ = hyper_util::server::conn::auto::Builder::new(
452 hyper_util::rt::TokioExecutor::new(),
453 )
454 .serve_connection(io, service)
455 .await;
456 });
457 }
458 });
459
460 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
461 let result = fetch_card_with_metadata(&url, None).await;
462 assert!(result.is_err());
463 match result.unwrap_err() {
464 ClientError::UnexpectedStatus { status, body } => {
465 assert_eq!(status, 404);
466 assert!(body.contains("Not Found"));
467 }
468 other => panic!("expected UnexpectedStatus, got {other:?}"),
469 }
470 }
471
472 #[tokio::test]
474 async fn fetch_card_with_metadata_304_returns_cached() {
475 use a2a_protocol_types::{AgentCapabilities, AgentCard};
476
477 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
479 let addr = listener.local_addr().unwrap();
480
481 tokio::spawn(async move {
482 loop {
483 let (stream, _) = listener.accept().await.unwrap();
484 let io = hyper_util::rt::TokioIo::new(stream);
485 tokio::spawn(async move {
486 let service = hyper::service::service_fn(|_req| async {
487 Ok::<_, hyper::Error>(
488 hyper::Response::builder()
489 .status(304)
490 .body(http_body_util::Full::new(hyper::body::Bytes::new()))
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 cached = CachedCard {
504 card: AgentCard {
505 url: None,
506 name: "cached-agent".into(),
507 version: "2.0".into(),
508 description: "Cached".into(),
509 supported_interfaces: vec![],
510 provider: None,
511 icon_url: None,
512 documentation_url: None,
513 capabilities: AgentCapabilities::none(),
514 security_schemes: None,
515 security_requirements: None,
516 default_input_modes: vec![],
517 default_output_modes: vec![],
518 skills: vec![],
519 signatures: None,
520 },
521 etag: Some("\"abc123\"".into()),
522 last_modified: None,
523 };
524
525 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
526 let (card, etag, _) = fetch_card_with_metadata(&url, Some(&cached)).await.unwrap();
527 assert_eq!(card.name, "cached-agent");
528 assert_eq!(etag, Some("\"abc123\"".into()));
529 }
530
531 #[tokio::test]
533 async fn fetch_card_with_metadata_200_parses_card() {
534 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
535
536 let card = AgentCard {
537 url: None,
538 name: "test-agent".into(),
539 version: "1.0".into(),
540 description: "A test".into(),
541 supported_interfaces: vec![AgentInterface {
542 url: "http://localhost:9090".into(),
543 protocol_binding: "JSONRPC".into(),
544 protocol_version: "1.0.0".into(),
545 tenant: None,
546 }],
547 provider: None,
548 icon_url: None,
549 documentation_url: None,
550 capabilities: AgentCapabilities::none(),
551 security_schemes: None,
552 security_requirements: None,
553 default_input_modes: vec![],
554 default_output_modes: vec![],
555 skills: vec![],
556 signatures: None,
557 };
558 let card_json = serde_json::to_string(&card).unwrap();
559
560 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
561 let addr = listener.local_addr().unwrap();
562
563 tokio::spawn(async move {
564 loop {
565 let (stream, _) = listener.accept().await.unwrap();
566 let io = hyper_util::rt::TokioIo::new(stream);
567 let body = card_json.clone();
568 tokio::spawn(async move {
569 let service = hyper::service::service_fn(move |_req| {
570 let body = body.clone();
571 async move {
572 Ok::<_, hyper::Error>(
573 hyper::Response::builder()
574 .status(200)
575 .header("etag", "\"xyz\"")
576 .header("last-modified", "Mon, 01 Jan 2026 00:00:00 GMT")
577 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
578 .unwrap(),
579 )
580 }
581 });
582 let _ = hyper_util::server::conn::auto::Builder::new(
583 hyper_util::rt::TokioExecutor::new(),
584 )
585 .serve_connection(io, service)
586 .await;
587 });
588 }
589 });
590
591 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
592 let (parsed_card, etag, last_modified) =
593 fetch_card_with_metadata(&url, None).await.unwrap();
594 assert_eq!(parsed_card.name, "test-agent");
595 assert_eq!(etag, Some("\"xyz\"".into()));
596 assert_eq!(last_modified, Some("Mon, 01 Jan 2026 00:00:00 GMT".into()));
597 }
598
599 #[allow(clippy::too_many_lines)]
601 #[tokio::test]
602 async fn caching_resolver_resolve_fetches_and_caches() {
603 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
604
605 let card = AgentCard {
606 url: None,
607 name: "resolver-test".into(),
608 version: "1.0".into(),
609 description: "Resolver test agent".into(),
610 supported_interfaces: vec![AgentInterface {
611 url: "http://localhost:9090".into(),
612 protocol_binding: "JSONRPC".into(),
613 protocol_version: "1.0.0".into(),
614 tenant: None,
615 }],
616 provider: None,
617 icon_url: None,
618 documentation_url: None,
619 capabilities: AgentCapabilities::none(),
620 security_schemes: None,
621 security_requirements: None,
622 default_input_modes: vec![],
623 default_output_modes: vec![],
624 skills: vec![],
625 signatures: None,
626 };
627 let card_json = serde_json::to_string(&card).unwrap();
628
629 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
630 let addr = listener.local_addr().unwrap();
631
632 tokio::spawn(async move {
633 loop {
634 let (stream, _) = listener.accept().await.unwrap();
635 let io = hyper_util::rt::TokioIo::new(stream);
636 let body = card_json.clone();
637 tokio::spawn(async move {
638 let service = hyper::service::service_fn(move |_req| {
639 let body = body.clone();
640 async move {
641 Ok::<_, hyper::Error>(
642 hyper::Response::builder()
643 .status(200)
644 .header("etag", "\"res-etag\"")
645 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
646 .unwrap(),
647 )
648 }
649 });
650 let _ = hyper_util::server::conn::auto::Builder::new(
651 hyper_util::rt::TokioExecutor::new(),
652 )
653 .serve_connection(io, service)
654 .await;
655 });
656 }
657 });
658
659 let base_url = format!("http://127.0.0.1:{}", addr.port());
660 let resolver = CachingCardResolver::with_path(&base_url, "/agent.json").unwrap();
661 assert!(
662 resolver.cache.read().await.is_none(),
663 "cache should start empty"
664 );
665
666 let fetched = resolver.resolve().await.unwrap();
667 assert_eq!(fetched.name, "resolver-test");
668
669 let cached = resolver.cache.read().await;
671 let entry = cached
672 .as_ref()
673 .expect("cache should be populated after resolve");
674 assert_eq!(entry.card.name, "resolver-test");
675 assert_eq!(entry.etag, Some("\"res-etag\"".into()));
676 drop(cached);
677 }
678
679 #[tokio::test]
681 async fn caching_resolver_resolve_returns_error_on_failure() {
682 let resolver = CachingCardResolver::with_path("http://127.0.0.1:1", "/agent.json").unwrap();
684 let result = resolver.resolve().await;
685 assert!(
686 result.is_err(),
687 "resolve should fail with unreachable server"
688 );
689 }
690
691 #[test]
693 fn build_card_url_path_without_leading_slash() {
694 let url = build_card_url("http://localhost:8080", "custom/card.json").unwrap();
695 assert_eq!(url, "http://localhost:8080/custom/card.json");
696 }
697
698 #[tokio::test]
700 async fn fetch_card_from_url_success() {
701 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
702
703 let card = AgentCard {
704 url: None,
705 name: "url-fetch-test".into(),
706 version: "1.0".into(),
707 description: "URL fetch test".into(),
708 supported_interfaces: vec![AgentInterface {
709 url: "http://localhost:9090".into(),
710 protocol_binding: "JSONRPC".into(),
711 protocol_version: "1.0.0".into(),
712 tenant: None,
713 }],
714 provider: None,
715 icon_url: None,
716 documentation_url: None,
717 capabilities: AgentCapabilities::none(),
718 security_schemes: None,
719 security_requirements: None,
720 default_input_modes: vec![],
721 default_output_modes: vec![],
722 skills: vec![],
723 signatures: None,
724 };
725 let card_json = serde_json::to_string(&card).unwrap();
726
727 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
728 let addr = listener.local_addr().unwrap();
729
730 tokio::spawn(async move {
731 loop {
732 let (stream, _) = listener.accept().await.unwrap();
733 let io = hyper_util::rt::TokioIo::new(stream);
734 let body = card_json.clone();
735 tokio::spawn(async move {
736 let service = hyper::service::service_fn(move |_req| {
737 let body = body.clone();
738 async move {
739 Ok::<_, hyper::Error>(
740 hyper::Response::builder()
741 .status(200)
742 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
743 .unwrap(),
744 )
745 }
746 });
747 let _ = hyper_util::server::conn::auto::Builder::new(
748 hyper_util::rt::TokioExecutor::new(),
749 )
750 .serve_connection(io, service)
751 .await;
752 });
753 }
754 });
755
756 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
757 let fetched = fetch_card_from_url(&url).await.unwrap();
758 assert_eq!(fetched.name, "url-fetch-test");
759 }
760
761 #[tokio::test]
763 async fn resolve_agent_card_with_path_success() {
764 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
765
766 let card = AgentCard {
767 url: None,
768 name: "path-resolve-test".into(),
769 version: "2.0".into(),
770 description: "Path resolve test".into(),
771 supported_interfaces: vec![AgentInterface {
772 url: "http://localhost:9090".into(),
773 protocol_binding: "JSONRPC".into(),
774 protocol_version: "1.0.0".into(),
775 tenant: None,
776 }],
777 provider: None,
778 icon_url: None,
779 documentation_url: None,
780 capabilities: AgentCapabilities::none(),
781 security_schemes: None,
782 security_requirements: None,
783 default_input_modes: vec![],
784 default_output_modes: vec![],
785 skills: vec![],
786 signatures: None,
787 };
788 let card_json = serde_json::to_string(&card).unwrap();
789
790 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
791 let addr = listener.local_addr().unwrap();
792
793 tokio::spawn(async move {
794 loop {
795 let (stream, _) = listener.accept().await.unwrap();
796 let io = hyper_util::rt::TokioIo::new(stream);
797 let body = card_json.clone();
798 tokio::spawn(async move {
799 let service = hyper::service::service_fn(move |_req| {
800 let body = body.clone();
801 async move {
802 Ok::<_, hyper::Error>(
803 hyper::Response::builder()
804 .status(200)
805 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
806 .unwrap(),
807 )
808 }
809 });
810 let _ = hyper_util::server::conn::auto::Builder::new(
811 hyper_util::rt::TokioExecutor::new(),
812 )
813 .serve_connection(io, service)
814 .await;
815 });
816 }
817 });
818
819 let base_url = format!("http://127.0.0.1:{}", addr.port());
820 let fetched = resolve_agent_card_with_path(&base_url, "/custom.json")
821 .await
822 .unwrap();
823 assert_eq!(fetched.name, "path-resolve-test");
824 }
825
826 #[tokio::test]
828 async fn fetch_card_rejects_oversized_content_length() {
829 use tokio::io::AsyncWriteExt;
830
831 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
832 let addr = listener.local_addr().unwrap();
833
834 tokio::spawn(async move {
837 loop {
838 let (mut stream, _) = listener.accept().await.unwrap();
839 tokio::spawn(async move {
840 let mut buf = [0u8; 4096];
842 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
843 let response = "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 10000000\r\n\r\nsmall";
845 let _ = stream.write_all(response.as_bytes()).await;
846 drop(stream);
848 });
849 }
850 });
851
852 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
853 let result = fetch_card_with_metadata(&url, None).await;
854 match result {
855 Err(ClientError::Transport(msg)) => {
856 assert!(
857 msg.contains("too large"),
858 "should mention size limit: {msg}"
859 );
860 }
861 other => panic!("expected Transport error about size, got {other:?}"),
862 }
863 }
864
865 #[tokio::test]
872 async fn fetch_card_accepts_content_length_at_exact_limit() {
873 use tokio::io::AsyncWriteExt;
874
875 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
876 let addr = listener.local_addr().unwrap();
877 let max_size: u64 = 2 * 1024 * 1024; 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];
884 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
885 let response = format!(
889 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {max_size}\r\n\r\nsmall"
890 );
891 let _ = stream.write_all(response.as_bytes()).await;
892 drop(stream);
893 });
894 }
895 });
896
897 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
898 let result = fetch_card_with_metadata(&url, None).await;
899
900 match &result {
902 Err(ClientError::Transport(msg)) if msg.contains("too large") => {
903 panic!("Content-Length at exact limit should not be rejected: {msg}");
904 }
905 _ => {} }
907 }
908
909 #[tokio::test]
914 async fn fetch_card_rejects_oversized_body_without_content_length() {
915 use tokio::io::AsyncWriteExt;
916
917 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
918 let addr = listener.local_addr().unwrap();
919 let max_size = 2 * 1024 * 1024_usize; tokio::spawn(async move {
922 loop {
923 let (mut stream, _) = listener.accept().await.unwrap();
924 let body_size = max_size + 1;
925 tokio::spawn(async move {
926 let mut buf = [0u8; 4096];
927 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
928 let header = "HTTP/1.0 200 OK\r\ncontent-type: application/json\r\n\r\n";
930 let _ = stream.write_all(header.as_bytes()).await;
931 let chunk = vec![b'x'; 64 * 1024];
933 let mut remaining = body_size;
934 while remaining > 0 {
935 let n = remaining.min(chunk.len());
936 if stream.write_all(&chunk[..n]).await.is_err() {
937 break;
938 }
939 remaining -= n;
940 }
941 drop(stream);
942 });
943 }
944 });
945
946 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
947 let result = fetch_card_with_metadata(&url, None).await;
948
949 match result {
950 Err(ClientError::Transport(msg)) => {
951 assert!(
952 msg.contains("too large"),
953 "should mention size limit: {msg}"
954 );
955 }
956 other => panic!("expected Transport error about size for body > limit, got {other:?}"),
957 }
958 }
959
960 #[tokio::test]
962 async fn fetch_card_with_metadata_304_with_last_modified() {
963 use a2a_protocol_types::{AgentCapabilities, AgentCard};
964
965 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
966 let addr = listener.local_addr().unwrap();
967
968 tokio::spawn(async move {
969 loop {
970 let (stream, _) = listener.accept().await.unwrap();
971 let io = hyper_util::rt::TokioIo::new(stream);
972 tokio::spawn(async move {
973 let service = hyper::service::service_fn(|_req| async {
974 Ok::<_, hyper::Error>(
975 hyper::Response::builder()
976 .status(304)
977 .body(http_body_util::Full::new(hyper::body::Bytes::new()))
978 .unwrap(),
979 )
980 });
981 let _ = hyper_util::server::conn::auto::Builder::new(
982 hyper_util::rt::TokioExecutor::new(),
983 )
984 .serve_connection(io, service)
985 .await;
986 });
987 }
988 });
989
990 let cached = CachedCard {
991 card: AgentCard {
992 url: None,
993 name: "lm-cached".into(),
994 version: "1.0".into(),
995 description: "Last-modified cached".into(),
996 supported_interfaces: vec![],
997 provider: None,
998 icon_url: None,
999 documentation_url: None,
1000 capabilities: AgentCapabilities::none(),
1001 security_schemes: None,
1002 security_requirements: None,
1003 default_input_modes: vec![],
1004 default_output_modes: vec![],
1005 skills: vec![],
1006 signatures: None,
1007 },
1008 etag: None,
1009 last_modified: Some("Mon, 01 Jan 2026 00:00:00 GMT".into()),
1010 };
1011
1012 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
1013 let (card, _, last_modified) = fetch_card_with_metadata(&url, Some(&cached)).await.unwrap();
1014 assert_eq!(card.name, "lm-cached");
1015 assert_eq!(last_modified, Some("Mon, 01 Jan 2026 00:00:00 GMT".into()));
1016 }
1017
1018 #[test]
1021 fn exceeds_card_body_size_over_limit() {
1022 assert!(exceeds_card_body_size(
1023 MAX_CARD_BODY_SIZE + 1,
1024 MAX_CARD_BODY_SIZE
1025 ));
1026 }
1027
1028 #[test]
1031 fn exceeds_card_body_size_exactly_at_limit_is_ok() {
1032 assert!(!exceeds_card_body_size(
1033 MAX_CARD_BODY_SIZE,
1034 MAX_CARD_BODY_SIZE
1035 ));
1036 }
1037
1038 #[test]
1039 fn exceeds_card_body_size_under_limit() {
1040 assert!(!exceeds_card_body_size(0, MAX_CARD_BODY_SIZE));
1041 assert!(!exceeds_card_body_size(1024, MAX_CARD_BODY_SIZE));
1042 assert!(!exceeds_card_body_size(
1043 MAX_CARD_BODY_SIZE - 1,
1044 MAX_CARD_BODY_SIZE
1045 ));
1046 }
1047
1048 #[test]
1049 fn exceeds_card_body_size_custom_limit() {
1050 assert!(exceeds_card_body_size(11, 10));
1051 assert!(!exceeds_card_body_size(10, 10));
1052 assert!(!exceeds_card_body_size(9, 10));
1053 }
1054}