1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct AgentConfig {
25 pub agent: AgentMetadata,
27
28 #[serde(default)]
30 pub server: ServerConfig,
31
32 #[serde(default)]
34 pub skills: Vec<SkillConfig>,
35
36 #[serde(default)]
38 pub features: FeaturesConfig,
39
40 #[serde(default)]
42 pub llm: Option<LlmConfig>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct LlmConfig {
48 pub provider: String,
50 pub api_key: Option<String>,
52 pub model: Option<String>,
54 pub base_url: Option<String>,
56}
57
58impl AgentConfig {
59 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 pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
67 let expanded = expand_env_vars(content)?;
69 let config: AgentConfig = toml::from_str(&expanded)?;
70 config.validate()?;
71 Ok(config)
72 }
73
74 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 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 pub fn agent_url(&self) -> String {
106 format!("http://{}:{}", self.server.host, self.server.http_port)
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct AgentMetadata {
113 pub name: String,
115
116 #[serde(default)]
118 pub description: Option<String>,
119
120 #[serde(default)]
122 pub version: Option<String>,
123
124 #[serde(default)]
126 pub provider: Option<ProviderInfo>,
127
128 #[serde(default)]
130 pub documentation_url: Option<String>,
131
132 #[serde(default)]
135 pub implementation: Option<String>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ProviderInfo {
141 pub name: String,
142 pub url: String,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ServerConfig {
148 #[serde(default = "default_host")]
150 pub host: String,
151
152 #[serde(default = "default_http_port")]
154 pub http_port: u16,
155
156 #[serde(default = "default_ws_port")]
158 pub ws_port: u16,
159
160 #[serde(default)]
162 pub storage: StorageConfig,
163
164 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
183#[serde(tag = "type", rename_all = "lowercase")]
184pub enum StorageConfig {
185 #[default]
187 InMemory,
188
189 Sqlx {
191 url: String,
193
194 #[serde(default = "default_max_connections")]
196 max_connections: u32,
197
198 #[serde(default)]
200 enable_logging: bool,
201 },
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206#[serde(tag = "type", rename_all = "lowercase")]
207pub enum AuthConfig {
208 #[default]
210 None,
211
212 Bearer {
214 tokens: Vec<String>,
216
217 #[serde(skip_serializing_if = "Option::is_none")]
219 format: Option<String>,
220 },
221
222 ApiKey {
224 keys: Vec<String>,
226
227 #[serde(default = "default_api_key_location")]
229 location: String,
230
231 #[serde(default = "default_api_key_name")]
233 name: String,
234 },
235
236 Jwt {
238 #[serde(skip_serializing_if = "Option::is_none")]
241 secret: Option<String>,
242
243 #[serde(skip_serializing_if = "Option::is_none")]
245 rsa_pem_path: Option<String>,
246
247 #[serde(default = "default_jwt_algorithm")]
249 algorithm: String,
250
251 #[serde(skip_serializing_if = "Option::is_none")]
253 issuer: Option<String>,
254
255 #[serde(skip_serializing_if = "Option::is_none")]
257 audience: Option<String>,
258 },
259
260 OAuth2 {
262 client_id: String,
264
265 client_secret: String,
267
268 authorization_url: String,
270
271 token_url: String,
273
274 #[serde(skip_serializing_if = "Option::is_none")]
276 redirect_url: Option<String>,
277
278 #[serde(default = "default_oauth2_flow")]
280 flow: String,
281
282 #[serde(default)]
284 scopes: Vec<String>,
285 },
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct SkillConfig {
291 pub id: String,
293
294 pub name: String,
296
297 #[serde(default)]
299 pub description: Option<String>,
300
301 #[serde(default)]
303 pub keywords: Vec<String>,
304
305 #[serde(default)]
307 pub examples: Vec<String>,
308
309 #[serde(default = "default_formats")]
311 pub input_formats: Vec<String>,
312
313 #[serde(default = "default_formats")]
315 pub output_formats: Vec<String>,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct FeaturesConfig {
321 #[serde(default)]
323 pub streaming: bool,
324
325 #[serde(default)]
327 pub push_notifications: bool,
328
329 #[serde(default)]
331 pub state_history: bool,
332
333 #[serde(default)]
335 pub authenticated_card: bool,
336
337 #[serde(default)]
339 pub extensions: ExtensionsConfig,
340
341 #[serde(default)]
343 pub mcp_server: McpServerConfig,
344
345 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
366pub struct ExtensionsConfig {
367 #[serde(default)]
369 pub ap2: Option<Ap2ExtensionConfig>,
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct Ap2ExtensionConfig {
375 pub roles: Vec<String>,
377
378 #[serde(default)]
380 pub required: bool,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct McpServerConfig {
386 #[serde(default)]
388 pub enabled: bool,
389
390 #[serde(default = "default_true")]
392 pub stdio: bool,
393
394 #[serde(skip_serializing_if = "Option::is_none")]
396 pub name: Option<String>,
397
398 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
420pub struct McpClientConfig {
421 #[serde(default)]
423 pub enabled: bool,
424
425 #[serde(default)]
427 pub servers: Vec<McpServerConnection>,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct McpServerConnection {
433 pub name: String,
435
436 pub command: String,
438
439 #[serde(default)]
441 pub args: Vec<String>,
442
443 #[serde(default)]
445 pub env: std::collections::HashMap<String, String>,
446
447 #[serde(skip_serializing_if = "Option::is_none")]
449 pub cwd: Option<String>,
450}
451
452fn 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
496fn 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 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}