1use clap::{Parser, ValueEnum};
7
8#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum AuthScheme {
12 #[default]
14 Bearer,
15 Dpop,
17}
18
19#[derive(Parser, Debug, Clone)]
21#[command(author, version, about, long_about = None)]
22pub struct Config {
23 #[arg(
25 long,
26 env = "MCP_AIRLOCK_REMOTE_MCP_URL",
27 help_heading = "Server Configuration"
28 )]
29 pub remote_mcp_url: String,
30
31 #[arg(
33 long,
34 env = "MCP_AIRLOCK_REMOTE_SSE_URL",
35 help_heading = "Server Configuration"
36 )]
37 pub remote_sse_url: Option<String>,
38
39 #[arg(
41 long,
42 env = "MCP_AIRLOCK_MCP_PROTOCOL_VERSION",
43 default_value = "2025-11-25",
44 help_heading = "Server Configuration"
45 )]
46 pub mcp_protocol_version: String,
47
48 #[arg(
50 long,
51 env = "MCP_AIRLOCK_AUTH_SCHEME",
52 value_enum,
53 default_value_t = AuthScheme::Bearer,
54 help_heading = "Server Configuration"
55 )]
56 pub auth_scheme: AuthScheme,
57
58 #[arg(
60 long,
61 env = "MCP_AIRLOCK_OIDC_DISCOVERY_URL",
62 help_heading = "OIDC Configuration"
63 )]
64 pub oidc_discovery_url: Option<String>,
65
66 #[arg(
68 long,
69 env = "MCP_AIRLOCK_KC_AUTH_URL",
70 help_heading = "OIDC Configuration"
71 )]
72 pub kc_auth_url: Option<String>,
73
74 #[arg(
76 long,
77 env = "MCP_AIRLOCK_KC_TOKEN_URL",
78 help_heading = "OIDC Configuration"
79 )]
80 pub kc_token_url: Option<String>,
81
82 #[arg(
84 long,
85 env = "MCP_AIRLOCK_KC_PAR_URL",
86 help_heading = "OIDC Configuration"
87 )]
88 pub kc_par_url: Option<String>,
89
90 #[arg(
92 long,
93 env = "MCP_AIRLOCK_OIDC_ISSUER",
94 help_heading = "OIDC Configuration"
95 )]
96 pub oidc_issuer: Option<String>,
97
98 #[arg(
100 long,
101 env = "MCP_AIRLOCK_OIDC_OFFLINE_ACCESS",
102 help_heading = "OIDC Configuration"
103 )]
104 pub oidc_offline_access: bool,
105
106 #[arg(
108 long,
109 env = "MCP_AIRLOCK_OIDC_CLIENT_ID",
110 default_value = "mcp-airlock",
111 help_heading = "OIDC Configuration"
112 )]
113 pub oidc_client_id: String,
114
115 #[arg(
117 long,
118 env = "MCP_AIRLOCK_OIDC_REDIRECT_URL",
119 default_value = "http://127.0.0.1:8082/callback",
120 help_heading = "Local State"
121 )]
122 pub oidc_redirect_url: String,
123
124 #[arg(
126 long,
127 env = "MCP_AIRLOCK_USER_ID",
128 default_value = "default_user",
129 help_heading = "Local State"
130 )]
131 pub user_id: String,
132
133 #[arg(
135 long,
136 env = "MCP_AIRLOCK_AUTH_TIMEOUT_SECS",
137 default_value_t = 300,
138 value_parser = clap::value_parser!(u64).range(1..),
139 help_heading = "Local State"
140 )]
141 pub auth_timeout_secs: u64,
142
143 #[arg(long, env = "MCP_AIRLOCK_TEMPLATE_DIR", help_heading = "Local State")]
145 pub template_dir: Option<std::path::PathBuf>,
146
147 #[arg(
149 long,
150 env = "MCP_AIRLOCK_LOG_LEVEL",
151 default_value = "info",
152 help_heading = "Logging"
153 )]
154 pub log_level: String,
155
156 #[arg(long, env = "MCP_AIRLOCK_LOG_DIR", help_heading = "Logging")]
158 pub log_dir: Option<std::path::PathBuf>,
159
160 #[arg(
162 long,
163 env = "MCP_AIRLOCK_ALLOW_INSECURE_HTTP",
164 help_heading = "Server Configuration"
165 )]
166 pub allow_insecure_http: bool,
167}
168
169impl Config {
170 pub fn parse() -> Self {
172 Parser::parse()
173 }
174
175 pub fn resolved_log_dir(&self) -> std::path::PathBuf {
177 self.log_dir.clone().unwrap_or_else(default_log_dir)
178 }
179}
180
181pub fn default_log_dir() -> std::path::PathBuf {
184 dirs::state_dir()
185 .or_else(dirs::data_local_dir)
186 .map(|d| d.join("mcp-airlock").join("logs"))
187 .unwrap_or_else(|| std::env::temp_dir().join("mcp-airlock"))
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn test_config_parsing_minimal() {
196 let args = vec![
197 "mcp-airlock",
198 "--remote-mcp-url",
199 "http://mcp/rpc",
200 "--remote-sse-url",
201 "http://mcp/sse",
202 "--oidc-discovery-url",
203 "http://kc/discovery",
204 ];
205 let config = Config::try_parse_from(args).unwrap();
206 assert_eq!(config.remote_mcp_url, "http://mcp/rpc");
207 assert_eq!(
208 config.oidc_discovery_url,
209 Some("http://kc/discovery".to_string())
210 );
211 assert_eq!(config.oidc_client_id, "mcp-airlock");
212 assert_eq!(config.user_id, "default_user");
213 assert_eq!(config.mcp_protocol_version, "2025-11-25");
214 assert_eq!(config.auth_timeout_secs, 300);
215 }
216
217 #[test]
218 fn test_config_auth_timeout() {
219 let base = [
220 "mcp-airlock",
221 "--remote-mcp-url",
222 "http://mcp/rpc",
223 "--remote-sse-url",
224 "http://mcp/sse",
225 ];
226 let config =
227 Config::try_parse_from(base.iter().chain(&["--auth-timeout-secs", "42"])).unwrap();
228 assert_eq!(config.auth_timeout_secs, 42);
229
230 let zero = Config::try_parse_from(base.iter().chain(&["--auth-timeout-secs", "0"]));
231 assert!(zero.is_err());
232 }
233
234 #[test]
235 fn test_config_parsing_full() {
236 let args = vec![
237 "mcp-airlock",
238 "--remote-mcp-url",
239 "http://mcp/rpc",
240 "--remote-sse-url",
241 "http://mcp/sse",
242 "--kc-auth-url",
243 "http://kc/auth",
244 "--kc-token-url",
245 "http://kc/token",
246 "--kc-par-url",
247 "http://kc/par",
248 "--oidc-client-id",
249 "custom-client",
250 "--user-id",
251 "custom-user",
252 "--oidc-redirect-url",
253 "http://localhost:9999/cb",
254 ];
255 let config = Config::try_parse_from(args).unwrap();
256 assert_eq!(config.oidc_client_id, "custom-client");
257 assert_eq!(config.user_id, "custom-user");
258 assert_eq!(config.oidc_redirect_url, "http://localhost:9999/cb");
259 assert_eq!(config.kc_auth_url, Some("http://kc/auth".to_string()));
260 }
261
262 #[test]
263 fn test_config_missing_required() {
264 let args = vec!["mcp-airlock"];
265 let result = Config::try_parse_from(args);
266 assert!(result.is_err());
267 assert_eq!(
268 result.unwrap_err().kind(),
269 clap::error::ErrorKind::MissingRequiredArgument
270 );
271 }
272
273 #[test]
274 fn test_config_invalid_enum_value() {
275 let args = vec![
276 "mcp-airlock",
277 "--remote-mcp-url",
278 "http://mcp/rpc",
279 "--remote-sse-url",
280 "http://mcp/sse",
281 "--auth-scheme",
282 "invalid",
283 ];
284 let result = Config::try_parse_from(args);
285 assert!(result.is_err());
286 assert_eq!(
287 result.unwrap_err().kind(),
288 clap::error::ErrorKind::InvalidValue
289 );
290 }
291
292 #[test]
293 fn test_config_unknown_argument() {
294 let args = vec![
295 "mcp-airlock",
296 "--remote-mcp-url",
297 "http://mcp/rpc",
298 "--remote-sse-url",
299 "http://mcp/sse",
300 "--unknown-arg",
301 "value",
302 ];
303 let result = Config::try_parse_from(args);
304 assert!(result.is_err());
305 assert_eq!(
306 result.unwrap_err().kind(),
307 clap::error::ErrorKind::UnknownArgument
308 );
309 }
310
311 #[test]
312 fn test_config_missing_value() {
313 let args = vec![
314 "mcp-airlock",
315 "--remote-mcp-url",
316 "http://mcp/rpc",
317 "--remote-sse-url",
318 "http://mcp/sse",
319 "--oidc-client-id", ];
321 let result = Config::try_parse_from(args);
322 assert!(result.is_err());
323 assert_eq!(
324 result.unwrap_err().kind(),
325 clap::error::ErrorKind::InvalidValue
326 );
327 }
328}