Skip to main content

a2a_ao/
agent_card.rs

1//! Agent Card — the self-describing metadata document for agent discovery.
2//!
3//! Every A2A-compatible agent publishes an Agent Card at:
4//!   `/.well-known/agent-card.json`
5//!
6//! The card describes the agent's capabilities, skills, supported interfaces,
7//! security schemes, and input/output modes.
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use url::Url;
12
13use crate::error::{A2AError, A2AResult};
14
15/// An A2A Agent Card — metadata describing an agent's capabilities.
16///
17/// Published at `/.well-known/agent-card.json` for discovery.
18#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
19#[serde(rename_all = "camelCase")]
20pub struct AgentCard {
21    /// Human-readable name of the agent.
22    pub name: String,
23
24    /// Description of what the agent does.
25    pub description: String,
26
27    /// Semantic version of the agent.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub version: Option<String>,
30
31    /// The provider/organization that created this agent.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub provider: Option<AgentProvider>,
34
35    /// URL to the agent's icon/logo.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub icon_url: Option<Url>,
38
39    /// URL to the agent's documentation.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub documentation_url: Option<Url>,
42
43    /// Interfaces supported by this agent (URLs + protocol bindings).
44    pub supported_interfaces: Vec<AgentInterface>,
45
46    /// Capabilities declared by this agent.
47    #[serde(default)]
48    pub capabilities: AgentCapabilities,
49
50    /// Security schemes supported by this agent.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub security_schemes: Vec<SecurityScheme>,
53
54    /// Security requirements (references to security_schemes).
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub security: Vec<SecurityRequirement>,
57
58    /// Default input content types accepted.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub default_input_modes: Vec<ContentType>,
61
62    /// Default output content types produced.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub default_output_modes: Vec<ContentType>,
65
66    /// Skills (specific abilities) of this agent.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub skills: Vec<AgentSkill>,
69}
70
71impl AgentCard {
72    /// Discover an agent by fetching its Agent Card from the well-known endpoint.
73    ///
74    /// Fetches `{base_url}/.well-known/agent-card.json`.
75    pub async fn discover(base_url: &str) -> A2AResult<Self> {
76        let url = format!(
77            "{}/.well-known/agent-card.json",
78            base_url.trim_end_matches('/')
79        );
80
81        tracing::info!(url = %url, "Discovering A2A agent");
82
83        let response = reqwest::get(&url)
84            .await
85            .map_err(|e| A2AError::DiscoveryFailed(format!("Failed to fetch agent card: {e}")))?;
86
87        if !response.status().is_success() {
88            return Err(A2AError::DiscoveryFailed(format!(
89                "Agent card endpoint returned {}",
90                response.status()
91            )));
92        }
93
94        let card: AgentCard = response
95            .json()
96            .await
97            .map_err(|e| A2AError::InvalidAgentCard(format!("Failed to parse agent card: {e}")))?;
98
99        card.validate()?;
100
101        tracing::info!(
102            name = %card.name,
103            skills = card.skills.len(),
104            "Discovered A2A agent"
105        );
106
107        Ok(card)
108    }
109
110    /// Validate the agent card has required fields.
111    pub fn validate(&self) -> A2AResult<()> {
112        if self.name.is_empty() {
113            return Err(A2AError::InvalidAgentCard("name is required".into()));
114        }
115        if self.description.is_empty() {
116            return Err(A2AError::InvalidAgentCard("description is required".into()));
117        }
118        if self.supported_interfaces.is_empty() {
119            return Err(A2AError::InvalidAgentCard(
120                "at least one supported interface is required".into(),
121            ));
122        }
123        Ok(())
124    }
125
126    /// Check if this agent supports streaming.
127    pub fn supports_streaming(&self) -> bool {
128        self.capabilities.streaming
129    }
130
131    /// Check if this agent supports push notifications.
132    pub fn supports_push_notifications(&self) -> bool {
133        self.capabilities.push_notifications
134    }
135
136    /// Find a skill by ID.
137    pub fn find_skill(&self, skill_id: &str) -> Option<&AgentSkill> {
138        self.skills.iter().find(|s| s.id == skill_id)
139    }
140
141    /// Get the primary interface URL.
142    pub fn primary_url(&self) -> Option<&Url> {
143        self.supported_interfaces.first().map(|i| &i.url)
144    }
145}
146
147/// Information about the agent's provider/creator.
148#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
149#[serde(rename_all = "camelCase")]
150pub struct AgentProvider {
151    /// Name of the organization.
152    pub organization: String,
153
154    /// URL of the organization.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub url: Option<Url>,
157}
158
159/// A supported interface (endpoint + protocol binding).
160#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
161#[serde(rename_all = "camelCase")]
162pub struct AgentInterface {
163    /// The URL of this interface.
164    pub url: Url,
165
166    /// Protocol binding (e.g., "jsonrpc+http", "grpc").
167    pub protocol_binding: ProtocolBinding,
168
169    /// Protocol version (e.g., "1.0").
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub protocol_version: Option<String>,
172}
173
174/// Protocol binding for an A2A interface.
175#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
176#[serde(rename_all = "kebab-case")]
177pub enum ProtocolBinding {
178    /// JSON-RPC 2.0 over HTTP(S).
179    JsonrpcHttp,
180    /// gRPC.
181    Grpc,
182    /// HTTP + JSON (REST-style).
183    HttpJson,
184    /// Custom binding.
185    #[serde(untagged)]
186    Custom(String),
187}
188
189/// Capabilities declared by the agent.
190#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
191#[serde(rename_all = "camelCase")]
192pub struct AgentCapabilities {
193    /// Whether the agent supports SSE streaming.
194    #[serde(default)]
195    pub streaming: bool,
196
197    /// Whether the agent supports push notifications (webhooks).
198    #[serde(default)]
199    pub push_notifications: bool,
200
201    /// Whether an extended agent card is available post-authentication.
202    #[serde(default)]
203    pub extended_agent_card: bool,
204
205    /// Declared extensions.
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub extensions: Vec<AgentExtension>,
208}
209
210/// An extension declared by the agent.
211#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
212#[serde(rename_all = "camelCase")]
213pub struct AgentExtension {
214    /// URI identifying this extension.
215    pub uri: String,
216
217    /// Human-readable description.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub description: Option<String>,
220
221    /// Whether this extension is required for interaction.
222    #[serde(default)]
223    pub required: bool,
224}
225
226/// A specific skill/ability of the agent.
227#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
228#[serde(rename_all = "camelCase")]
229pub struct AgentSkill {
230    /// Unique identifier for this skill.
231    pub id: String,
232
233    /// Human-readable name.
234    pub name: String,
235
236    /// Description of what this skill does.
237    pub description: String,
238
239    /// Tags for categorization and search.
240    #[serde(default, skip_serializing_if = "Vec::is_empty")]
241    pub tags: Vec<String>,
242
243    /// Example prompts that demonstrate this skill.
244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
245    pub examples: Vec<String>,
246
247    /// Accepted input modes for this skill (overrides agent default).
248    #[serde(default, skip_serializing_if = "Vec::is_empty")]
249    pub input_modes: Vec<ContentType>,
250
251    /// Output modes for this skill (overrides agent default).
252    #[serde(default, skip_serializing_if = "Vec::is_empty")]
253    pub output_modes: Vec<ContentType>,
254}
255
256/// Content type descriptor.
257#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
258#[serde(rename_all = "camelCase")]
259pub struct ContentType {
260    /// MIME type (e.g., "text/plain", "application/json", "image/png").
261    pub media_type: String,
262}
263
264impl ContentType {
265    pub fn text() -> Self {
266        Self {
267            media_type: "text/plain".into(),
268        }
269    }
270    pub fn json() -> Self {
271        Self {
272            media_type: "application/json".into(),
273        }
274    }
275    pub fn a2a_json() -> Self {
276        Self {
277            media_type: "application/a2a+json".into(),
278        }
279    }
280}
281
282/// A security scheme (parity with OpenAPI security schemes).
283#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
284#[serde(rename_all = "camelCase", tag = "type")]
285pub enum SecurityScheme {
286    /// API key in header, query, or cookie.
287    #[serde(rename = "apiKey")]
288    ApiKey {
289        name: String,
290        #[serde(rename = "in")]
291        location: ApiKeyLocation,
292        #[serde(skip_serializing_if = "Option::is_none")]
293        description: Option<String>,
294    },
295
296    /// HTTP authentication (Bearer, Basic, etc.).
297    Http {
298        scheme: String,
299        #[serde(skip_serializing_if = "Option::is_none")]
300        bearer_format: Option<String>,
301        #[serde(skip_serializing_if = "Option::is_none")]
302        description: Option<String>,
303    },
304
305    /// OAuth 2.0 flows.
306    #[serde(rename = "oauth2")]
307    OAuth2 {
308        flows: serde_json::Value,
309        #[serde(skip_serializing_if = "Option::is_none")]
310        description: Option<String>,
311    },
312
313    /// OpenID Connect.
314    OpenIdConnect {
315        open_id_connect_url: Url,
316        #[serde(skip_serializing_if = "Option::is_none")]
317        description: Option<String>,
318    },
319}
320
321/// Location for API key security scheme.
322#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
323#[serde(rename_all = "lowercase")]
324pub enum ApiKeyLocation {
325    Header,
326    Query,
327    Cookie,
328}
329
330/// A security requirement referencing a named security scheme.
331#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
332pub struct SecurityRequirement {
333    /// The name of the security scheme.
334    pub scheme: String,
335    /// Required scopes (for OAuth2).
336    #[serde(default, skip_serializing_if = "Vec::is_empty")]
337    pub scopes: Vec<String>,
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn test_serialize_agent_card() {
346        let card = AgentCard {
347            name: "summarizer".into(),
348            description: "Summarizes documents with citations".into(),
349            version: Some("1.0.0".into()),
350            provider: Some(AgentProvider {
351                organization: "AgentOven".into(),
352                url: Some(Url::parse("https://agentoven.dev").unwrap()),
353            }),
354            icon_url: None,
355            documentation_url: None,
356            supported_interfaces: vec![AgentInterface {
357                url: Url::parse("https://agent.example.com/a2a").unwrap(),
358                protocol_binding: ProtocolBinding::JsonrpcHttp,
359                protocol_version: Some("1.0".into()),
360            }],
361            capabilities: AgentCapabilities {
362                streaming: true,
363                push_notifications: true,
364                ..Default::default()
365            },
366            security_schemes: vec![SecurityScheme::Http {
367                scheme: "bearer".into(),
368                bearer_format: Some("JWT".into()),
369                description: None,
370            }],
371            security: vec![],
372            default_input_modes: vec![ContentType::text()],
373            default_output_modes: vec![ContentType::text(), ContentType::json()],
374            skills: vec![AgentSkill {
375                id: "summarize".into(),
376                name: "Document Summarization".into(),
377                description: "Summarizes long documents into concise summaries".into(),
378                tags: vec!["summarization".into(), "nlp".into()],
379                examples: vec!["Summarize this quarterly report".into()],
380                input_modes: vec![],
381                output_modes: vec![],
382            }],
383        };
384
385        let json = serde_json::to_string_pretty(&card).unwrap();
386        assert!(json.contains("summarizer"));
387        assert!(json.contains("agentoven.dev"));
388
389        // Round-trip
390        let parsed: AgentCard = serde_json::from_str(&json).unwrap();
391        assert_eq!(parsed.name, "summarizer");
392        assert!(parsed.capabilities.streaming);
393    }
394
395    #[test]
396    fn test_validate_agent_card() {
397        let mut card = AgentCard {
398            name: "".into(),
399            description: "test".into(),
400            version: None,
401            provider: None,
402            icon_url: None,
403            documentation_url: None,
404            supported_interfaces: vec![],
405            capabilities: Default::default(),
406            security_schemes: vec![],
407            security: vec![],
408            default_input_modes: vec![],
409            default_output_modes: vec![],
410            skills: vec![],
411        };
412
413        assert!(card.validate().is_err());
414
415        card.name = "test-agent".into();
416        assert!(card.validate().is_err()); // still missing interfaces
417
418        card.supported_interfaces.push(AgentInterface {
419            url: Url::parse("https://example.com").unwrap(),
420            protocol_binding: ProtocolBinding::JsonrpcHttp,
421            protocol_version: None,
422        });
423        assert!(card.validate().is_ok());
424    }
425}