//! Configuration management
use std::{collections::HashMap, env, path::Path, time::Duration};
use figment::{
Figment,
providers::{Env, Format, Yaml},
};
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::mtls::MtlsConfig;
use crate::routing_profile::RoutingProfileConfig;
use crate::security::policy::ToolPolicyConfig;
use crate::{Error, Result};
// ── Key Server configuration types (declared here, used in key_server module) ──
/// Key Server configuration — OIDC identity to temporary scoped API keys.
///
/// Disabled by default. Enable with `key_server.enabled: true`.
///
/// # Example
///
/// ```yaml
/// key_server:
/// enabled: true
/// token_ttl_secs: 3600
/// oidc:
/// - issuer: "https://accounts.google.com"
/// audiences: ["my-gateway-client-id"]
/// allowed_domains: ["company.com"]
/// policies:
/// - match: { domain: "company.com" }
/// scopes:
/// backends: ["*"]
/// tools: ["*"]
/// rate_limit: 100
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct KeyServerConfig {
/// Enable the key server (default: `false`).
pub enabled: bool,
/// Issued token lifetime in seconds (default: 3600 = 1 hour).
#[serde(default = "default_key_server_token_ttl")]
pub token_ttl_secs: u64,
/// Maximum active tokens per identity before new issuance is rejected (default: 5).
#[serde(default = "default_max_tokens_per_identity")]
pub max_tokens_per_identity: u32,
/// Maximum age of an incoming OIDC token in seconds (replay protection, default: 300).
#[serde(default = "default_max_oidc_token_age")]
pub max_oidc_token_age_secs: u64,
/// How often to reap expired tokens from the in-memory store (seconds, default: 60).
#[serde(default = "default_cleanup_interval")]
pub cleanup_interval_secs: u64,
/// OIDC provider configurations.
#[serde(default)]
pub oidc: Vec<KeyServerProviderConfig>,
/// Access policy rules (first-match-wins).
#[serde(default)]
pub policies: Vec<KeyServerPolicyConfig>,
/// Admin bearer token for revocation endpoints (`env:VAR_NAME` supported).
/// If `None`, revocation endpoints return 503.
#[serde(default)]
pub admin_token: Option<String>,
}
fn default_key_server_token_ttl() -> u64 { 3600 }
fn default_max_tokens_per_identity() -> u32 { 5 }
fn default_max_oidc_token_age() -> u64 { 300 }
fn default_cleanup_interval() -> u64 { 60 }
impl Default for KeyServerConfig {
fn default() -> Self {
Self {
enabled: false,
token_ttl_secs: default_key_server_token_ttl(),
max_tokens_per_identity: default_max_tokens_per_identity(),
max_oidc_token_age_secs: default_max_oidc_token_age(),
cleanup_interval_secs: default_cleanup_interval(),
oidc: Vec::new(),
policies: Vec::new(),
admin_token: None,
}
}
}
impl KeyServerConfig {
/// Resolve the admin token, expanding `env:VAR_NAME` syntax.
#[must_use]
pub fn resolve_admin_token(&self) -> Option<String> {
self.admin_token.as_ref().map(|t| {
if let Some(var) = t.strip_prefix("env:") {
env::var(var).unwrap_or_else(|_| t.clone())
} else {
t.clone()
}
})
}
}
/// Configuration for a single OIDC identity provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyServerProviderConfig {
/// The OIDC issuer URL (must match the `iss` claim in tokens).
pub issuer: String,
/// Override JWKS URI. Defaults to `{issuer}/.well-known/jwks.json`.
#[serde(default)]
pub jwks_uri: Option<String>,
/// Expected audience values (`aud` claim). Empty = any audience accepted.
#[serde(default)]
pub audiences: Vec<String>,
/// Restrict to these email domains. Empty = any domain accepted.
#[serde(default)]
pub allowed_domains: Vec<String>,
}
/// An access policy rule: match criteria + granted scopes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyServerPolicyConfig {
/// Criteria that must be satisfied for this rule to match.
#[serde(rename = "match")]
pub match_criteria: PolicyMatchConfig,
/// Scopes granted when this rule matches.
pub scopes: PolicyScopesConfig,
}
/// Match criteria for a policy rule. All non-`None` fields must match.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PolicyMatchConfig {
/// Email domain suffix (e.g., `"company.com"`).
#[serde(skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
/// Exact OIDC issuer URL.
#[serde(skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
/// Exact email address.
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
/// Required group membership.
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
}
/// Scopes granted by a policy rule.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PolicyScopesConfig {
/// Allowed backends. `["*"]` or empty = all.
#[serde(default)]
pub backends: Vec<String>,
/// Allowed tools. `["*"]` or empty = all.
#[serde(default)]
pub tools: Vec<String>,
/// Rate limit in requests/minute (0 = unlimited).
#[serde(default)]
pub rate_limit: u32,
}
/// Runtime OIDC verification parameters (derived from `KeyServerConfig`).
#[derive(Debug, Clone)]
pub struct KeyServerOidcConfig {
/// Maximum age of an incoming OIDC token (seconds).
pub max_token_age_secs: u64,
}
/// Main configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct Config {
/// Environment files to load before processing config.
/// Paths support ~ expansion. Loaded in order, later files override earlier.
/// Variables are set into the process environment for `{env.VAR}` resolution.
#[serde(default)]
pub env_files: Vec<String>,
/// Server configuration
pub server: ServerConfig,
/// Authentication configuration
pub auth: AuthConfig,
/// Meta-MCP configuration
pub meta_mcp: MetaMcpConfig,
/// Streaming configuration (for real-time notifications)
pub streaming: StreamingConfig,
/// Failsafe configuration
pub failsafe: FailsafeConfig,
/// Backend configurations
pub backends: HashMap<String, BackendConfig>,
/// Capability configuration (direct REST API integration)
pub capabilities: CapabilityConfig,
/// Cache configuration
pub cache: CacheConfig,
/// Playbook configuration
pub playbooks: PlaybooksConfig,
/// Security policy configuration
pub security: SecurityConfig,
/// Webhook receiver configuration
pub webhooks: WebhookConfig,
/// Routing profiles for session-scoped tool access control
#[serde(default)]
pub routing_profiles: HashMap<String, RoutingProfileConfig>,
/// Name of the routing profile applied to new sessions.
/// Defaults to `"default"` (allow-all when not explicitly configured).
#[serde(default = "default_routing_profile")]
pub default_routing_profile: String,
/// Code Mode configuration (search+execute pattern)
#[serde(default)]
pub code_mode: CodeModeConfig,
/// Mutual TLS configuration for transport-layer certificate authentication
#[serde(default)]
pub mtls: MtlsConfig,
/// Key Server — OIDC identity to temporary scoped API keys
#[serde(default)]
pub key_server: KeyServerConfig,
}
fn default_routing_profile() -> String {
"default".to_string()
}
/// Code Mode configuration — the search+execute pattern for minimal context usage.
///
/// When enabled, `tools/list` returns only two meta-tools (`gateway_search` and
/// `gateway_execute`) instead of the full meta-tool set. This reduces context
/// consumption to near-zero while preserving full access to every backend tool
/// through the search-then-execute workflow.
///
/// # Example
///
/// ```yaml
/// code_mode:
/// enabled: true
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct CodeModeConfig {
/// Enable Code Mode.
///
/// When `true`, `tools/list` returns only `gateway_search` and
/// `gateway_execute`. When `false` (default), the full meta-tool list
/// is returned as before.
pub enabled: bool,
}
/// Cache configuration for response caching
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CacheConfig {
/// Enable response caching
pub enabled: bool,
/// Default TTL for cached responses
#[serde(with = "humantime_serde")]
pub default_ttl: Duration,
/// Maximum number of entries before eviction
pub max_entries: usize,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: true,
default_ttl: Duration::from_secs(60),
max_entries: 10_000,
}
}
}
/// Playbook configuration for multi-step tool chains
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PlaybooksConfig {
/// Enable playbook engine
pub enabled: bool,
/// Directories to load playbook definitions from
pub directories: Vec<String>,
}
impl Default for PlaybooksConfig {
fn default() -> Self {
Self {
enabled: false,
directories: vec!["playbooks".to_string()],
}
}
}
/// Webhook receiver configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct WebhookConfig {
/// Enable the webhook receiver
pub enabled: bool,
/// Base path prefix for all webhook endpoints (e.g., "/webhooks")
pub base_path: String,
/// Require HMAC signature on all webhooks (can be overridden per definition)
pub require_signature: bool,
/// Rate limit for webhook endpoints (requests per minute, 0 = unlimited)
pub rate_limit: u32,
}
impl Default for WebhookConfig {
fn default() -> Self {
Self {
enabled: true,
base_path: "/webhooks".to_string(),
require_signature: true,
rate_limit: 100,
}
}
}
/// Security configuration for the gateway
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SecurityConfig {
/// Enable input sanitization (null byte rejection, control char stripping, NFC)
pub sanitize_input: bool,
/// Enable SSRF protection for outbound URLs
pub ssrf_protection: bool,
/// Tool allow/deny policy
pub tool_policy: ToolPolicyConfig,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
sanitize_input: true,
ssrf_protection: true,
tool_policy: ToolPolicyConfig::default(),
}
}
}
/// Capability configuration for direct REST API integration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CapabilityConfig {
/// Enable capability system
pub enabled: bool,
/// Backend name for capabilities (shown in `gateway_list_servers`)
pub name: String,
/// Directories to load capability definitions from
pub directories: Vec<String>,
}
impl Default for CapabilityConfig {
fn default() -> Self {
Self {
enabled: true,
name: "fulcrum".to_string(),
directories: vec!["capabilities".to_string()],
}
}
}
/// Authentication configuration for gateway access
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AuthConfig {
/// Enable authentication (default: false for backwards compatibility)
pub enabled: bool,
/// Bearer token for simple authentication
/// Supports: literal value, `env:VAR_NAME`, or `auto` (generates random token)
#[serde(default)]
pub bearer_token: Option<String>,
/// API keys for multi-client access with optional restrictions
#[serde(default)]
pub api_keys: Vec<ApiKeyConfig>,
/// Paths that bypass authentication (default: `["/health"]`)
#[serde(default = "default_public_paths")]
pub public_paths: Vec<String>,
}
fn default_public_paths() -> Vec<String> {
vec!["/health".to_string()]
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
enabled: false,
bearer_token: None,
api_keys: Vec::new(),
public_paths: default_public_paths(),
}
}
}
impl AuthConfig {
/// Resolve the bearer token (expand env vars, generate if `auto`)
#[must_use]
pub fn resolve_bearer_token(&self) -> Option<String> {
self.bearer_token.as_ref().map(|token| {
if token == "auto" {
// Generate a random token
use rand::Rng;
let random_bytes: [u8; 32] = rand::rng().random();
format!(
"mcp_{}",
base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
random_bytes
)
)
} else if let Some(var_name) = token.strip_prefix("env:") {
env::var(var_name).unwrap_or_else(|_| token.clone())
} else {
token.clone()
}
})
}
}
/// API key configuration for multi-client access
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKeyConfig {
/// The API key value (supports `env:VAR_NAME`)
pub key: String,
/// Human-readable name for this client
#[serde(default)]
pub name: String,
/// Rate limit (requests per minute, 0 = unlimited)
#[serde(default)]
pub rate_limit: u32,
/// Allowed backends (empty = all backends)
#[serde(default)]
pub backends: Vec<String>,
/// Allowed tools (if Some, ONLY these tools are accessible).
/// Supports glob patterns (e.g., `"search_*"` matches `search_web`, `search_local`).
/// If set, this acts as an allowlist (deny all except listed).
#[serde(default)]
pub allowed_tools: Option<Vec<String>>,
/// Denied tools (if Some, these tools are blocked on top of global policy).
/// Supports glob patterns (e.g., `"filesystem_*"` matches `filesystem_read`, `filesystem_write`).
/// If set, this acts as a blocklist (allow all except listed + global denies).
#[serde(default)]
pub denied_tools: Option<Vec<String>>,
}
impl ApiKeyConfig {
/// Resolve the API key (expand env vars)
#[must_use]
pub fn resolve_key(&self) -> String {
if let Some(var_name) = self.key.strip_prefix("env:") {
env::var(var_name).unwrap_or_else(|_| self.key.clone())
} else {
self.key.clone()
}
}
/// Check if this key has access to a backend
#[must_use]
pub fn can_access_backend(&self, backend: &str) -> bool {
self.backends.is_empty() || self.backends.iter().any(|b| b == "*" || b == backend)
}
}
impl Config {
/// Load configuration from file and environment
///
/// # Errors
///
/// Returns an error if the config file does not exist or cannot be parsed.
pub fn load(path: Option<&Path>) -> Result<Self> {
let mut figment = Figment::new();
// Load from file if provided
if let Some(p) = path {
if !p.exists() {
return Err(Error::Config(format!(
"Config file not found: {}",
p.display()
)));
}
figment = figment.merge(Yaml::file(p));
}
// Merge environment variables (MCP_GATEWAY_ prefix)
figment = figment.merge(Env::prefixed("MCP_GATEWAY_").split("__"));
let mut config: Self = figment
.extract()
.map_err(|e| Error::Config(e.to_string()))?;
// Load env files into process environment (before env var expansion)
config.load_env_files();
// Expand ${VAR} in backend headers
config.expand_env_vars();
Ok(config)
}
/// Load environment files into the process environment.
/// Supports ~ expansion. Files that don't exist are silently skipped.
fn load_env_files(&self) {
for path_str in &self.env_files {
let expanded = if path_str.starts_with('~') {
if let Some(home) = dirs::home_dir() {
path_str.replacen('~', &home.display().to_string(), 1)
} else {
path_str.clone()
}
} else {
path_str.clone()
};
let path = Path::new(&expanded);
if path.exists() {
match dotenvy::from_path(path) {
Ok(()) => {
tracing::info!("Loaded env file: {expanded}");
}
Err(e) => {
tracing::warn!("Failed to load env file {expanded}: {e}");
}
}
} else {
tracing::debug!("Env file not found (skipped): {expanded}");
}
}
}
/// Expand ${VAR} and ${VAR:-default} patterns in config values
fn expand_env_vars(&mut self) {
// Pattern: ${VAR} or ${VAR:-default}
let re = Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)(?::-([^}]*))?\}").unwrap();
// Expand in backend headers
for backend in self.backends.values_mut() {
for value in backend.headers.values_mut() {
*value = Self::expand_string(&re, value);
}
// Also expand in backend env maps (stdio subprocess environment)
for value in backend.env.values_mut() {
*value = Self::expand_string(&re, value);
}
}
// Expand in capability directories
for dir in &mut self.capabilities.directories {
*dir = Self::expand_string(&re, dir);
}
}
/// Expand environment variables in a string
fn expand_string(re: &Regex, value: &str) -> String {
re.replace_all(value, |caps: ®ex::Captures| {
let var_name = &caps[1];
let default = caps.get(2).map_or("", |m| m.as_str());
env::var(var_name).unwrap_or_else(|_| default.to_string())
})
.into_owned()
}
/// Get enabled backends only
pub fn enabled_backends(&self) -> impl Iterator<Item = (&String, &BackendConfig)> {
self.backends.iter().filter(|(_, b)| b.enabled)
}
}
/// Server configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
/// Host to bind to
pub host: String,
/// Port to listen on
pub port: u16,
/// Request timeout
#[serde(with = "humantime_serde")]
pub request_timeout: Duration,
/// Graceful shutdown timeout
#[serde(with = "humantime_serde")]
pub shutdown_timeout: Duration,
/// Maximum request body size (bytes)
pub max_body_size: usize,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 39400,
request_timeout: Duration::from_secs(30),
shutdown_timeout: Duration::from_secs(30),
max_body_size: 10 * 1024 * 1024, // 10MB
}
}
}
/// Meta-MCP configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MetaMcpConfig {
/// Enable Meta-MCP mode
pub enabled: bool,
/// Cache tool lists
pub cache_tools: bool,
/// Tool cache TTL
#[serde(with = "humantime_serde")]
pub cache_ttl: Duration,
/// Backends to warm-start on gateway startup (pre-connect and cache tools)
#[serde(default)]
pub warm_start: Vec<String>,
}
impl Default for MetaMcpConfig {
fn default() -> Self {
Self {
enabled: true,
cache_tools: true,
cache_ttl: Duration::from_secs(300),
warm_start: Vec::new(),
}
}
}
/// Streaming configuration (for real-time notifications)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct StreamingConfig {
/// Enable streaming (GET /mcp for notifications)
pub enabled: bool,
/// Notification buffer size per client
pub buffer_size: usize,
/// Keep-alive interval for SSE streams
#[serde(with = "humantime_serde")]
pub keep_alive_interval: Duration,
/// Backends to auto-subscribe for notifications
#[serde(default)]
pub auto_subscribe: Vec<String>,
}
impl Default for StreamingConfig {
fn default() -> Self {
Self {
enabled: true,
buffer_size: 100,
keep_alive_interval: Duration::from_secs(15),
auto_subscribe: Vec::new(),
}
}
}
/// Failsafe configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct FailsafeConfig {
/// Circuit breaker configuration
pub circuit_breaker: CircuitBreakerConfig,
/// Retry configuration
pub retry: RetryConfig,
/// Rate limiting configuration
pub rate_limit: RateLimitConfig,
/// Health check configuration
pub health_check: HealthCheckConfig,
}
/// Circuit breaker configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CircuitBreakerConfig {
/// Enable circuit breaker
pub enabled: bool,
/// Failure threshold before opening
pub failure_threshold: u32,
/// Success threshold to close
pub success_threshold: u32,
/// Time to wait before half-open
#[serde(with = "humantime_serde")]
pub reset_timeout: Duration,
}
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
enabled: true,
failure_threshold: 5,
success_threshold: 3,
reset_timeout: Duration::from_secs(30),
}
}
}
/// Retry configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RetryConfig {
/// Enable retries
pub enabled: bool,
/// Maximum retry attempts
pub max_attempts: u32,
/// Initial backoff duration
#[serde(with = "humantime_serde")]
pub initial_backoff: Duration,
/// Maximum backoff duration
#[serde(with = "humantime_serde")]
pub max_backoff: Duration,
/// Backoff multiplier
pub multiplier: f64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
enabled: true,
max_attempts: 3,
initial_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(10),
multiplier: 2.0,
}
}
}
/// Rate limiting configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RateLimitConfig {
/// Enable rate limiting
pub enabled: bool,
/// Requests per second per backend
pub requests_per_second: u32,
/// Burst size
pub burst_size: u32,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: true,
requests_per_second: 100,
burst_size: 50,
}
}
}
/// Health check configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HealthCheckConfig {
/// Enable health checks
pub enabled: bool,
/// Health check interval
#[serde(with = "humantime_serde")]
pub interval: Duration,
/// Health check timeout
#[serde(with = "humantime_serde")]
pub timeout: Duration,
}
impl Default for HealthCheckConfig {
fn default() -> Self {
Self {
enabled: true,
interval: Duration::from_secs(30),
timeout: Duration::from_secs(5),
}
}
}
/// Backend configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct BackendConfig {
/// Human-readable description
pub description: String,
/// Whether backend is enabled
pub enabled: bool,
/// Transport type
#[serde(flatten)]
pub transport: TransportConfig,
/// Idle timeout before hibernation
#[serde(with = "humantime_serde")]
pub idle_timeout: Duration,
/// Request timeout for this backend
#[serde(with = "humantime_serde")]
pub timeout: Duration,
/// Environment variables (for stdio)
pub env: HashMap<String, String>,
/// HTTP headers (for http/sse)
pub headers: HashMap<String, String>,
/// OAuth configuration (optional)
#[serde(default)]
pub oauth: Option<OAuthConfig>,
}
/// OAuth configuration for a backend
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthConfig {
/// Enable OAuth for this backend
#[serde(default = "default_true")]
pub enabled: bool,
/// OAuth scopes to request (if empty, uses server's supported scopes)
#[serde(default)]
pub scopes: Vec<String>,
/// Client ID (optional - uses dynamic registration or generates one if not set)
#[serde(default)]
pub client_id: Option<String>,
/// How many seconds before expiry to proactively refresh the token.
///
/// The background refresh task triggers when
/// `time_until_expiry < max(token_lifetime * 0.10, token_refresh_buffer_secs)`.
/// Defaults to 300 seconds (5 minutes).
#[serde(default = "default_token_refresh_buffer")]
pub token_refresh_buffer_secs: u64,
}
fn default_token_refresh_buffer() -> u64 {
300
}
fn default_true() -> bool {
true
}
impl Default for BackendConfig {
fn default() -> Self {
Self {
description: String::new(),
enabled: true,
transport: TransportConfig::default(),
idle_timeout: Duration::from_secs(300),
timeout: Duration::from_secs(30),
env: HashMap::new(),
headers: HashMap::new(),
oauth: None,
}
}
}
/// Transport configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TransportConfig {
/// Stdio transport (subprocess)
Stdio {
/// Command to execute
command: String,
/// Working directory
#[serde(default)]
cwd: Option<String>,
},
/// HTTP transport
Http {
/// HTTP URL
http_url: String,
/// Use Streamable HTTP (direct POST, no SSE handshake)
/// Default is false (use SSE handshake)
#[serde(default)]
streamable_http: bool,
/// Override protocol version (for servers that only support older versions)
#[serde(default)]
protocol_version: Option<String>,
},
}
impl Default for TransportConfig {
fn default() -> Self {
Self::Http {
http_url: String::new(),
streamable_http: false,
protocol_version: None,
}
}
}
impl TransportConfig {
/// Get transport type name
#[must_use]
pub fn transport_type(&self) -> &'static str {
match self {
Self::Stdio { .. } => "stdio",
Self::Http {
http_url,
streamable_http: false,
..
} if http_url.ends_with("/sse") => "sse",
Self::Http {
streamable_http: true,
..
} => "streamable-http",
Self::Http { .. } => "http",
}
}
}
/// Custom humantime serde module for Duration
pub mod humantime_serde {
use std::time::Duration;
use serde::{self, Deserialize, Deserializer, Serializer};
/// Serialize Duration to human-readable string (e.g., "30s")
///
/// # Errors
///
/// Returns a serialization error if the serializer fails.
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&format!("{}s", duration.as_secs()))
}
/// Deserialize human-readable duration string (e.g., "30s", "5m", "100ms")
///
/// # Errors
///
/// Returns a deserialization error if the string cannot be parsed as a duration.
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
// Parse "30s", "5m", etc.
if let Some(secs) = s.strip_suffix('s') {
secs.parse::<u64>()
.map(Duration::from_secs)
.map_err(serde::de::Error::custom)
} else if let Some(mins) = s.strip_suffix('m') {
mins.parse::<u64>()
.map(|m| Duration::from_secs(m * 60))
.map_err(serde::de::Error::custom)
} else if let Some(ms) = s.strip_suffix("ms") {
ms.parse::<u64>()
.map(Duration::from_millis)
.map_err(serde::de::Error::custom)
} else {
// Assume seconds
s.parse::<u64>()
.map(Duration::from_secs)
.map_err(serde::de::Error::custom)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_load_env_files_sets_env_vars() {
let dir = tempfile::tempdir().unwrap();
let env_path = dir.path().join("test.env");
let mut f = std::fs::File::create(&env_path).unwrap();
writeln!(f, "MCP_GW_TEST_KEY_A=hello_from_env_file").unwrap();
writeln!(f, "MCP_GW_TEST_KEY_B=42").unwrap();
drop(f);
let config = Config {
env_files: vec![env_path.to_string_lossy().to_string()],
..Default::default()
};
config.load_env_files();
assert_eq!(env::var("MCP_GW_TEST_KEY_A").unwrap(), "hello_from_env_file");
assert_eq!(env::var("MCP_GW_TEST_KEY_B").unwrap(), "42");
// Note: env::remove_var is unsafe in edition 2024 and lib forbids unsafe.
// Test keys use unique MCP_GW_TEST_ prefix so won't conflict.
}
#[test]
fn test_load_env_files_skips_missing() {
let config = Config {
env_files: vec!["/nonexistent/path/.env".to_string()],
..Default::default()
};
// Should not panic
config.load_env_files();
}
#[test]
fn test_load_env_files_empty() {
let config = Config::default();
assert!(config.env_files.is_empty());
config.load_env_files(); // No-op, should not panic
}
#[test]
fn test_env_files_deserialized_from_yaml() {
let yaml = r#"
env_files:
- ~/.claude/secrets.env
- /tmp/extra.env
server:
host: "127.0.0.1"
port: 39401
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.env_files.len(), 2);
assert_eq!(config.env_files[0], "~/.claude/secrets.env");
}
}