Skip to main content

a2a_server/
agent_card.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// Copyright A2A Contributors (https://github.com/a2aproject)
3// SPDX-License-Identifier: Apache-2.0
4use std::sync::Arc;
5
6use a2a::AgentCard;
7use axum::{
8    Json,
9    extract::State,
10    http::{HeaderMap, StatusCode, header},
11    response::IntoResponse,
12};
13
14/// Well-known path for the public agent card.
15pub const WELL_KNOWN_AGENT_CARD_PATH: &str = "/.well-known/agent-card.json";
16
17/// Trait for producing agent cards dynamically.
18pub trait AgentCardProducer: Send + Sync + 'static {
19    fn card(&self) -> AgentCard;
20}
21
22/// A static agent card producer.
23pub struct StaticAgentCard {
24    card: AgentCard,
25}
26
27impl StaticAgentCard {
28    pub fn new(card: AgentCard) -> Self {
29        StaticAgentCard { card }
30    }
31}
32
33impl AgentCardProducer for StaticAgentCard {
34    fn card(&self) -> AgentCard {
35        self.card.clone()
36    }
37}
38
39/// Create an axum router serving the agent card at `/.well-known/agent-card.json`
40/// with CORS headers for public discovery.
41pub fn agent_card_router<P: AgentCardProducer>(producer: Arc<P>) -> axum::Router {
42    axum::Router::new()
43        .route(
44            WELL_KNOWN_AGENT_CARD_PATH,
45            axum::routing::get(handle_agent_card::<P>),
46        )
47        .with_state(producer)
48}
49
50async fn handle_agent_card<P: AgentCardProducer>(
51    State(producer): State<Arc<P>>,
52    headers: HeaderMap,
53) -> impl IntoResponse {
54    let card = producer.card();
55    let mut resp_headers = HeaderMap::new();
56
57    // CORS headers for public discovery
58    let origin = headers
59        .get(header::ORIGIN)
60        .and_then(|v| v.to_str().ok())
61        .unwrap_or("*");
62
63    if origin != "*" {
64        resp_headers.insert(
65            header::ACCESS_CONTROL_ALLOW_ORIGIN,
66            origin.parse().unwrap_or_else(|_| "*".parse().unwrap()),
67        );
68        resp_headers.insert(
69            header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
70            "true".parse().unwrap(),
71        );
72        resp_headers.insert(header::VARY, "Origin".parse().unwrap());
73    } else {
74        resp_headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap());
75    }
76
77    // Spec §8.6.1: Agent Card responses SHOULD carry a Cache-Control
78    // max-age and an ETag derived from the card's version, since the card
79    // changes far less often than clients may fetch it.
80    resp_headers.insert(header::CACHE_CONTROL, "max-age=300".parse().unwrap());
81    if let Ok(etag) = format!("\"{}\"", card.version).parse() {
82        resp_headers.insert(header::ETAG, etag);
83    }
84
85    (StatusCode::OK, resp_headers, Json(card))
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use a2a::AgentCapabilities;
92    use axum::body::Body;
93    use axum::http::Request;
94    use tower::ServiceExt;
95
96    fn test_card() -> AgentCard {
97        AgentCard {
98            name: "TestAgent".into(),
99            description: "A test agent".into(),
100            version: "1.0".into(),
101            supported_interfaces: vec![],
102            capabilities: AgentCapabilities::default(),
103            default_input_modes: vec!["text".into()],
104            default_output_modes: vec!["text".into()],
105            skills: vec![],
106            provider: None,
107            documentation_url: None,
108            icon_url: None,
109            security_schemes: None,
110            security_requirements: None,
111            signatures: None,
112        }
113    }
114
115    #[test]
116    fn test_static_agent_card() {
117        let card = test_card();
118        let sac = StaticAgentCard::new(card.clone());
119        assert_eq!(sac.card().name, "TestAgent");
120    }
121
122    #[test]
123    fn test_well_known_path() {
124        assert_eq!(WELL_KNOWN_AGENT_CARD_PATH, "/.well-known/agent-card.json");
125    }
126
127    #[tokio::test]
128    async fn test_agent_card_router_no_origin() {
129        let card = test_card();
130        let producer = Arc::new(StaticAgentCard::new(card));
131        let app = agent_card_router(producer);
132
133        let req = Request::builder()
134            .uri("/.well-known/agent-card.json")
135            .body(Body::empty())
136            .unwrap();
137
138        let resp = app.oneshot(req).await.unwrap();
139        assert_eq!(resp.status(), StatusCode::OK);
140        assert_eq!(
141            resp.headers()
142                .get("access-control-allow-origin")
143                .unwrap()
144                .to_str()
145                .unwrap(),
146            "*"
147        );
148    }
149
150    #[tokio::test]
151    async fn test_agent_card_router_with_origin() {
152        let card = test_card();
153        let producer = Arc::new(StaticAgentCard::new(card));
154        let app = agent_card_router(producer);
155
156        let req = Request::builder()
157            .uri("/.well-known/agent-card.json")
158            .header("origin", "https://example.com")
159            .body(Body::empty())
160            .unwrap();
161
162        let resp = app.oneshot(req).await.unwrap();
163        assert_eq!(resp.status(), StatusCode::OK);
164        assert_eq!(
165            resp.headers()
166                .get("access-control-allow-origin")
167                .unwrap()
168                .to_str()
169                .unwrap(),
170            "https://example.com"
171        );
172        assert_eq!(
173            resp.headers()
174                .get("access-control-allow-credentials")
175                .unwrap()
176                .to_str()
177                .unwrap(),
178            "true"
179        );
180    }
181
182    #[tokio::test]
183    async fn test_agent_card_router_sets_cache_control_and_etag() {
184        // ACTS CARD-CACHE-001, spec §8.6.1: the response must carry a
185        // Cache-Control or ETag header; this crate provides both.
186        let card = test_card();
187        let producer = Arc::new(StaticAgentCard::new(card));
188        let app = agent_card_router(producer);
189
190        let req = Request::builder()
191            .uri("/.well-known/agent-card.json")
192            .body(Body::empty())
193            .unwrap();
194
195        let resp = app.oneshot(req).await.unwrap();
196        assert_eq!(resp.status(), StatusCode::OK);
197        assert!(
198            resp.headers()
199                .get("cache-control")
200                .unwrap()
201                .to_str()
202                .unwrap()
203                .contains("max-age")
204        );
205        assert_eq!(
206            resp.headers().get("etag").unwrap().to_str().unwrap(),
207            "\"1.0\""
208        );
209    }
210}