Skip to main content

mcp_airlock/
config.rs

1//! # Configuration
2//!
3//! Every option is a CLI flag and an `MCP_AIRLOCK_*` environment variable
4//! (flags win). See `mcp-airlock --help`.
5
6use clap::{Parser, ValueEnum};
7
8/// The scheme of the `Authorization` header sent to the MCP server. The DPoP
9/// proof header is sent with either.
10#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum AuthScheme {
12    /// `Authorization: Bearer <token>`, as the MCP specification requires.
13    #[default]
14    Bearer,
15    /// `Authorization: DPoP <token>` (RFC 9449 ยง7.1), for servers that insist.
16    Dpop,
17}
18
19/// mcp-airlock's configuration.
20#[derive(Parser, Debug, Clone)]
21#[command(author, version, about, long_about = None)]
22pub struct Config {
23    /// Remote MCP endpoint (the Streamable HTTP URL of the server)
24    #[arg(
25        long,
26        env = "MCP_AIRLOCK_REMOTE_MCP_URL",
27        help_heading = "Server Configuration"
28    )]
29    pub remote_mcp_url: String,
30
31    /// Standalone SSE endpoint of legacy (2025-11-25 and earlier) servers [default: --remote-mcp-url]
32    #[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    /// Fallback MCP-Protocol-Version, used only when a message doesn't determine its version
40    #[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    /// Authorization header scheme; the DPoP proof header is sent either way
49    #[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    /// Authorization server metadata URL (skips discovery from the MCP server)
59    #[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    /// Authorization endpoint override
67    #[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    /// Token endpoint override
75    #[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    /// Pushed Authorization Request (PAR) endpoint override
83    #[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    /// Expected issuer of the authorization server the client ID is registered with
91    #[arg(
92        long,
93        env = "MCP_AIRLOCK_OIDC_ISSUER",
94        help_heading = "OIDC Configuration"
95    )]
96    pub oidc_issuer: Option<String>,
97
98    /// Request the offline_access scope (long-lived refresh tokens) when the provider offers it
99    #[arg(
100        long,
101        env = "MCP_AIRLOCK_OIDC_OFFLINE_ACCESS",
102        help_heading = "OIDC Configuration"
103    )]
104    pub oidc_offline_access: bool,
105
106    /// OIDC Client ID (a pre-registered ID, or an https URL of a Client ID Metadata Document)
107    #[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    /// Loopback redirect URL for the login callback (must be registered with the provider)
116    #[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    /// Name under which credentials are stored in the OS keychain
125    #[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    /// Seconds to wait for the browser login to complete before giving up
134    #[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    /// Directory containing success.html and failure.html for the auth callback
144    #[arg(long, env = "MCP_AIRLOCK_TEMPLATE_DIR", help_heading = "Local State")]
145    pub template_dir: Option<std::path::PathBuf>,
146
147    /// Log level (error, warn, info, debug, trace)
148    #[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    /// Directory for logs [default: per-user state directory, e.g. ~/.local/state/mcp-airlock/logs]
157    #[arg(long, env = "MCP_AIRLOCK_LOG_DIR", help_heading = "Logging")]
158    pub log_dir: Option<std::path::PathBuf>,
159
160    /// Allow plain-HTTP MCP and authorization server URLs on non-loopback hosts (insecure)
161    #[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    /// Parses the process arguments and environment, exiting on errors.
171    pub fn parse() -> Self {
172        Parser::parse()
173    }
174
175    /// The log directory: `--log-dir`, or a private per-user directory.
176    pub fn resolved_log_dir(&self) -> std::path::PathBuf {
177        self.log_dir.clone().unwrap_or_else(default_log_dir)
178    }
179}
180
181/// `$XDG_STATE_HOME/mcp-airlock/logs` on Linux, the local data directory on
182/// macOS and Windows, and a per-user temp directory as a last resort.
183pub 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", // Missing value
320        ];
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}