Skip to main content

a2a_agents/core/
config.rs

1//! Agent configuration with TOML support
2//!
3//! This module provides declarative configuration for A2A agents via TOML files.
4//! It supports environment variable interpolation and sensible defaults.
5
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8use thiserror::Error;
9
10#[derive(Debug, Error)]
11pub enum ConfigError {
12    #[error("Failed to read config file: {0}")]
13    IoError(#[from] std::io::Error),
14    #[error("Failed to parse TOML: {0}")]
15    TomlError(#[from] toml::de::Error),
16    #[error("Environment variable not found: {0}")]
17    EnvVarError(String),
18    #[error("Invalid configuration: {0}")]
19    ValidationError(String),
20}
21
22/// Complete agent configuration from TOML
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct AgentConfig {
25    /// Agent metadata
26    pub agent: AgentMetadata,
27
28    /// Server configuration
29    #[serde(default)]
30    pub server: ServerConfig,
31
32    /// Skills exposed by the agent
33    #[serde(default)]
34    pub skills: Vec<SkillConfig>,
35
36    /// Features enabled for the agent
37    #[serde(default)]
38    pub features: FeaturesConfig,
39
40    /// LLM Configuration
41    #[serde(default)]
42    pub llm: Option<LlmConfig>,
43}
44
45/// LLM Configuration
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct LlmConfig {
48    /// LLM Provider (e.g. "openai", "gemini")
49    pub provider: String,
50    /// API key for the LLM
51    pub api_key: Option<String>,
52    /// Model to use
53    pub model: Option<String>,
54    /// Base URL (for providers like openai that support local LLMs like ollama)
55    pub base_url: Option<String>,
56}
57
58impl AgentConfig {
59    /// Load configuration from a TOML file
60    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
61        let content = std::fs::read_to_string(path)?;
62        Self::from_toml(&content)
63    }
64
65    /// Parse configuration from TOML string
66    pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
67        // Expand environment variables
68        let expanded = expand_env_vars(content)?;
69        let config: AgentConfig = toml::from_str(&expanded)?;
70        config.validate()?;
71        Ok(config)
72    }
73
74    /// Validate the configuration
75    pub fn validate(&self) -> Result<(), ConfigError> {
76        if self.agent.name.is_empty() {
77            return Err(ConfigError::ValidationError(
78                "Agent name cannot be empty".to_string(),
79            ));
80        }
81
82        if !self.features.mcp_server.enabled
83            && self.server.http_port == 0
84            && self.server.ws_port == 0
85        {
86            return Err(ConfigError::ValidationError(
87                "At least one server port must be configured when MCP server is disabled"
88                    .to_string(),
89            ));
90        }
91
92        // Validate skills
93        for skill in &self.skills {
94            if skill.id.is_empty() {
95                return Err(ConfigError::ValidationError(
96                    "Skill ID cannot be empty".to_string(),
97                ));
98            }
99        }
100
101        Ok(())
102    }
103
104    /// Build agent card URL from server config
105    pub fn agent_url(&self) -> String {
106        format!("http://{}:{}", self.server.host, self.server.http_port)
107    }
108}
109
110/// Agent metadata and identity
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct AgentMetadata {
113    /// Agent name
114    pub name: String,
115
116    /// Agent description
117    #[serde(default)]
118    pub description: Option<String>,
119
120    /// Agent version
121    #[serde(default)]
122    pub version: Option<String>,
123
124    /// Provider information
125    #[serde(default)]
126    pub provider: Option<ProviderInfo>,
127
128    /// Documentation URL
129    #[serde(default)]
130    pub documentation_url: Option<String>,
131
132    /// The implementation handler to use for this agent (e.g. 'reimbursement', 'echo')
133    /// Used primarily by the generic a2a binary.
134    #[serde(default)]
135    pub implementation: Option<String>,
136}
137
138/// Provider information
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ProviderInfo {
141    pub name: String,
142    pub url: String,
143}
144
145/// Server configuration
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ServerConfig {
148    /// Host to bind to
149    #[serde(default = "default_host")]
150    pub host: String,
151
152    /// HTTP server port (0 to disable)
153    #[serde(default = "default_http_port")]
154    pub http_port: u16,
155
156    /// WebSocket server port (0 to disable)
157    #[serde(default = "default_ws_port")]
158    pub ws_port: u16,
159
160    /// Storage configuration
161    #[serde(default)]
162    pub storage: StorageConfig,
163
164    /// Authentication configuration
165    #[serde(default)]
166    pub auth: AuthConfig,
167}
168
169impl Default for ServerConfig {
170    fn default() -> Self {
171        Self {
172            host: default_host(),
173            http_port: default_http_port(),
174            ws_port: default_ws_port(),
175            storage: StorageConfig::default(),
176            auth: AuthConfig::default(),
177        }
178    }
179}
180
181/// Storage backend configuration
182#[derive(Debug, Clone, Default, Serialize, Deserialize)]
183#[serde(tag = "type", rename_all = "lowercase")]
184pub enum StorageConfig {
185    /// In-memory storage (default)
186    #[default]
187    InMemory,
188
189    /// SQLx-based persistent storage
190    Sqlx {
191        /// Database URL (supports env vars like ${DATABASE_URL})
192        url: String,
193
194        /// Maximum number of connections in the pool
195        #[serde(default = "default_max_connections")]
196        max_connections: u32,
197
198        /// Enable SQL query logging
199        #[serde(default)]
200        enable_logging: bool,
201    },
202}
203
204/// Authentication configuration
205#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206#[serde(tag = "type", rename_all = "lowercase")]
207pub enum AuthConfig {
208    /// No authentication (default for development)
209    #[default]
210    None,
211
212    /// Bearer token authentication
213    Bearer {
214        /// List of valid tokens (supports env vars)
215        tokens: Vec<String>,
216
217        /// Optional bearer format description (e.g., "JWT")
218        #[serde(skip_serializing_if = "Option::is_none")]
219        format: Option<String>,
220    },
221
222    /// API Key authentication
223    ApiKey {
224        /// Valid API keys
225        keys: Vec<String>,
226
227        /// Location of the API key: "header", "query", or "cookie"
228        #[serde(default = "default_api_key_location")]
229        location: String,
230
231        /// Name of the header/query param/cookie
232        #[serde(default = "default_api_key_name")]
233        name: String,
234    },
235
236    /// JWT (JSON Web Token) authentication
237    Jwt {
238        /// JWT secret for HMAC algorithms (HS256, HS384, HS512)
239        /// Use ${ENV_VAR} for environment variables
240        #[serde(skip_serializing_if = "Option::is_none")]
241        secret: Option<String>,
242
243        /// RSA public key in PEM format for RSA algorithms (RS256, RS384, RS512)
244        #[serde(skip_serializing_if = "Option::is_none")]
245        rsa_pem_path: Option<String>,
246
247        /// Algorithm to use (HS256, HS384, HS512, RS256, RS384, RS512)
248        #[serde(default = "default_jwt_algorithm")]
249        algorithm: String,
250
251        /// Required issuer (iss claim)
252        #[serde(skip_serializing_if = "Option::is_none")]
253        issuer: Option<String>,
254
255        /// Required audience (aud claim)
256        #[serde(skip_serializing_if = "Option::is_none")]
257        audience: Option<String>,
258    },
259
260    /// OAuth2 authentication
261    OAuth2 {
262        /// Client ID
263        client_id: String,
264
265        /// Client secret (use ${ENV_VAR} for environment variables)
266        client_secret: String,
267
268        /// Authorization URL
269        authorization_url: String,
270
271        /// Token URL
272        token_url: String,
273
274        /// Redirect URL for authorization code flow
275        #[serde(skip_serializing_if = "Option::is_none")]
276        redirect_url: Option<String>,
277
278        /// OAuth2 flow type: "authorization_code" or "client_credentials"
279        #[serde(default = "default_oauth2_flow")]
280        flow: String,
281
282        /// Required scopes
283        #[serde(default)]
284        scopes: Vec<String>,
285    },
286}
287
288/// Skill configuration
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct SkillConfig {
291    /// Unique skill identifier
292    pub id: String,
293
294    /// Human-readable skill name
295    pub name: String,
296
297    /// Skill description
298    #[serde(default)]
299    pub description: Option<String>,
300
301    /// Keywords for skill discovery
302    #[serde(default)]
303    pub keywords: Vec<String>,
304
305    /// Example queries for this skill
306    #[serde(default)]
307    pub examples: Vec<String>,
308
309    /// Supported input formats
310    #[serde(default = "default_formats")]
311    pub input_formats: Vec<String>,
312
313    /// Supported output formats
314    #[serde(default = "default_formats")]
315    pub output_formats: Vec<String>,
316}
317
318/// Features configuration
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct FeaturesConfig {
321    /// Enable streaming updates
322    #[serde(default)]
323    pub streaming: bool,
324
325    /// Enable push notifications
326    #[serde(default)]
327    pub push_notifications: bool,
328
329    /// Enable state transition history
330    #[serde(default)]
331    pub state_history: bool,
332
333    /// Enable authenticated extended card
334    #[serde(default)]
335    pub authenticated_card: bool,
336
337    /// Protocol extensions (AP2, etc.)
338    #[serde(default)]
339    pub extensions: ExtensionsConfig,
340
341    /// MCP server configuration (expose agent as MCP server)
342    #[serde(default)]
343    pub mcp_server: McpServerConfig,
344
345    /// MCP client configuration (connect to MCP servers to use their tools)
346    #[serde(default)]
347    pub mcp_client: McpClientConfig,
348}
349
350impl Default for FeaturesConfig {
351    fn default() -> Self {
352        Self {
353            streaming: true,
354            push_notifications: true,
355            state_history: true,
356            authenticated_card: false,
357            extensions: ExtensionsConfig::default(),
358            mcp_server: McpServerConfig::default(),
359            mcp_client: McpClientConfig::default(),
360        }
361    }
362}
363
364/// Protocol extensions configuration
365#[derive(Debug, Clone, Default, Serialize, Deserialize)]
366pub struct ExtensionsConfig {
367    /// AP2 (Agent Payments Protocol) extension
368    #[serde(default)]
369    pub ap2: Option<Ap2ExtensionConfig>,
370}
371
372/// AP2 extension configuration
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct Ap2ExtensionConfig {
375    /// AP2 roles this agent performs (merchant, shopper, credentials-provider, payment-processor)
376    pub roles: Vec<String>,
377
378    /// Whether clients must understand AP2 to interact with this agent
379    #[serde(default)]
380    pub required: bool,
381}
382
383/// MCP server configuration
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct McpServerConfig {
386    /// Enable MCP server (expose agent as MCP tools)
387    #[serde(default)]
388    pub enabled: bool,
389
390    /// Use stdio transport (for Claude Desktop integration)
391    #[serde(default = "default_true")]
392    pub stdio: bool,
393
394    /// Server name (defaults to agent name)
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub name: Option<String>,
397
398    /// Server version (defaults to agent version)
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub version: Option<String>,
401}
402
403impl Default for McpServerConfig {
404    fn default() -> Self {
405        Self {
406            enabled: false,
407            stdio: true,
408            name: None,
409            version: None,
410        }
411    }
412}
413
414fn default_true() -> bool {
415    true
416}
417
418/// MCP client configuration
419#[derive(Debug, Clone, Default, Serialize, Deserialize)]
420pub struct McpClientConfig {
421    /// Enable MCP client (connect to MCP servers to use their tools)
422    #[serde(default)]
423    pub enabled: bool,
424
425    /// MCP servers to connect to
426    #[serde(default)]
427    pub servers: Vec<McpServerConnection>,
428}
429
430/// Configuration for connecting to an MCP server
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct McpServerConnection {
433    /// Unique name for this MCP server
434    pub name: String,
435
436    /// Command to run to start the MCP server
437    pub command: String,
438
439    /// Arguments to pass to the command
440    #[serde(default)]
441    pub args: Vec<String>,
442
443    /// Environment variables to set
444    #[serde(default)]
445    pub env: std::collections::HashMap<String, String>,
446
447    /// Working directory for the command
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub cwd: Option<String>,
450}
451
452// Default value functions
453
454fn default_host() -> String {
455    std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string())
456}
457
458fn default_http_port() -> u16 {
459    std::env::var("HTTP_PORT")
460        .ok()
461        .and_then(|s| s.parse().ok())
462        .unwrap_or(8080)
463}
464
465fn default_ws_port() -> u16 {
466    std::env::var("WS_PORT")
467        .ok()
468        .and_then(|s| s.parse().ok())
469        .unwrap_or(8081)
470}
471
472fn default_max_connections() -> u32 {
473    10
474}
475
476fn default_jwt_algorithm() -> String {
477    "HS256".to_string()
478}
479
480fn default_oauth2_flow() -> String {
481    "authorization_code".to_string()
482}
483
484fn default_api_key_location() -> String {
485    "header".to_string()
486}
487
488fn default_api_key_name() -> String {
489    "X-API-Key".to_string()
490}
491
492fn default_formats() -> Vec<String> {
493    vec!["text".to_string(), "data".to_string()]
494}
495
496/// Expand environment variables in the config string
497/// Supports ${VAR_NAME} and ${VAR_NAME:-default} syntax
498fn expand_env_vars(content: &str) -> Result<String, ConfigError> {
499    use std::sync::LazyLock;
500    static ENV_VAR_RE: LazyLock<regex::Regex> =
501        LazyLock::new(|| regex::Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)\}").unwrap());
502
503    let mut result = content.to_string();
504    let re = &*ENV_VAR_RE;
505
506    for cap in re.captures_iter(content) {
507        let full_match = &cap[0];
508        let var_name = &cap[1];
509
510        let value =
511            std::env::var(var_name).map_err(|_| ConfigError::EnvVarError(var_name.to_string()))?;
512
513        result = result.replace(full_match, &value);
514    }
515
516    Ok(result)
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn test_minimal_config() {
525        let toml = r#"
526            [agent]
527            name = "Test Agent"
528        "#;
529
530        let config = AgentConfig::from_toml(toml).unwrap();
531        assert_eq!(config.agent.name, "Test Agent");
532        assert_eq!(config.server.http_port, 8080);
533    }
534
535    #[test]
536    fn test_complete_config() {
537        let toml = r#"
538            [agent]
539            name = "Reimbursement Agent"
540            description = "Handles employee reimbursements"
541            version = "1.0.0"
542
543            [agent.provider]
544            name = "Example Corp"
545            url = "https://example.com"
546
547            [server]
548            host = "0.0.0.0"
549            http_port = 3000
550            ws_port = 3001
551
552            [server.storage]
553            type = "sqlx"
554            url = "sqlite:test.db"
555            max_connections = 5
556            enable_logging = true
557
558            [server.auth]
559            type = "bearer"
560            tokens = ["token123"]
561            format = "JWT"
562
563            [[skills]]
564            id = "process_expense"
565            name = "Process Expense"
566            description = "Process expense reimbursements"
567            keywords = ["expense", "reimbursement"]
568            examples = ["Reimburse my $50 lunch"]
569            input_formats = ["text", "data"]
570            output_formats = ["text", "data"]
571
572            [features]
573            streaming = true
574            push_notifications = true
575            state_history = true
576            authenticated_card = false
577        "#;
578
579        let config = AgentConfig::from_toml(toml).unwrap();
580        assert_eq!(config.agent.name, "Reimbursement Agent");
581        assert_eq!(config.server.http_port, 3000);
582        assert_eq!(config.skills.len(), 1);
583        assert_eq!(config.skills[0].id, "process_expense");
584        assert!(config.features.streaming);
585    }
586
587    #[test]
588    fn test_env_var_expansion() {
589        // SAFETY: This is a test function run in a controlled environment
590        // We're setting an environment variable that won't affect other tests
591        unsafe {
592            std::env::set_var("TEST_TOKEN", "secret123");
593        }
594
595        let content = r#"
596            [server.auth]
597            type = "bearer"
598            tokens = ["${TEST_TOKEN}"]
599        "#;
600
601        let expanded = expand_env_vars(content).unwrap();
602        assert!(expanded.contains("secret123"));
603    }
604
605    #[test]
606    #[cfg(feature = "auth")]
607    fn test_jwt_auth_config() {
608        let toml = r#"
609            [agent]
610            name = "JWT Agent"
611
612            [server.auth]
613            type = "jwt"
614            secret = "my-jwt-secret"
615            algorithm = "HS256"
616            issuer = "https://auth.example.com"
617            audience = "api://my-agent"
618        "#;
619
620        let config = AgentConfig::from_toml(toml).unwrap();
621        match &config.server.auth {
622            AuthConfig::Jwt {
623                secret,
624                algorithm,
625                issuer,
626                audience,
627                ..
628            } => {
629                assert_eq!(secret.as_ref().unwrap(), "my-jwt-secret");
630                assert_eq!(algorithm, "HS256");
631                assert_eq!(issuer.as_ref().unwrap(), "https://auth.example.com");
632                assert_eq!(audience.as_ref().unwrap(), "api://my-agent");
633            }
634            _ => panic!("Expected JWT auth config"),
635        }
636    }
637
638    #[test]
639    #[cfg(feature = "auth")]
640    fn test_oauth2_auth_config() {
641        let toml = r#"
642            [agent]
643            name = "OAuth2 Agent"
644
645            [server.auth]
646            type = "oauth2"
647            client_id = "my-client-id"
648            client_secret = "my-client-secret"
649            authorization_url = "https://provider.com/auth"
650            token_url = "https://provider.com/token"
651            flow = "authorization_code"
652            scopes = ["read", "write"]
653        "#;
654
655        let config = AgentConfig::from_toml(toml).unwrap();
656        match &config.server.auth {
657            AuthConfig::OAuth2 {
658                client_id,
659                client_secret,
660                flow,
661                scopes,
662                ..
663            } => {
664                assert_eq!(client_id, "my-client-id");
665                assert_eq!(client_secret, "my-client-secret");
666                assert_eq!(flow, "authorization_code");
667                assert_eq!(scopes.len(), 2);
668                assert_eq!(scopes[0], "read");
669            }
670            _ => panic!("Expected OAuth2 auth config"),
671        }
672    }
673
674    #[test]
675    fn test_validation_empty_name() {
676        let toml = r#"
677            [agent]
678            name = ""
679        "#;
680
681        let result = AgentConfig::from_toml(toml);
682        assert!(result.is_err());
683    }
684
685    #[test]
686    fn test_ap2_extension_config() {
687        let toml = r#"
688            [agent]
689            name = "Merchant Agent"
690
691            [features.extensions.ap2]
692            roles = ["merchant", "payment-processor"]
693            required = true
694        "#;
695
696        let config = AgentConfig::from_toml(toml).unwrap();
697        let ap2 = config.features.extensions.ap2.unwrap();
698        assert_eq!(ap2.roles, vec!["merchant", "payment-processor"]);
699        assert!(ap2.required);
700    }
701
702    #[test]
703    fn test_ap2_extension_config_optional() {
704        let toml = r#"
705            [agent]
706            name = "Plain Agent"
707        "#;
708
709        let config = AgentConfig::from_toml(toml).unwrap();
710        assert!(config.features.extensions.ap2.is_none());
711    }
712}