use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to parse TOML: {0}")]
TomlError(#[from] toml::de::Error),
#[error("Environment variable not found: {0}")]
EnvVarError(String),
#[error("Invalid configuration: {0}")]
ValidationError(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub agent: AgentMetadata,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub skills: Vec<SkillConfig>,
#[serde(default)]
pub features: FeaturesConfig,
#[serde(default)]
pub llm: Option<LlmConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig {
pub provider: String,
pub api_key: Option<String>,
pub model: Option<String>,
pub base_url: Option<String>,
}
impl AgentConfig {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path)?;
Self::from_toml(&content)
}
pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
let expanded = expand_env_vars(content)?;
let config: AgentConfig = toml::from_str(&expanded)?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.agent.name.is_empty() {
return Err(ConfigError::ValidationError(
"Agent name cannot be empty".to_string(),
));
}
if !self.features.mcp_server.enabled && self.server.http_port == 0 {
return Err(ConfigError::ValidationError(
"The HTTP server port must be configured when MCP server is disabled".to_string(),
));
}
for skill in &self.skills {
if skill.id.is_empty() {
return Err(ConfigError::ValidationError(
"Skill ID cannot be empty".to_string(),
));
}
}
Ok(())
}
pub fn agent_url(&self) -> String {
format!("http://{}:{}", self.server.host, self.server.http_port)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentMetadata {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub provider: Option<ProviderInfo>,
#[serde(default)]
pub documentation_url: Option<String>,
#[serde(default)]
pub implementation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderInfo {
pub name: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_http_port")]
pub http_port: u16,
#[serde(default)]
pub storage: StorageConfig,
#[serde(default)]
pub auth: AuthConfig,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
http_port: default_http_port(),
storage: StorageConfig::default(),
auth: AuthConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum StorageConfig {
#[default]
InMemory,
Sqlx {
url: String,
#[serde(default = "default_max_connections")]
max_connections: u32,
#[serde(default)]
enable_logging: bool,
},
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum AuthConfig {
#[default]
None,
Bearer {
tokens: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<String>,
},
ApiKey {
keys: Vec<String>,
#[serde(default = "default_api_key_location")]
location: String,
#[serde(default = "default_api_key_name")]
name: String,
},
Jwt {
#[serde(skip_serializing_if = "Option::is_none")]
secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rsa_pem_path: Option<String>,
#[serde(default = "default_jwt_algorithm")]
algorithm: String,
#[serde(skip_serializing_if = "Option::is_none")]
issuer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
audience: Option<String>,
},
OAuth2 {
client_id: String,
client_secret: String,
authorization_url: String,
token_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
redirect_url: Option<String>,
#[serde(default = "default_oauth2_flow")]
flow: String,
#[serde(default)]
scopes: Vec<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillConfig {
pub id: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub examples: Vec<String>,
#[serde(default = "default_formats")]
pub input_formats: Vec<String>,
#[serde(default = "default_formats")]
pub output_formats: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeaturesConfig {
#[serde(default)]
pub streaming: bool,
#[serde(default)]
pub push_notifications: bool,
#[serde(default)]
pub state_history: bool,
#[serde(default)]
pub authenticated_card: bool,
#[serde(default)]
pub extensions: ExtensionsConfig,
#[serde(default)]
pub mcp_server: McpServerConfig,
#[serde(default)]
pub mcp_client: McpClientConfig,
}
impl Default for FeaturesConfig {
fn default() -> Self {
Self {
streaming: true,
push_notifications: true,
state_history: true,
authenticated_card: false,
extensions: ExtensionsConfig::default(),
mcp_server: McpServerConfig::default(),
mcp_client: McpClientConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExtensionsConfig {
#[serde(default)]
pub ap2: Option<Ap2ExtensionConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ap2ExtensionConfig {
pub roles: Vec<String>,
#[serde(default)]
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_true")]
pub stdio: bool,
#[serde(default)]
pub http: McpHttpConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
impl Default for McpServerConfig {
fn default() -> Self {
Self {
enabled: false,
stdio: true,
http: McpHttpConfig::default(),
name: None,
version: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpHttpConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_mcp_http_host")]
pub host: String,
#[serde(default = "default_mcp_http_port")]
pub port: u16,
#[serde(default = "default_mcp_http_path")]
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_hosts: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_origins: Option<Vec<String>>,
}
impl Default for McpHttpConfig {
fn default() -> Self {
Self {
enabled: false,
host: default_mcp_http_host(),
port: default_mcp_http_port(),
path: default_mcp_http_path(),
allowed_hosts: None,
allowed_origins: None,
}
}
}
fn default_true() -> bool {
true
}
fn default_mcp_http_host() -> String {
"127.0.0.1".to_string()
}
fn default_mcp_http_port() -> u16 {
8000
}
fn default_mcp_http_path() -> String {
"/mcp".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct McpClientConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub servers: Vec<McpServerConnection>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConnection {
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: std::collections::HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}
fn default_host() -> String {
std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string())
}
fn default_http_port() -> u16 {
std::env::var("HTTP_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8080)
}
fn default_max_connections() -> u32 {
10
}
fn default_jwt_algorithm() -> String {
"HS256".to_string()
}
fn default_oauth2_flow() -> String {
"authorization_code".to_string()
}
fn default_api_key_location() -> String {
"header".to_string()
}
fn default_api_key_name() -> String {
"X-API-Key".to_string()
}
fn default_formats() -> Vec<String> {
vec!["text".to_string(), "data".to_string()]
}
fn expand_env_vars(content: &str) -> Result<String, ConfigError> {
use std::sync::LazyLock;
static ENV_VAR_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)\}").unwrap());
let mut result = content.to_string();
let re = &*ENV_VAR_RE;
for cap in re.captures_iter(content) {
let full_match = &cap[0];
let var_name = &cap[1];
let value =
std::env::var(var_name).map_err(|_| ConfigError::EnvVarError(var_name.to_string()))?;
result = result.replace(full_match, &value);
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_minimal_config() {
let toml = r#"
[agent]
name = "Test Agent"
"#;
let config = AgentConfig::from_toml(toml).unwrap();
assert_eq!(config.agent.name, "Test Agent");
assert_eq!(config.server.http_port, 8080);
}
#[test]
fn test_complete_config() {
let toml = r#"
[agent]
name = "Reimbursement Agent"
description = "Handles employee reimbursements"
version = "1.0.0"
[agent.provider]
name = "Example Corp"
url = "https://example.com"
[server]
host = "0.0.0.0"
http_port = 3000
[server.storage]
type = "sqlx"
url = "sqlite:test.db"
max_connections = 5
enable_logging = true
[server.auth]
type = "bearer"
tokens = ["token123"]
format = "JWT"
[[skills]]
id = "process_expense"
name = "Process Expense"
description = "Process expense reimbursements"
keywords = ["expense", "reimbursement"]
examples = ["Reimburse my $50 lunch"]
input_formats = ["text", "data"]
output_formats = ["text", "data"]
[features]
streaming = true
push_notifications = true
state_history = true
authenticated_card = false
"#;
let config = AgentConfig::from_toml(toml).unwrap();
assert_eq!(config.agent.name, "Reimbursement Agent");
assert_eq!(config.server.http_port, 3000);
assert_eq!(config.skills.len(), 1);
assert_eq!(config.skills[0].id, "process_expense");
assert!(config.features.streaming);
}
#[test]
fn test_env_var_expansion() {
unsafe {
std::env::set_var("TEST_TOKEN", "secret123");
}
let content = r#"
[server.auth]
type = "bearer"
tokens = ["${TEST_TOKEN}"]
"#;
let expanded = expand_env_vars(content).unwrap();
assert!(expanded.contains("secret123"));
}
#[test]
#[cfg(feature = "auth")]
fn test_jwt_auth_config() {
let toml = r#"
[agent]
name = "JWT Agent"
[server.auth]
type = "jwt"
secret = "my-jwt-secret"
algorithm = "HS256"
issuer = "https://auth.example.com"
audience = "api://my-agent"
"#;
let config = AgentConfig::from_toml(toml).unwrap();
match &config.server.auth {
AuthConfig::Jwt {
secret,
algorithm,
issuer,
audience,
..
} => {
assert_eq!(secret.as_ref().unwrap(), "my-jwt-secret");
assert_eq!(algorithm, "HS256");
assert_eq!(issuer.as_ref().unwrap(), "https://auth.example.com");
assert_eq!(audience.as_ref().unwrap(), "api://my-agent");
}
_ => panic!("Expected JWT auth config"),
}
}
#[test]
#[cfg(feature = "auth")]
fn test_oauth2_auth_config() {
let toml = r#"
[agent]
name = "OAuth2 Agent"
[server.auth]
type = "oauth2"
client_id = "my-client-id"
client_secret = "my-client-secret"
authorization_url = "https://provider.com/auth"
token_url = "https://provider.com/token"
flow = "authorization_code"
scopes = ["read", "write"]
"#;
let config = AgentConfig::from_toml(toml).unwrap();
match &config.server.auth {
AuthConfig::OAuth2 {
client_id,
client_secret,
flow,
scopes,
..
} => {
assert_eq!(client_id, "my-client-id");
assert_eq!(client_secret, "my-client-secret");
assert_eq!(flow, "authorization_code");
assert_eq!(scopes.len(), 2);
assert_eq!(scopes[0], "read");
}
_ => panic!("Expected OAuth2 auth config"),
}
}
#[test]
fn test_validation_empty_name() {
let toml = r#"
[agent]
name = ""
"#;
let result = AgentConfig::from_toml(toml);
assert!(result.is_err());
}
#[test]
fn test_ap2_extension_config() {
let toml = r#"
[agent]
name = "Merchant Agent"
[features.extensions.ap2]
roles = ["merchant", "payment-processor"]
required = true
"#;
let config = AgentConfig::from_toml(toml).unwrap();
let ap2 = config.features.extensions.ap2.unwrap();
assert_eq!(ap2.roles, vec!["merchant", "payment-processor"]);
assert!(ap2.required);
}
#[test]
fn test_mcp_http_config() {
let toml = r#"
[agent]
name = "HTTP MCP Agent"
[server]
http_port = 0
[features.mcp_server]
enabled = true
stdio = false
[features.mcp_server.http]
enabled = true
host = "0.0.0.0"
port = 9000
path = "/rpc"
"#;
let config = AgentConfig::from_toml(toml).unwrap();
let http = &config.features.mcp_server.http;
assert!(http.enabled);
assert_eq!(http.host, "0.0.0.0");
assert_eq!(http.port, 9000);
assert_eq!(http.path, "/rpc");
assert!(http.allowed_hosts.is_none());
assert!(http.allowed_origins.is_none());
}
#[test]
fn test_mcp_http_security_knobs() {
let toml = r#"
[agent]
name = "Public MCP Agent"
[server]
http_port = 0
[features.mcp_server]
enabled = true
[features.mcp_server.http]
enabled = true
allowed_hosts = ["mcp.example.com", "mcp.example.com:8000"]
allowed_origins = ["https://app.example.com"]
"#;
let config = AgentConfig::from_toml(toml).unwrap();
let http = &config.features.mcp_server.http;
assert_eq!(
http.allowed_hosts.as_deref(),
Some(
[
"mcp.example.com".to_string(),
"mcp.example.com:8000".to_string()
]
.as_slice()
)
);
assert_eq!(
http.allowed_origins.as_deref(),
Some(["https://app.example.com".to_string()].as_slice())
);
}
#[test]
fn test_mcp_http_disable_host_validation() {
let toml = r#"
[agent]
name = "Open MCP Agent"
[server]
http_port = 0
[features.mcp_server]
enabled = true
[features.mcp_server.http]
enabled = true
allowed_hosts = []
"#;
let config = AgentConfig::from_toml(toml).unwrap();
assert_eq!(
config.features.mcp_server.http.allowed_hosts.as_deref(),
Some([].as_slice())
);
}
#[test]
fn test_mcp_http_config_defaults() {
let toml = r#"
[agent]
name = "Stdio MCP Agent"
[server]
http_port = 0
[features.mcp_server]
enabled = true
"#;
let config = AgentConfig::from_toml(toml).unwrap();
let mcp = &config.features.mcp_server;
assert!(mcp.stdio);
assert!(!mcp.http.enabled);
assert_eq!(mcp.http.host, "127.0.0.1");
assert_eq!(mcp.http.port, 8000);
assert_eq!(mcp.http.path, "/mcp");
}
#[test]
fn test_ap2_extension_config_optional() {
let toml = r#"
[agent]
name = "Plain Agent"
"#;
let config = AgentConfig::from_toml(toml).unwrap();
assert!(config.features.extensions.ap2.is_none());
}
}