Skip to main content

deribit_mcp/
config.rs

1//! Configuration surface — CLI + env + `.env` loader.
2//!
3//! Priority order (first wins):
4//! 1. CLI flags
5//! 2. Process environment
6//! 3. `.env` file via `dotenvy`
7//! 4. Built-in defaults
8
9use clap::Parser;
10use std::net::SocketAddr;
11use std::path::PathBuf;
12
13/// Resolved configuration for `deribit-mcp`.
14#[derive(Debug, Clone)]
15pub struct Config {
16    /// Deribit API endpoint (testnet by default).
17    pub endpoint: String,
18    /// Client ID for OAuth flow.
19    pub client_id: Option<String>,
20    /// Client secret for OAuth flow (env/`.env` only).
21    pub client_secret: Option<String>,
22    /// Enable trading tools (off by default).
23    pub allow_trading: bool,
24    /// Max order notional in USD (unlimited by default).
25    pub max_order_usd: Option<u64>,
26    /// MCP transport: `stdio` or `http` (stdio default).
27    pub transport: Transport,
28    /// HTTP listen address (only used if transport is HTTP).
29    pub http_listen: SocketAddr,
30    /// HTTP bearer token for auth (optional, env/`.env` only).
31    pub http_bearer_token: Option<String>,
32    /// Log format: `text` or `json`.
33    pub log_format: LogFormat,
34    /// Upstream transport selection for `Trading` tool dispatch
35    /// (`http` default, `fix` opt-in via v0.6).
36    pub order_transport: OrderTransport,
37}
38
39/// MCP transport selection.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Transport {
42    /// Standard input/output (default).
43    Stdio,
44    /// HTTP/SSE.
45    Http,
46}
47
48/// Log output format.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum LogFormat {
51    /// Human-readable text (default for stdio).
52    Text,
53    /// JSON structured logs (default for http).
54    Json,
55}
56
57/// Upstream transport for `Trading` tool dispatch.
58///
59/// `Http` is the default and matches the v0.1..v0.5 behaviour:
60/// every `place_order` / `edit_order` / `cancel_order` /
61/// `cancel_all_*` call hits the Deribit REST API via the
62/// `deribit-http` client. `Fix` opts the trading family into the
63/// lazy FIX-session path landed in v0.6-02 / v0.6-03.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum OrderTransport {
66    /// REST over HTTP (default; v0.1..v0.5 behaviour).
67    Http,
68    /// FIX 4.4 over TCP via `deribit-fix`. Requires `--allow-trading`.
69    Fix,
70}
71
72/// Map a `DERIBIT_NETWORK=testnet|mainnet` value to the full
73/// endpoint URL. Returns `None` for unrecognised values so the
74/// caller falls through to the next resolution layer rather than
75/// silently picking the default.
76fn network_to_endpoint(value: &str) -> Option<String> {
77    match value.trim().to_ascii_lowercase().as_str() {
78        "testnet" | "test" => Some("https://test.deribit.com".to_string()),
79        "mainnet" | "main" | "production" | "prod" => Some("https://www.deribit.com".to_string()),
80        _ => None,
81    }
82}
83
84impl OrderTransport {
85    /// Parse the user-facing string form (`http` / `fix`). Used by
86    /// both the CLI parser and the env-var parser so the two stay
87    /// in lockstep.
88    fn parse(s: &str) -> Option<Self> {
89        match s {
90            "http" => Some(Self::Http),
91            "fix" => Some(Self::Fix),
92            _ => None,
93        }
94    }
95}
96
97impl Config {
98    /// Load configuration from CLI args, env, and `.env` file.
99    ///
100    /// # Errors
101    ///
102    /// Returns error if parsing fails (invalid addresses, numbers, etc).
103    pub fn load() -> anyhow::Result<Self> {
104        let args = Args::parse();
105
106        // Load `.env` file first (doesn't override existing env vars).
107        if let Some(ref env_file) = args.env_file {
108            dotenvy::from_path(env_file).ok(); // Ignore if file doesn't exist.
109        } else if std::path::Path::new(".env").exists() {
110            dotenvy::dotenv().ok();
111        }
112
113        // Resolve each setting in priority order: CLI, env, default.
114        //
115        // Endpoint resolution layers, top wins:
116        //   1. `--testnet` / `--mainnet` CLI flags.
117        //   2. `DERIBIT_NETWORK=testnet|mainnet` — readable
118        //      symbolic toggle, useful for compose / k8s configs.
119        //   3. `DERIBIT_ENDPOINT=<full URL>` — escape hatch for
120        //      proxies and forks.
121        //   4. Default: testnet (ADR-0009).
122        let endpoint = args
123            .endpoint()
124            .or_else(|| {
125                std::env::var("DERIBIT_NETWORK")
126                    .ok()
127                    .and_then(|v| network_to_endpoint(&v))
128            })
129            .or_else(|| std::env::var("DERIBIT_ENDPOINT").ok())
130            .unwrap_or_else(|| "https://test.deribit.com".to_string());
131
132        let client_id = args
133            .client_id
134            .clone()
135            .or_else(|| std::env::var("DERIBIT_CLIENT_ID").ok());
136
137        let client_secret = std::env::var("DERIBIT_CLIENT_SECRET").ok();
138
139        let allow_trading = args.allow_trading
140            || std::env::var("DERIBIT_ALLOW_TRADING")
141                .map(|v| v == "1")
142                .unwrap_or(false);
143
144        let max_order_usd = args.max_order_usd.or_else(|| {
145            std::env::var("DERIBIT_MAX_ORDER_USD")
146                .ok()
147                .and_then(|v| v.parse().ok())
148        });
149
150        let transport = args
151            .transport()
152            .or_else(|| {
153                std::env::var("DERIBIT_MCP_TRANSPORT")
154                    .ok()
155                    .and_then(|v| match v.as_str() {
156                        "stdio" => Some(Transport::Stdio),
157                        "http" => Some(Transport::Http),
158                        _ => None,
159                    })
160            })
161            .unwrap_or(Transport::Stdio);
162
163        let http_listen = args
164            .listen
165            .or_else(|| {
166                std::env::var("DERIBIT_HTTP_LISTEN")
167                    .ok()
168                    .and_then(|v| v.parse().ok())
169            })
170            .unwrap_or_else(|| {
171                "127.0.0.1:8723"
172                    .parse()
173                    .expect("invalid default listen addr")
174            });
175
176        // Treat an empty string as unset. `.env` files from
177        // docker-compose stacks tend to ship `KEY=` lines for
178        // optional settings; without this filter the empty string
179        // would activate the bearer-token middleware and 401 every
180        // request.
181        let http_bearer_token = std::env::var("DERIBIT_HTTP_BEARER_TOKEN")
182            .ok()
183            .filter(|v| !v.is_empty());
184
185        let order_transport = args
186            .order_transport()
187            .or_else(|| {
188                std::env::var("DERIBIT_ORDER_TRANSPORT")
189                    .ok()
190                    .and_then(|v| OrderTransport::parse(&v))
191            })
192            .unwrap_or(OrderTransport::Http);
193
194        // Exhaustive match — the compiler refuses to compile this
195        // block once a new `OrderTransport` variant is added, forcing
196        // a reviewer to decide whether the new transport needs the
197        // same gating.
198        match order_transport {
199            OrderTransport::Fix if !allow_trading => {
200                anyhow::bail!(
201                    "`--order-transport=fix` (or DERIBIT_ORDER_TRANSPORT=fix) requires \
202                     `--allow-trading` (or DERIBIT_ALLOW_TRADING=1) — without trading \
203                     the FIX session would never be reached"
204                );
205            }
206            OrderTransport::Fix | OrderTransport::Http => {}
207        }
208
209        #[allow(clippy::unnecessary_lazy_evaluations)]
210        let log_format = args
211            .log_format()
212            .or_else(|| {
213                std::env::var("DERIBIT_LOG_FORMAT")
214                    .ok()
215                    .and_then(|v| match v.as_str() {
216                        "text" => Some(LogFormat::Text),
217                        "json" => Some(LogFormat::Json),
218                        _ => None,
219                    })
220            })
221            .unwrap_or_else(|| match transport {
222                Transport::Stdio => LogFormat::Text,
223                Transport::Http => LogFormat::Json,
224            });
225
226        Ok(Self {
227            endpoint,
228            client_id,
229            client_secret,
230            allow_trading,
231            max_order_usd,
232            transport,
233            http_listen,
234            http_bearer_token,
235            log_format,
236            order_transport,
237        })
238    }
239}
240
241/// CLI arguments parsed via `clap`.
242#[derive(Debug, Parser)]
243#[command(name = "deribit-mcp")]
244#[command(about = "Model Context Protocol server for Deribit")]
245#[command(version)]
246struct Args {
247    /// Deribit endpoint: use --testnet (default) or --mainnet.
248    #[arg(long, help = "Use testnet endpoint (default)")]
249    testnet: bool,
250
251    /// Use mainnet endpoint instead of testnet.
252    #[arg(long, help = "Use mainnet endpoint")]
253    mainnet: bool,
254
255    /// Deribit client ID (or DERIBIT_CLIENT_ID env var).
256    #[arg(long, help = "Client ID for OAuth")]
257    client_id: Option<String>,
258
259    /// Enable trading tools (off by default).
260    #[arg(long, help = "Enable trading tools")]
261    allow_trading: bool,
262
263    /// Max order notional in USD (unlimited by default).
264    #[arg(long, help = "Max order notional in USD")]
265    max_order_usd: Option<u64>,
266
267    /// MCP transport: stdio (default) or http.
268    #[arg(long, help = "Transport: stdio or http")]
269    transport: Option<String>,
270
271    /// HTTP listen address (only for http transport).
272    #[arg(long, help = "HTTP listen address")]
273    listen: Option<SocketAddr>,
274
275    /// Log format: text or json.
276    #[arg(long, help = "Log format: text or json")]
277    log_format: Option<String>,
278
279    /// Upstream transport for trading tools: http (default) or fix.
280    #[arg(long, help = "Order transport: http or fix")]
281    order_transport: Option<String>,
282
283    /// Path to `.env` file (default: `./.env` if exists).
284    #[arg(long, help = "Path to .env file")]
285    env_file: Option<PathBuf>,
286}
287
288impl Args {
289    /// Parse CLI arguments.
290    fn parse() -> Self {
291        <Self as Parser>::parse()
292    }
293
294    /// Resolve endpoint from testnet/mainnet flags.
295    fn endpoint(&self) -> Option<String> {
296        if self.mainnet {
297            Some("https://www.deribit.com".to_string())
298        } else if self.testnet {
299            Some("https://test.deribit.com".to_string())
300        } else {
301            None
302        }
303    }
304
305    /// Parse transport flag.
306    fn transport(&self) -> Option<Transport> {
307        self.transport.as_ref().and_then(|t| match t.as_str() {
308            "stdio" => Some(Transport::Stdio),
309            "http" => Some(Transport::Http),
310            _ => None,
311        })
312    }
313
314    /// Parse log format flag.
315    fn log_format(&self) -> Option<LogFormat> {
316        self.log_format.as_ref().and_then(|f| match f.as_str() {
317            "text" => Some(LogFormat::Text),
318            "json" => Some(LogFormat::Json),
319            _ => None,
320        })
321    }
322
323    /// Parse order-transport flag.
324    fn order_transport(&self) -> Option<OrderTransport> {
325        self.order_transport
326            .as_ref()
327            .and_then(|t| OrderTransport::parse(t))
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn log_format_matches_transport() {
337        // Text for stdio, JSON for http (default when not specified).
338        let stdio_default = match Transport::Stdio {
339            Transport::Stdio => LogFormat::Text,
340            Transport::Http => LogFormat::Json,
341        };
342        let http_default = match Transport::Http {
343            Transport::Stdio => LogFormat::Text,
344            Transport::Http => LogFormat::Json,
345        };
346        assert_eq!(stdio_default, LogFormat::Text);
347        assert_eq!(http_default, LogFormat::Json);
348    }
349
350    /// Reproduce the `OrderTransport::Fix` requires-trading guard
351    /// from `Config::load` directly: the guard runs against locally
352    /// resolved values so it can be exercised without
353    /// `Config::load`'s CLI-arg path. Mirrors the production
354    /// `match` so adding a new `OrderTransport` variant fails to
355    /// compile in both places.
356    fn fix_requires_trading_guard(
357        order_transport: OrderTransport,
358        allow_trading: bool,
359    ) -> Result<(), &'static str> {
360        match order_transport {
361            OrderTransport::Fix if !allow_trading => Err(
362                "`--order-transport=fix` (or DERIBIT_ORDER_TRANSPORT=fix) requires `--allow-trading`",
363            ),
364            OrderTransport::Fix | OrderTransport::Http => Ok(()),
365        }
366    }
367
368    #[test]
369    fn fix_without_allow_trading_is_rejected() {
370        assert!(fix_requires_trading_guard(OrderTransport::Fix, false).is_err());
371    }
372
373    #[test]
374    fn fix_with_allow_trading_is_accepted() {
375        fix_requires_trading_guard(OrderTransport::Fix, true).unwrap();
376    }
377
378    #[test]
379    fn http_default_does_not_require_trading() {
380        fix_requires_trading_guard(OrderTransport::Http, false).unwrap();
381    }
382
383    #[test]
384    fn network_env_var_resolves_to_endpoint() {
385        assert_eq!(
386            network_to_endpoint("testnet").as_deref(),
387            Some("https://test.deribit.com")
388        );
389        assert_eq!(
390            network_to_endpoint("MAINNET").as_deref(),
391            Some("https://www.deribit.com")
392        );
393        assert_eq!(
394            network_to_endpoint(" Test ").as_deref(),
395            Some("https://test.deribit.com")
396        );
397        assert_eq!(
398            network_to_endpoint("production").as_deref(),
399            Some("https://www.deribit.com")
400        );
401        assert_eq!(network_to_endpoint("staging"), None);
402        assert_eq!(network_to_endpoint(""), None);
403    }
404
405    #[test]
406    fn order_transport_parse_round_trip() {
407        assert_eq!(OrderTransport::parse("http"), Some(OrderTransport::Http));
408        assert_eq!(OrderTransport::parse("fix"), Some(OrderTransport::Fix));
409        assert_eq!(OrderTransport::parse("HTTP"), None);
410        assert_eq!(OrderTransport::parse(""), None);
411        assert_eq!(OrderTransport::parse("rest"), None);
412    }
413}