Skip to main content

deribit_mcp/
context.rs

1//! Shared adapter context — the single value every handler holds.
2//!
3//! `AdapterContext` owns the configuration snapshot, the upstream HTTP
4//! client (built eagerly), and a lazy WebSocket client (constructed on
5//! first use, since live resources only land in v0.3 — ADR-0006).
6//!
7//! Handlers receive an `Arc<AdapterContext>`. The context is built once
8//! at startup and never mutated; the `OnceCell` guards single-init of
9//! the WS client.
10
11use std::sync::Arc;
12
13#[cfg(feature = "fix")]
14use deribit_fix::DeribitFixClient;
15#[cfg(feature = "fix")]
16use deribit_fix::config::DeribitFixConfig;
17use deribit_http::config::credentials::ApiCredentials;
18use deribit_http::{DeribitHttpClient, HttpConfig};
19use deribit_websocket::client::DeribitWebSocketClient;
20use deribit_websocket::config::WebSocketConfig;
21#[cfg(feature = "fix")]
22use tokio::sync::Mutex;
23use tokio::sync::OnceCell;
24use url::Url;
25
26use crate::config::Config;
27#[cfg(feature = "fix")]
28use crate::config::OrderTransport;
29use crate::error::AdapterError;
30
31const TESTNET_WS_URL: &str = "wss://test.deribit.com/ws/api/v2";
32const MAINNET_WS_URL: &str = "wss://www.deribit.com/ws/api/v2";
33
34/// Shared adapter context.
35///
36/// Cheap to clone via `Arc`; safe to share across tokio tasks. The
37/// upstream HTTP client is constructed eagerly so a misconfiguration
38/// surfaces at startup. The WebSocket client is lazy — most v0.1 tools
39/// are HTTP-only.
40///
41/// `Debug` is implemented manually below so the upstream
42/// `DeribitFixClient` (which does not derive `Debug`) doesn't leak
43/// into the bound; the FIX field is rendered as a redacted
44/// `<fix client>` placeholder.
45pub struct AdapterContext {
46    /// Resolved configuration. Frozen for the lifetime of the process.
47    pub config: Arc<Config>,
48    /// Upstream HTTP client used by every `Read` / `Account` / `Trading`
49    /// tool.
50    pub http: DeribitHttpClient,
51    /// Upstream WebSocket client. Built lazily on first
52    /// `websocket()` access.
53    ws: OnceCell<DeribitWebSocketClient>,
54    /// Upstream FIX 4.4 client. Built lazily on first
55    /// [`ensure_fix`](Self::ensure_fix) call when
56    /// `--order-transport=fix` is configured. Wrapped in a tokio
57    /// [`Mutex`] because [`deribit_fix::DeribitFixClient`] takes
58    /// `&mut self` for `connect` / `disconnect` / order operations.
59    #[cfg(feature = "fix")]
60    fix: OnceCell<Arc<Mutex<DeribitFixClient>>>,
61}
62
63impl std::fmt::Debug for AdapterContext {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        let mut s = f.debug_struct("AdapterContext");
66        s.field("config", &self.config)
67            .field("http", &"<DeribitHttpClient>")
68            .field("ws", &self.ws);
69        #[cfg(feature = "fix")]
70        s.field(
71            "fix",
72            &if self.fix.initialized() {
73                "<fix client>"
74            } else {
75                "<not initialized>"
76            },
77        );
78        s.finish()
79    }
80}
81
82impl AdapterContext {
83    /// Build the adapter context from a resolved [`Config`].
84    ///
85    /// # Errors
86    ///
87    /// Returns [`AdapterError::Validation`] when the configured Deribit
88    /// endpoint is not a valid URL. The upstream HTTP client itself is
89    /// infallible to construct.
90    pub fn new(config: Arc<Config>) -> Result<Self, AdapterError> {
91        let http_cfg = http_config_from(&config)?;
92        let http = DeribitHttpClient::with_config(http_cfg);
93
94        Ok(Self {
95            config,
96            http,
97            ws: OnceCell::new(),
98            #[cfg(feature = "fix")]
99            fix: OnceCell::new(),
100        })
101    }
102
103    /// Whether the configuration carries both an OAuth client id and
104    /// secret. The tool registry uses this to gate the `Account` and
105    /// `Trading` families (ADR-0003 / ADR-0010).
106    #[must_use]
107    pub fn has_credentials(&self) -> bool {
108        self.config.client_id.is_some() && self.config.client_secret.is_some()
109    }
110
111    /// Snapshot of the OAuth state. Drives registry decisions
112    /// (whether `Account` / `Trading` tools register at all) and
113    /// gives downstream callers a stable enum to match on instead
114    /// of a free-form `bool`.
115    ///
116    /// Auth is **lazy** — `Configured` does not imply that
117    /// `deribit-http` has yet issued a `public/auth` call. The
118    /// upstream `AuthManager` triggers OAuth on the first private
119    /// endpoint hit and refreshes ~30 s before `expires_in`
120    /// (handled inside `deribit-http`).
121    #[must_use]
122    pub fn auth_state(&self) -> AuthState {
123        if self.has_credentials() {
124            AuthState::Configured
125        } else {
126            AuthState::Anonymous
127        }
128    }
129
130    /// Lazily construct (or return) the WebSocket client.
131    ///
132    /// # Errors
133    ///
134    /// Returns [`AdapterError::Upstream`] (with
135    /// [`UpstreamErrorKind::Websocket`]) when the upstream WebSocket
136    /// crate refuses the configuration — typically a transport
137    /// failure on the very first connect attempt.
138    ///
139    /// [`UpstreamErrorKind::Websocket`]: crate::error::UpstreamErrorKind::Websocket
140    pub async fn websocket(&self) -> Result<&DeribitWebSocketClient, AdapterError> {
141        self.ws
142            .get_or_try_init(|| async {
143                let cfg = ws_config_from(&self.config);
144                DeribitWebSocketClient::new(&cfg)
145            })
146            .await
147            .map_err(AdapterError::from)
148    }
149
150    /// Lazily construct, log on, and return a shared handle to the
151    /// FIX 4.4 client.
152    ///
153    /// First call drives `DeribitFixClient::new` + `connect()`,
154    /// which performs the FIX `Logon (A)` and starts the heartbeat
155    /// task. Subsequent calls return the same `Arc<Mutex<…>>` so
156    /// callers reuse a single session across the process lifetime.
157    /// SIGTERM should drive [`shutdown_fix`](Self::shutdown_fix) so
158    /// the session ends with a proper FIX `Logout (5)`.
159    ///
160    /// # Errors
161    ///
162    /// - [`AdapterError::Validation`] with `field = "order_transport"`
163    ///   when the configuration does not select the FIX transport
164    ///   (`OrderTransport::Http`); calling `ensure_fix` in that
165    ///   state is a programmer error.
166    /// - [`AdapterError::Auth`] with the upstream FIX rejection
167    ///   reason when `Logon (A)` is rejected.
168    /// - [`AdapterError::Upstream`] with [`UpstreamErrorKind::Fix`]
169    ///   for transport, session, config, and protocol errors.
170    ///
171    /// [`UpstreamErrorKind::Fix`]: crate::error::UpstreamErrorKind::Fix
172    #[cfg(feature = "fix")]
173    pub async fn ensure_fix(&self) -> Result<Arc<Mutex<DeribitFixClient>>, AdapterError> {
174        match self.config.order_transport {
175            OrderTransport::Fix => {}
176            OrderTransport::Http => {
177                return Err(AdapterError::validation(
178                    "order_transport",
179                    "ensure_fix called but configured order_transport is `http`",
180                ));
181            }
182        }
183        let handle = self
184            .fix
185            .get_or_try_init(|| async {
186                let cfg = fix_config_from(&self.config)?;
187                let mut client = DeribitFixClient::new(&cfg).await?;
188                client.connect().await?;
189                Ok::<_, AdapterError>(Arc::new(Mutex::new(client)))
190            })
191            .await?;
192        Ok(handle.clone())
193    }
194
195    /// Issue a FIX `Logout (5)` and tear down the session, if one
196    /// has been established. No-op when the FIX session was never
197    /// opened. Called from the SIGTERM handler at process shutdown.
198    ///
199    /// # Errors
200    ///
201    /// Surfaces any [`AdapterError`] that the upstream
202    /// `disconnect` call produces. Best-effort — callers should
203    /// log the error rather than abort the shutdown.
204    #[cfg(feature = "fix")]
205    pub async fn shutdown_fix(&self) -> Result<(), AdapterError> {
206        if let Some(handle) = self.fix.get() {
207            let mut guard = handle.lock().await;
208            guard.disconnect().await?;
209        }
210        Ok(())
211    }
212}
213
214/// Build the upstream `HttpConfig` from our resolved `Config`.
215///
216/// Forwards `client_id` / `client_secret` from our resolved `Config`
217/// into the upstream `ApiCredentials`. Without this step, the upstream
218/// `HttpConfig::testnet()` / `production()` constructors fall back to
219/// `DERIBIT_CLIENT_ID` / `DERIBIT_CLIENT_SECRET` env vars — which may
220/// already match, but only if dotenvy has populated the process
221/// environment. Forwarding explicitly removes the dependency.
222fn http_config_from(config: &Config) -> Result<HttpConfig, AdapterError> {
223    let parsed = Url::parse(&config.endpoint)
224        .map_err(|err| AdapterError::validation("endpoint", format!("invalid URL: {err}")))?;
225
226    let testnet = !is_mainnet(&parsed);
227    let mut cfg = if testnet {
228        HttpConfig::testnet()
229    } else {
230        HttpConfig::production()
231    };
232    // Only override `base_url` when the caller has actually
233    // supplied a custom path (proxy / fork). The upstream's
234    // `testnet()` / `production()` constructors already pin
235    // `https://(test|www).deribit.com/api/v2`. A bare host like
236    // `https://test.deribit.com` (no trailing path) would strip
237    // the `/api/v2` suffix and turn every request into a 404 —
238    // see deribit-http's `TESTNET_BASE_URL` /
239    // `PRODUCTION_BASE_URL` constants.
240    let user_supplied_path = !matches!(parsed.path(), "" | "/");
241    if user_supplied_path {
242        cfg.base_url = parsed;
243    }
244    cfg.testnet = testnet;
245    // Match on references first so we never clone the secret on the
246    // partial-credential branch (where the clone would be discarded
247    // and only inflate the number of in-memory copies of the secret
248    // for `tracing`/heap dumps to potentially observe).
249    cfg.credentials = match (config.client_id.as_ref(), config.client_secret.as_ref()) {
250        (Some(client_id), Some(client_secret)) => Some(ApiCredentials {
251            client_id: Some(client_id.clone()),
252            client_secret: Some(client_secret.clone()),
253        }),
254        _ => None,
255    };
256    Ok(cfg)
257}
258
259/// OAuth posture the adapter advertises to its callers.
260///
261/// Returned by [`AdapterContext::auth_state`].
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum AuthState {
264    /// No credentials configured — only public `Read` tools register.
265    Anonymous,
266    /// Credentials present in the config. The first private call
267    /// triggers OAuth via the upstream `AuthManager`.
268    Configured,
269}
270
271/// Build the upstream `WebSocketConfig` from our resolved `Config`.
272///
273/// Infallible: both URLs are compile-time constants and parse
274/// successfully. The `expect` here would only fire if the upstream
275/// crate's URL parser regressed.
276fn ws_config_from(config: &Config) -> WebSocketConfig {
277    let url = if endpoint_is_mainnet(&config.endpoint) {
278        MAINNET_WS_URL
279    } else {
280        TESTNET_WS_URL
281    };
282    WebSocketConfig::with_url(url).expect("compile-time WS URL constant must parse")
283}
284
285fn endpoint_is_mainnet(endpoint: &str) -> bool {
286    Url::parse(endpoint).ok().is_some_and(|u| is_mainnet(&u))
287}
288
289/// Build the upstream `DeribitFixConfig` from our resolved `Config`.
290///
291/// `client_id` becomes the FIX `Username` field; `client_secret`
292/// is the password material the upstream library uses to sign the
293/// logon (HMAC-SHA-256 with timestamp + nonce, per the Deribit
294/// FIX spec). The host / port pair is picked by environment:
295/// testnet → `fix-test.deribit.com:9881`, mainnet →
296/// `fix.deribit.com:9881`.
297#[cfg(feature = "fix")]
298fn fix_config_from(config: &Config) -> Result<DeribitFixConfig, AdapterError> {
299    let (Some(client_id), Some(client_secret)) =
300        (config.client_id.as_ref(), config.client_secret.as_ref())
301    else {
302        return Err(AdapterError::validation(
303            "credentials",
304            "FIX transport requires DERIBIT_CLIENT_ID + DERIBIT_CLIENT_SECRET",
305        ));
306    };
307    let mainnet = endpoint_is_mainnet(&config.endpoint);
308    let (host, port) = if mainnet {
309        ("fix.deribit.com", 9881_u16)
310    } else {
311        ("fix-test.deribit.com", 9881_u16)
312    };
313    let mut fix_cfg =
314        DeribitFixConfig::new().with_credentials(client_id.clone(), client_secret.clone());
315    fix_cfg.host = host.to_string();
316    fix_cfg.port = port;
317    fix_cfg.use_ssl = false;
318    Ok(fix_cfg)
319}
320
321fn is_mainnet(url: &Url) -> bool {
322    matches!(url.host_str(), Some(host) if host == "www.deribit.com" || host == "deribit.com")
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::config::{LogFormat, OrderTransport, Transport};
329    use std::net::SocketAddr;
330
331    fn cfg(endpoint: &str, with_creds: bool) -> Config {
332        Config {
333            endpoint: endpoint.to_string(),
334            client_id: with_creds.then(|| "id".to_string()),
335            client_secret: with_creds.then(|| "secret".to_string()),
336            allow_trading: false,
337            max_order_usd: None,
338            transport: Transport::Stdio,
339            http_listen: SocketAddr::from(([127, 0, 0, 1], 8723)),
340            http_bearer_token: None,
341            log_format: LogFormat::Text,
342            order_transport: OrderTransport::Http,
343        }
344    }
345
346    #[cfg(feature = "fix")]
347    #[tokio::test]
348    async fn ensure_fix_when_transport_is_http_returns_validation() {
349        // Default `cfg(...)` builds with `OrderTransport::Http`. The
350        // ensure_fix call must short-circuit with a structured
351        // Validation error rather than attempt a network connect.
352        let ctx =
353            AdapterContext::new(Arc::new(cfg("https://test.deribit.com", true))).expect("ctx");
354        // `Arc<Mutex<DeribitFixClient>>` doesn't derive `Debug`, so
355        // we destructure the result manually instead of going
356        // through `unwrap_err`.
357        match ctx.ensure_fix().await {
358            Ok(_) => panic!("expected Validation error, got Ok"),
359            Err(AdapterError::Validation { field, .. }) => {
360                assert_eq!(field, "order_transport");
361            }
362            Err(other) => panic!("unexpected: {other:?}"),
363        }
364    }
365
366    #[cfg(feature = "fix")]
367    #[tokio::test]
368    async fn ensure_fix_without_credentials_returns_validation() {
369        // Configure the FIX transport but with no creds; the
370        // upstream `DeribitFixClient::new` would otherwise be
371        // exercised. Adapter rejects up-front.
372        let mut config = cfg("https://test.deribit.com", false);
373        config.order_transport = OrderTransport::Fix;
374        config.allow_trading = true;
375        let ctx = AdapterContext::new(Arc::new(config)).expect("ctx");
376        match ctx.ensure_fix().await {
377            Ok(_) => panic!("expected Validation error, got Ok"),
378            Err(AdapterError::Validation { field, .. }) => {
379                assert_eq!(field, "credentials");
380            }
381            Err(other) => panic!("unexpected: {other:?}"),
382        }
383    }
384
385    #[cfg(feature = "fix")]
386    #[tokio::test]
387    async fn shutdown_fix_when_never_opened_is_noop() {
388        let ctx =
389            AdapterContext::new(Arc::new(cfg("https://test.deribit.com", true))).expect("ctx");
390        ctx.shutdown_fix().await.expect("noop ok");
391    }
392
393    #[test]
394    fn context_builds_for_testnet_endpoint() {
395        let ctx =
396            AdapterContext::new(Arc::new(cfg("https://test.deribit.com", false))).expect("context");
397        assert!(!ctx.has_credentials());
398    }
399
400    #[test]
401    fn context_builds_for_mainnet_endpoint() {
402        let ctx =
403            AdapterContext::new(Arc::new(cfg("https://www.deribit.com", true))).expect("context");
404        assert!(ctx.has_credentials());
405    }
406
407    #[test]
408    fn context_rejects_invalid_endpoint() {
409        let err = AdapterContext::new(Arc::new(cfg("not a url", false))).unwrap_err();
410        assert!(matches!(
411            err,
412            AdapterError::Validation { ref field, .. } if field == "endpoint"
413        ));
414    }
415
416    #[test]
417    fn has_credentials_requires_both_id_and_secret() {
418        let mut c = cfg("https://test.deribit.com", false);
419        c.client_id = Some("id".into());
420        let ctx = AdapterContext::new(Arc::new(c)).expect("context");
421        assert!(!ctx.has_credentials());
422    }
423
424    #[test]
425    fn auth_state_is_anonymous_without_credentials() {
426        let ctx =
427            AdapterContext::new(Arc::new(cfg("https://test.deribit.com", false))).expect("ctx");
428        assert_eq!(ctx.auth_state(), AuthState::Anonymous);
429    }
430
431    #[test]
432    fn auth_state_is_configured_with_credentials() {
433        let ctx =
434            AdapterContext::new(Arc::new(cfg("https://test.deribit.com", true))).expect("ctx");
435        assert_eq!(ctx.auth_state(), AuthState::Configured);
436    }
437
438    #[test]
439    fn http_config_carries_credentials_into_upstream() {
440        // We can't observe `HttpConfig.credentials` from outside the
441        // adapter (the field is `pub` but the client owns the value),
442        // so this test pins the struct-level forwarding by building
443        // the same config the constructor builds and asserting the
444        // credentials it places on `HttpConfig`.
445        let resolved = cfg("https://test.deribit.com", true);
446        let http_cfg = http_config_from(&resolved).expect("http cfg");
447        let creds = http_cfg.credentials.as_ref().expect("credentials present");
448        assert_eq!(creds.client_id.as_deref(), Some("id"));
449        assert_eq!(creds.client_secret.as_deref(), Some("secret"));
450    }
451
452    #[test]
453    fn http_config_omits_credentials_without_both() {
454        let mut resolved = cfg("https://test.deribit.com", false);
455        resolved.client_id = Some("id".into());
456        let http_cfg = http_config_from(&resolved).expect("http cfg");
457        assert!(http_cfg.credentials.is_none());
458    }
459}