turbomcp_server/
config.rs

1//! Server configuration management
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::time::Duration;
7
8/// Server configuration
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ServerConfig {
11    /// Server name
12    pub name: String,
13    /// Server version
14    pub version: String,
15    /// Server description
16    pub description: Option<String>,
17    /// Bind address
18    pub bind_address: String,
19    /// Bind port
20    pub port: u16,
21    /// Enable TLS
22    pub enable_tls: bool,
23    /// TLS configuration
24    pub tls: Option<TlsConfig>,
25    /// Timeout configuration
26    pub timeouts: TimeoutConfig,
27    /// Rate limiting configuration
28    pub rate_limiting: RateLimitingConfig,
29    /// Logging configuration
30    pub logging: LoggingConfig,
31    /// Additional configuration
32    pub additional: HashMap<String, serde_json::Value>,
33}
34
35/// TLS configuration
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct TlsConfig {
38    /// Certificate file path
39    pub cert_file: PathBuf,
40    /// Private key file path
41    pub key_file: PathBuf,
42}
43
44/// Timeout configuration
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct TimeoutConfig {
47    /// Request timeout
48    pub request_timeout: Duration,
49    /// Connection timeout
50    pub connection_timeout: Duration,
51    /// Keep-alive timeout
52    pub keep_alive_timeout: Duration,
53    /// Tool execution timeout (default for all tools)
54    pub tool_execution_timeout: Duration,
55    /// Per-tool timeout overrides (tool_name -> duration_seconds)
56    pub tool_timeouts: HashMap<String, u64>,
57}
58
59/// Rate limiting configuration
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct RateLimitingConfig {
62    /// Enable rate limiting
63    pub enabled: bool,
64    /// Requests per second
65    pub requests_per_second: u32,
66    /// Burst capacity
67    pub burst_capacity: u32,
68}
69
70/// Logging configuration
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct LoggingConfig {
73    /// Log level
74    pub level: String,
75    /// Enable structured logging
76    pub structured: bool,
77    /// Log file path
78    pub file: Option<PathBuf>,
79}
80
81impl Default for ServerConfig {
82    fn default() -> Self {
83        Self {
84            name: crate::SERVER_NAME.to_string(),
85            version: crate::SERVER_VERSION.to_string(),
86            description: Some("Next generation MCP server".to_string()),
87            bind_address: "127.0.0.1".to_string(),
88            port: 8080,
89            enable_tls: false,
90            tls: None,
91            timeouts: TimeoutConfig::default(),
92            rate_limiting: RateLimitingConfig::default(),
93            logging: LoggingConfig::default(),
94            additional: HashMap::new(),
95        }
96    }
97}
98
99/// Configuration error types
100#[derive(Debug, thiserror::Error)]
101pub enum ConfigError {
102    /// Config file not found
103    #[error("Configuration file not found: {0}")]
104    FileNotFound(PathBuf),
105
106    /// Unsupported file format
107    #[error("Unsupported configuration file format. Use .toml, .yaml, .yml, or .json")]
108    UnsupportedFormat,
109
110    /// Configuration parsing error
111    #[error("Failed to parse configuration: {0}")]
112    ParseError(#[from] config::ConfigError),
113
114    /// IO error
115    #[error("IO error: {0}")]
116    IoError(#[from] std::io::Error),
117}
118
119impl ServerConfig {
120    /// Load configuration from a file (TOML, YAML, or JSON)
121    ///
122    /// The file format is auto-detected from the file extension:
123    /// - `.toml` → TOML format
124    /// - `.yaml` or `.yml` → YAML format
125    /// - `.json` → JSON format
126    ///
127    /// Environment variables with the `TURBOMCP_` prefix will override file settings.
128    /// For example, `TURBOMCP_PORT=9000` will override the `port` setting.
129    ///
130    /// # Example
131    ///
132    /// ```rust,no_run
133    /// use turbomcp_server::ServerConfig;
134    ///
135    /// let config = ServerConfig::from_file("config.toml").expect("Failed to load config");
136    /// ```
137    ///
138    /// # Errors
139    ///
140    /// Returns an error if:
141    /// - The file doesn't exist
142    /// - The file format is unsupported
143    /// - The file contains invalid configuration
144    pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self, ConfigError> {
145        use config::{Config, File, FileFormat};
146
147        let path = path.as_ref();
148
149        // Check if file exists
150        if !path.exists() {
151            return Err(ConfigError::FileNotFound(path.to_path_buf()));
152        }
153
154        // Determine file format from extension
155        let format = match path.extension().and_then(|s| s.to_str()) {
156            Some("toml") => FileFormat::Toml,
157            Some("yaml") | Some("yml") => FileFormat::Yaml,
158            Some("json") => FileFormat::Json,
159            _ => return Err(ConfigError::UnsupportedFormat),
160        };
161
162        // Build configuration with file + environment variables
163        let config = Config::builder()
164            .add_source(File::new(
165                path.to_str()
166                    .ok_or_else(|| ConfigError::UnsupportedFormat)?,
167                format,
168            ))
169            // Environment variables override file settings (12-factor app pattern)
170            .add_source(
171                config::Environment::with_prefix("TURBOMCP")
172                    .separator("__") // Use __ for nested config (e.g., TURBOMCP_TIMEOUTS__REQUEST_TIMEOUT)
173                    .try_parsing(true),
174            )
175            .build()?;
176
177        Ok(config.try_deserialize()?)
178    }
179
180    /// Load configuration from a file with custom environment prefix
181    ///
182    /// # Example
183    ///
184    /// ```rust,no_run
185    /// use turbomcp_server::ServerConfig;
186    ///
187    /// // Use MYAPP_PORT instead of TURBOMCP_PORT
188    /// let config = ServerConfig::from_file_with_prefix("config.toml", "MYAPP")
189    ///     .expect("Failed to load config");
190    /// ```
191    pub fn from_file_with_prefix(
192        path: impl AsRef<std::path::Path>,
193        env_prefix: &str,
194    ) -> Result<Self, ConfigError> {
195        use config::{Config, File, FileFormat};
196
197        let path = path.as_ref();
198
199        if !path.exists() {
200            return Err(ConfigError::FileNotFound(path.to_path_buf()));
201        }
202
203        let format = match path.extension().and_then(|s| s.to_str()) {
204            Some("toml") => FileFormat::Toml,
205            Some("yaml") | Some("yml") => FileFormat::Yaml,
206            Some("json") => FileFormat::Json,
207            _ => return Err(ConfigError::UnsupportedFormat),
208        };
209
210        let config = Config::builder()
211            .add_source(File::new(
212                path.to_str()
213                    .ok_or_else(|| ConfigError::UnsupportedFormat)?,
214                format,
215            ))
216            .add_source(
217                config::Environment::with_prefix(env_prefix)
218                    .separator("__")
219                    .try_parsing(true),
220            )
221            .build()?;
222
223        Ok(config.try_deserialize()?)
224    }
225
226    /// Create a configuration builder
227    ///
228    /// Use this for programmatic configuration without files.
229    ///
230    /// # Example
231    ///
232    /// ```rust
233    /// use turbomcp_server::ServerConfig;
234    ///
235    /// let config = ServerConfig::builder()
236    ///     .name("my-server")
237    ///     .port(9000)
238    ///     .build();
239    /// ```
240    pub fn builder() -> ConfigurationBuilder {
241        ConfigurationBuilder::new()
242    }
243}
244
245impl Default for TimeoutConfig {
246    fn default() -> Self {
247        Self {
248            request_timeout: Duration::from_secs(30),
249            connection_timeout: Duration::from_secs(10),
250            keep_alive_timeout: Duration::from_secs(60),
251            tool_execution_timeout: Duration::from_secs(120), // 2 minutes default for tools
252            tool_timeouts: HashMap::new(),                    // No per-tool overrides by default
253        }
254    }
255}
256
257impl Default for RateLimitingConfig {
258    fn default() -> Self {
259        Self {
260            enabled: true,
261            requests_per_second: 100,
262            burst_capacity: 200,
263        }
264    }
265}
266
267impl Default for LoggingConfig {
268    fn default() -> Self {
269        Self {
270            level: "info".to_string(),
271            structured: true,
272            file: None,
273        }
274    }
275}
276
277/// Configuration builder
278#[derive(Debug)]
279pub struct ConfigurationBuilder {
280    /// Configuration being built
281    config: ServerConfig,
282}
283
284impl ConfigurationBuilder {
285    /// Create a new configuration builder
286    #[must_use]
287    pub fn new() -> Self {
288        Self {
289            config: ServerConfig::default(),
290        }
291    }
292
293    /// Set server name
294    pub fn name(mut self, name: impl Into<String>) -> Self {
295        self.config.name = name.into();
296        self
297    }
298
299    /// Set server version
300    pub fn version(mut self, version: impl Into<String>) -> Self {
301        self.config.version = version.into();
302        self
303    }
304
305    /// Set server description
306    pub fn description(mut self, description: impl Into<String>) -> Self {
307        self.config.description = Some(description.into());
308        self
309    }
310
311    /// Set bind address
312    pub fn bind_address(mut self, address: impl Into<String>) -> Self {
313        self.config.bind_address = address.into();
314        self
315    }
316
317    /// Set port
318    #[must_use]
319    pub const fn port(mut self, port: u16) -> Self {
320        self.config.port = port;
321        self
322    }
323
324    /// Enable TLS with configuration
325    #[must_use]
326    pub fn tls(mut self, cert_file: PathBuf, key_file: PathBuf) -> Self {
327        self.config.enable_tls = true;
328        self.config.tls = Some(TlsConfig {
329            cert_file,
330            key_file,
331        });
332        self
333    }
334
335    /// Set request timeout
336    #[must_use]
337    pub const fn request_timeout(mut self, timeout: Duration) -> Self {
338        self.config.timeouts.request_timeout = timeout;
339        self
340    }
341
342    /// Enable rate limiting
343    #[must_use]
344    pub const fn rate_limiting(mut self, requests_per_second: u32, burst_capacity: u32) -> Self {
345        self.config.rate_limiting.enabled = true;
346        self.config.rate_limiting.requests_per_second = requests_per_second;
347        self.config.rate_limiting.burst_capacity = burst_capacity;
348        self
349    }
350
351    /// Set log level
352    pub fn log_level(mut self, level: impl Into<String>) -> Self {
353        self.config.logging.level = level.into();
354        self
355    }
356
357    /// Build the configuration
358    #[must_use]
359    pub fn build(self) -> ServerConfig {
360        self.config
361    }
362}
363
364impl Default for ConfigurationBuilder {
365    fn default() -> Self {
366        Self::new()
367    }
368}
369
370/// Configuration alias for convenience
371pub type Configuration = ServerConfig;
372#[cfg(test)]
373mod inline_tests {
374    use super::*;
375
376    #[test]
377    fn test_default_config() {
378        let config = ServerConfig::default();
379        assert_eq!(config.name, crate::SERVER_NAME);
380        assert_eq!(config.version, crate::SERVER_VERSION);
381        assert_eq!(config.bind_address, "127.0.0.1");
382        assert_eq!(config.port, 8080);
383        assert!(!config.enable_tls);
384    }
385
386    #[test]
387    fn test_config_builder() {
388        let config = ConfigurationBuilder::new()
389            .name("test-server")
390            .port(9000)
391            .build();
392
393        assert_eq!(config.name, "test-server");
394        assert_eq!(config.port, 9000);
395    }
396
397    // Property-based tests
398    mod proptest_tests {
399        use super::*;
400        use proptest::prelude::*;
401
402        proptest! {
403            /// Test that config serialization roundtrips correctly for any valid port
404            #[test]
405            fn test_config_port_roundtrip(port in 1024u16..65535u16) {
406                let config = ConfigurationBuilder::new()
407                    .port(port)
408                    .build();
409
410                prop_assert_eq!(config.port, port);
411            }
412
413            /// Test that server name is preserved through builder
414            #[test]
415            fn test_config_name_preservation(name in "[a-zA-Z0-9_-]{1,50}") {
416                let config = ConfigurationBuilder::new()
417                    .name(&name)
418                    .build();
419
420                prop_assert_eq!(config.name, name);
421            }
422
423            /// Test rate limiting configuration validity
424            #[test]
425            fn test_rate_limiting_config(
426                rps in 1u32..10000u32,
427                burst in 1u32..1000u32
428            ) {
429                let config = RateLimitingConfig {
430                    enabled: true,
431                    requests_per_second: rps,
432                    burst_capacity: burst,
433                };
434
435                // Verify values are within bounds
436                prop_assert!(config.requests_per_second >= 1);
437                prop_assert!(config.burst_capacity >= 1);
438            }
439        }
440    }
441}
442
443/// WebSocket server configuration
444///
445/// Configuration for WebSocket transport when using `run_websocket_with_config()`.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447#[cfg(feature = "websocket")]
448pub struct WebSocketServerConfig {
449    /// Bind address (e.g., "127.0.0.1:8080")
450    pub bind_addr: String,
451    /// WebSocket endpoint path (default: "/ws")
452    pub endpoint_path: String,
453    /// Maximum concurrent request handlers per connection (default: 100)
454    pub max_concurrent_requests: usize,
455}
456
457#[cfg(feature = "websocket")]
458impl Default for WebSocketServerConfig {
459    fn default() -> Self {
460        Self {
461            bind_addr: "127.0.0.1:8080".to_string(),
462            endpoint_path: "/ws".to_string(),
463            max_concurrent_requests: 100,
464        }
465    }
466}
467
468// Additional comprehensive tests in separate file
469#[cfg(test)]
470mod tests;