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 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-card.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-card.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!(
340 resolver.url,
341 "http://localhost:8080/.well-known/agent-card.json"
342 );
343 }
344
345 #[test]
346 fn caching_resolver_new_rejects_invalid_url() {
347 assert!(CachingCardResolver::new("").is_err());
348 assert!(CachingCardResolver::new("ftp://example.com").is_err());
349 }
350
351 #[test]
352 fn caching_resolver_with_path() {
353 let resolver =
354 CachingCardResolver::with_path("http://localhost:8080", "/custom/card.json").unwrap();
355 assert_eq!(resolver.url, "http://localhost:8080/custom/card.json");
356 }
357
358 #[tokio::test]
359 async fn caching_resolver_invalidate_empty() {
360 let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
361 assert!(resolver.cache.read().await.is_none());
363 resolver.invalidate().await;
364 assert!(resolver.cache.read().await.is_none());
365 }
366
367 #[tokio::test]
368 async fn caching_resolver_invalidate_clears_populated_cache() {
369 use a2a_protocol_types::{AgentCapabilities, AgentCard};
370
371 let resolver = CachingCardResolver::new("http://localhost:8080").unwrap();
372
373 {
375 let mut guard = resolver.cache.write().await;
376 *guard = Some(CachedCard {
377 card: AgentCard {
378 url: None,
379 name: "cached".into(),
380 version: "1.0".into(),
381 description: "Cached agent".into(),
382 supported_interfaces: vec![],
383 provider: None,
384 icon_url: None,
385 documentation_url: None,
386 capabilities: AgentCapabilities::none(),
387 security_schemes: None,
388 security_requirements: None,
389 default_input_modes: vec![],
390 default_output_modes: vec![],
391 skills: vec![],
392 signatures: None,
393 },
394 etag: Some("test-etag".into()),
395 last_modified: None,
396 });
397 }
398
399 {
401 let cached = resolver.cache.read().await;
402 let entry = cached.as_ref().expect("cache should be populated");
403 assert_eq!(entry.card.name, "cached");
404 assert_eq!(entry.etag, Some("test-etag".into()));
405 drop(cached);
406 }
407
408 resolver.invalidate().await;
410 assert!(
411 resolver.cache.read().await.is_none(),
412 "invalidate must clear a populated cache"
413 );
414 }
415
416 #[tokio::test]
419 async fn fetch_card_with_metadata_non_success_status() {
420 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
422 let addr = listener.local_addr().unwrap();
423
424 tokio::spawn(async move {
425 loop {
426 let (stream, _) = listener.accept().await.unwrap();
427 let io = hyper_util::rt::TokioIo::new(stream);
428 tokio::spawn(async move {
429 let service = hyper::service::service_fn(|_req| async {
430 Ok::<_, hyper::Error>(
431 hyper::Response::builder()
432 .status(404)
433 .body(http_body_util::Full::new(hyper::body::Bytes::from(
434 "Not Found",
435 )))
436 .unwrap(),
437 )
438 });
439 let _ = hyper_util::server::conn::auto::Builder::new(
440 hyper_util::rt::TokioExecutor::new(),
441 )
442 .serve_connection(io, service)
443 .await;
444 });
445 }
446 });
447
448 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
449 let result = fetch_card_with_metadata(&url, None).await;
450 assert!(result.is_err());
451 match result.unwrap_err() {
452 ClientError::UnexpectedStatus { status, body } => {
453 assert_eq!(status, 404);
454 assert!(body.contains("Not Found"));
455 }
456 other => panic!("expected UnexpectedStatus, got {other:?}"),
457 }
458 }
459
460 #[tokio::test]
462 async fn fetch_card_with_metadata_304_returns_cached() {
463 use a2a_protocol_types::{AgentCapabilities, AgentCard};
464
465 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
467 let addr = listener.local_addr().unwrap();
468
469 tokio::spawn(async move {
470 loop {
471 let (stream, _) = listener.accept().await.unwrap();
472 let io = hyper_util::rt::TokioIo::new(stream);
473 tokio::spawn(async move {
474 let service = hyper::service::service_fn(|_req| async {
475 Ok::<_, hyper::Error>(
476 hyper::Response::builder()
477 .status(304)
478 .body(http_body_util::Full::new(hyper::body::Bytes::new()))
479 .unwrap(),
480 )
481 });
482 let _ = hyper_util::server::conn::auto::Builder::new(
483 hyper_util::rt::TokioExecutor::new(),
484 )
485 .serve_connection(io, service)
486 .await;
487 });
488 }
489 });
490
491 let cached = CachedCard {
492 card: AgentCard {
493 url: None,
494 name: "cached-agent".into(),
495 version: "2.0".into(),
496 description: "Cached".into(),
497 supported_interfaces: vec![],
498 provider: None,
499 icon_url: None,
500 documentation_url: None,
501 capabilities: AgentCapabilities::none(),
502 security_schemes: None,
503 security_requirements: None,
504 default_input_modes: vec![],
505 default_output_modes: vec![],
506 skills: vec![],
507 signatures: None,
508 },
509 etag: Some("\"abc123\"".into()),
510 last_modified: None,
511 };
512
513 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
514 let (card, etag, _) = fetch_card_with_metadata(&url, Some(&cached)).await.unwrap();
515 assert_eq!(card.name, "cached-agent");
516 assert_eq!(etag, Some("\"abc123\"".into()));
517 }
518
519 #[tokio::test]
521 async fn fetch_card_with_metadata_200_parses_card() {
522 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
523
524 let card = AgentCard {
525 url: None,
526 name: "test-agent".into(),
527 version: "1.0".into(),
528 description: "A test".into(),
529 supported_interfaces: vec![AgentInterface {
530 url: "http://localhost:9090".into(),
531 protocol_binding: "JSONRPC".into(),
532 protocol_version: "1.0.0".into(),
533 tenant: None,
534 }],
535 provider: None,
536 icon_url: None,
537 documentation_url: None,
538 capabilities: AgentCapabilities::none(),
539 security_schemes: None,
540 security_requirements: None,
541 default_input_modes: vec![],
542 default_output_modes: vec![],
543 skills: vec![],
544 signatures: None,
545 };
546 let card_json = serde_json::to_string(&card).unwrap();
547
548 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
549 let addr = listener.local_addr().unwrap();
550
551 tokio::spawn(async move {
552 loop {
553 let (stream, _) = listener.accept().await.unwrap();
554 let io = hyper_util::rt::TokioIo::new(stream);
555 let body = card_json.clone();
556 tokio::spawn(async move {
557 let service = hyper::service::service_fn(move |_req| {
558 let body = body.clone();
559 async move {
560 Ok::<_, hyper::Error>(
561 hyper::Response::builder()
562 .status(200)
563 .header("etag", "\"xyz\"")
564 .header("last-modified", "Mon, 01 Jan 2026 00:00:00 GMT")
565 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
566 .unwrap(),
567 )
568 }
569 });
570 let _ = hyper_util::server::conn::auto::Builder::new(
571 hyper_util::rt::TokioExecutor::new(),
572 )
573 .serve_connection(io, service)
574 .await;
575 });
576 }
577 });
578
579 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
580 let (parsed_card, etag, last_modified) =
581 fetch_card_with_metadata(&url, None).await.unwrap();
582 assert_eq!(parsed_card.name, "test-agent");
583 assert_eq!(etag, Some("\"xyz\"".into()));
584 assert_eq!(last_modified, Some("Mon, 01 Jan 2026 00:00:00 GMT".into()));
585 }
586
587 #[allow(clippy::too_many_lines)]
589 #[tokio::test]
590 async fn caching_resolver_resolve_fetches_and_caches() {
591 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
592
593 let card = AgentCard {
594 url: None,
595 name: "resolver-test".into(),
596 version: "1.0".into(),
597 description: "Resolver test agent".into(),
598 supported_interfaces: vec![AgentInterface {
599 url: "http://localhost:9090".into(),
600 protocol_binding: "JSONRPC".into(),
601 protocol_version: "1.0.0".into(),
602 tenant: None,
603 }],
604 provider: None,
605 icon_url: None,
606 documentation_url: None,
607 capabilities: AgentCapabilities::none(),
608 security_schemes: None,
609 security_requirements: None,
610 default_input_modes: vec![],
611 default_output_modes: vec![],
612 skills: vec![],
613 signatures: None,
614 };
615 let card_json = serde_json::to_string(&card).unwrap();
616
617 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
618 let addr = listener.local_addr().unwrap();
619
620 tokio::spawn(async move {
621 loop {
622 let (stream, _) = listener.accept().await.unwrap();
623 let io = hyper_util::rt::TokioIo::new(stream);
624 let body = card_json.clone();
625 tokio::spawn(async move {
626 let service = hyper::service::service_fn(move |_req| {
627 let body = body.clone();
628 async move {
629 Ok::<_, hyper::Error>(
630 hyper::Response::builder()
631 .status(200)
632 .header("etag", "\"res-etag\"")
633 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
634 .unwrap(),
635 )
636 }
637 });
638 let _ = hyper_util::server::conn::auto::Builder::new(
639 hyper_util::rt::TokioExecutor::new(),
640 )
641 .serve_connection(io, service)
642 .await;
643 });
644 }
645 });
646
647 let base_url = format!("http://127.0.0.1:{}", addr.port());
648 let resolver = CachingCardResolver::with_path(&base_url, "/agent.json").unwrap();
649 assert!(
650 resolver.cache.read().await.is_none(),
651 "cache should start empty"
652 );
653
654 let fetched = resolver.resolve().await.unwrap();
655 assert_eq!(fetched.name, "resolver-test");
656
657 let cached = resolver.cache.read().await;
659 let entry = cached
660 .as_ref()
661 .expect("cache should be populated after resolve");
662 assert_eq!(entry.card.name, "resolver-test");
663 assert_eq!(entry.etag, Some("\"res-etag\"".into()));
664 drop(cached);
665 }
666
667 #[tokio::test]
669 async fn caching_resolver_resolve_returns_error_on_failure() {
670 let resolver = CachingCardResolver::with_path("http://127.0.0.1:1", "/agent.json").unwrap();
672 let result = resolver.resolve().await;
673 assert!(
674 result.is_err(),
675 "resolve should fail with unreachable server"
676 );
677 }
678
679 #[test]
681 fn build_card_url_path_without_leading_slash() {
682 let url = build_card_url("http://localhost:8080", "custom/card.json").unwrap();
683 assert_eq!(url, "http://localhost:8080/custom/card.json");
684 }
685
686 #[tokio::test]
688 async fn fetch_card_from_url_success() {
689 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
690
691 let card = AgentCard {
692 url: None,
693 name: "url-fetch-test".into(),
694 version: "1.0".into(),
695 description: "URL fetch test".into(),
696 supported_interfaces: vec![AgentInterface {
697 url: "http://localhost:9090".into(),
698 protocol_binding: "JSONRPC".into(),
699 protocol_version: "1.0.0".into(),
700 tenant: None,
701 }],
702 provider: None,
703 icon_url: None,
704 documentation_url: None,
705 capabilities: AgentCapabilities::none(),
706 security_schemes: None,
707 security_requirements: None,
708 default_input_modes: vec![],
709 default_output_modes: vec![],
710 skills: vec![],
711 signatures: None,
712 };
713 let card_json = serde_json::to_string(&card).unwrap();
714
715 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
716 let addr = listener.local_addr().unwrap();
717
718 tokio::spawn(async move {
719 loop {
720 let (stream, _) = listener.accept().await.unwrap();
721 let io = hyper_util::rt::TokioIo::new(stream);
722 let body = card_json.clone();
723 tokio::spawn(async move {
724 let service = hyper::service::service_fn(move |_req| {
725 let body = body.clone();
726 async move {
727 Ok::<_, hyper::Error>(
728 hyper::Response::builder()
729 .status(200)
730 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
731 .unwrap(),
732 )
733 }
734 });
735 let _ = hyper_util::server::conn::auto::Builder::new(
736 hyper_util::rt::TokioExecutor::new(),
737 )
738 .serve_connection(io, service)
739 .await;
740 });
741 }
742 });
743
744 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
745 let fetched = fetch_card_from_url(&url).await.unwrap();
746 assert_eq!(fetched.name, "url-fetch-test");
747 }
748
749 #[tokio::test]
751 async fn resolve_agent_card_with_path_success() {
752 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
753
754 let card = AgentCard {
755 url: None,
756 name: "path-resolve-test".into(),
757 version: "2.0".into(),
758 description: "Path resolve test".into(),
759 supported_interfaces: vec![AgentInterface {
760 url: "http://localhost:9090".into(),
761 protocol_binding: "JSONRPC".into(),
762 protocol_version: "1.0.0".into(),
763 tenant: None,
764 }],
765 provider: None,
766 icon_url: None,
767 documentation_url: None,
768 capabilities: AgentCapabilities::none(),
769 security_schemes: None,
770 security_requirements: None,
771 default_input_modes: vec![],
772 default_output_modes: vec![],
773 skills: vec![],
774 signatures: None,
775 };
776 let card_json = serde_json::to_string(&card).unwrap();
777
778 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
779 let addr = listener.local_addr().unwrap();
780
781 tokio::spawn(async move {
782 loop {
783 let (stream, _) = listener.accept().await.unwrap();
784 let io = hyper_util::rt::TokioIo::new(stream);
785 let body = card_json.clone();
786 tokio::spawn(async move {
787 let service = hyper::service::service_fn(move |_req| {
788 let body = body.clone();
789 async move {
790 Ok::<_, hyper::Error>(
791 hyper::Response::builder()
792 .status(200)
793 .body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
794 .unwrap(),
795 )
796 }
797 });
798 let _ = hyper_util::server::conn::auto::Builder::new(
799 hyper_util::rt::TokioExecutor::new(),
800 )
801 .serve_connection(io, service)
802 .await;
803 });
804 }
805 });
806
807 let base_url = format!("http://127.0.0.1:{}", addr.port());
808 let fetched = resolve_agent_card_with_path(&base_url, "/custom.json")
809 .await
810 .unwrap();
811 assert_eq!(fetched.name, "path-resolve-test");
812 }
813
814 #[tokio::test]
816 async fn fetch_card_rejects_oversized_content_length() {
817 use tokio::io::AsyncWriteExt;
818
819 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
820 let addr = listener.local_addr().unwrap();
821
822 tokio::spawn(async move {
825 loop {
826 let (mut stream, _) = listener.accept().await.unwrap();
827 tokio::spawn(async move {
828 let mut buf = [0u8; 4096];
830 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
831 let response = "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 10000000\r\n\r\nsmall";
833 let _ = stream.write_all(response.as_bytes()).await;
834 drop(stream);
836 });
837 }
838 });
839
840 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
841 let result = fetch_card_with_metadata(&url, None).await;
842 match result {
843 Err(ClientError::Transport(msg)) => {
844 assert!(
845 msg.contains("too large"),
846 "should mention size limit: {msg}"
847 );
848 }
849 other => panic!("expected Transport error about size, got {other:?}"),
850 }
851 }
852
853 #[tokio::test]
860 async fn fetch_card_accepts_content_length_at_exact_limit() {
861 use tokio::io::AsyncWriteExt;
862
863 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
864 let addr = listener.local_addr().unwrap();
865 let max_size: u64 = 2 * 1024 * 1024; tokio::spawn(async move {
868 loop {
869 let (mut stream, _) = listener.accept().await.unwrap();
870 tokio::spawn(async move {
871 let mut buf = [0u8; 4096];
872 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
873 let response = format!(
877 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {max_size}\r\n\r\nsmall"
878 );
879 let _ = stream.write_all(response.as_bytes()).await;
880 drop(stream);
881 });
882 }
883 });
884
885 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
886 let result = fetch_card_with_metadata(&url, None).await;
887
888 match &result {
890 Err(ClientError::Transport(msg)) if msg.contains("too large") => {
891 panic!("Content-Length at exact limit should not be rejected: {msg}");
892 }
893 _ => {} }
895 }
896
897 #[tokio::test]
902 async fn fetch_card_rejects_oversized_body_without_content_length() {
903 use tokio::io::AsyncWriteExt;
904
905 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
906 let addr = listener.local_addr().unwrap();
907 let max_size = 2 * 1024 * 1024_usize; tokio::spawn(async move {
910 loop {
911 let (mut stream, _) = listener.accept().await.unwrap();
912 let body_size = max_size + 1;
913 tokio::spawn(async move {
914 let mut buf = [0u8; 4096];
915 let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
916 let header = "HTTP/1.0 200 OK\r\ncontent-type: application/json\r\n\r\n";
918 let _ = stream.write_all(header.as_bytes()).await;
919 let chunk = vec![b'x'; 64 * 1024];
921 let mut remaining = body_size;
922 while remaining > 0 {
923 let n = remaining.min(chunk.len());
924 if stream.write_all(&chunk[..n]).await.is_err() {
925 break;
926 }
927 remaining -= n;
928 }
929 drop(stream);
930 });
931 }
932 });
933
934 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
935 let result = fetch_card_with_metadata(&url, None).await;
936
937 match result {
938 Err(ClientError::Transport(msg)) => {
939 assert!(
940 msg.contains("too large"),
941 "should mention size limit: {msg}"
942 );
943 }
944 other => panic!("expected Transport error about size for body > limit, got {other:?}"),
945 }
946 }
947
948 #[tokio::test]
950 async fn fetch_card_with_metadata_304_with_last_modified() {
951 use a2a_protocol_types::{AgentCapabilities, AgentCard};
952
953 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
954 let addr = listener.local_addr().unwrap();
955
956 tokio::spawn(async move {
957 loop {
958 let (stream, _) = listener.accept().await.unwrap();
959 let io = hyper_util::rt::TokioIo::new(stream);
960 tokio::spawn(async move {
961 let service = hyper::service::service_fn(|_req| async {
962 Ok::<_, hyper::Error>(
963 hyper::Response::builder()
964 .status(304)
965 .body(http_body_util::Full::new(hyper::body::Bytes::new()))
966 .unwrap(),
967 )
968 });
969 let _ = hyper_util::server::conn::auto::Builder::new(
970 hyper_util::rt::TokioExecutor::new(),
971 )
972 .serve_connection(io, service)
973 .await;
974 });
975 }
976 });
977
978 let cached = CachedCard {
979 card: AgentCard {
980 url: None,
981 name: "lm-cached".into(),
982 version: "1.0".into(),
983 description: "Last-modified cached".into(),
984 supported_interfaces: vec![],
985 provider: None,
986 icon_url: None,
987 documentation_url: None,
988 capabilities: AgentCapabilities::none(),
989 security_schemes: None,
990 security_requirements: None,
991 default_input_modes: vec![],
992 default_output_modes: vec![],
993 skills: vec![],
994 signatures: None,
995 },
996 etag: None,
997 last_modified: Some("Mon, 01 Jan 2026 00:00:00 GMT".into()),
998 };
999
1000 let url = format!("http://127.0.0.1:{}/agent.json", addr.port());
1001 let (card, _, last_modified) = fetch_card_with_metadata(&url, Some(&cached)).await.unwrap();
1002 assert_eq!(card.name, "lm-cached");
1003 assert_eq!(last_modified, Some("Mon, 01 Jan 2026 00:00:00 GMT".into()));
1004 }
1005}