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