Skip to main content

deribit_mcp/tools/
mod.rs

1//! Tool registry, effect-class gating, and dispatch.
2//!
3//! Tools are partitioned by effect class (ADR-0003):
4//!
5//! - [`public`] — `Read` tools with no auth requirement.
6//! - [`account`] — `Account` tools that require credentials.
7//! - [`trading`] — `Trading` tools that require credentials **and**
8//!   `--allow-trading`.
9//!
10//! The registry is built once at startup from the configured class set
11//! and frozen for the lifetime of the process. A tool absent from the
12//! registry is uninvokable; this is the first line of defence for the
13//! trading opt-in (ADR-0010). Dispatch performs a defence-in-depth
14//! class re-check before calling the handler.
15//!
16//! v0.1-06 ships the registry plumbing and dispatch glue. The actual
17//! `Read` tools land in v0.1-10 / v0.1-11; `Account` in v0.2;
18//! `Trading` in v0.4.
19
20use std::collections::HashMap;
21use std::future::Future;
22use std::pin::Pin;
23use std::sync::Arc;
24
25use rmcp::model::Tool;
26use serde_json::Value;
27
28use crate::context::AdapterContext;
29use crate::error::AdapterError;
30
31pub mod account;
32pub mod public;
33pub mod schema;
34pub mod trading;
35
36/// Effect class of an MCP tool.
37///
38/// Driven by ADR-0003. The class is part of the handler's type, not a
39/// runtime field — the registry refuses to register a `Trading` tool
40/// without the corresponding feature gate.
41///
42/// Marked `#[non_exhaustive]` so adding a new class in a future
43/// milestone (e.g. an `Admin` class) is not a SemVer break for callers
44/// outside the crate. Internal matches stay exhaustive.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
46#[non_exhaustive]
47pub enum ToolClass {
48    /// Read-only public market data. No auth required.
49    Read,
50    /// Authenticated account-scoped reads.
51    Account,
52    /// Trading-class actions. Requires `--allow-trading` and credentials.
53    Trading,
54}
55
56impl ToolClass {
57    /// CLI flag that enables this class. Used in
58    /// [`AdapterError::NotEnabled`] payloads so the LLM knows what is
59    /// missing.
60    #[must_use]
61    pub const fn flag(self) -> &'static str {
62        match self {
63            Self::Read => "(always enabled)",
64            Self::Account => "DERIBIT_CLIENT_ID + DERIBIT_CLIENT_SECRET",
65            Self::Trading => "--allow-trading",
66        }
67    }
68}
69
70/// Boxed dynamic future returned by every tool handler.
71///
72/// We use a `Pin<Box<dyn Future>>` rather than `impl Future` so all
73/// handlers share one type and can live in the same registry map.
74pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, AdapterError>> + Send + 'a>>;
75
76/// A handler invocation: takes the shared context and the JSON
77/// arguments and returns the JSON output (or an [`AdapterError`]).
78pub type ToolHandlerFn =
79    Arc<dyn for<'a> Fn(&'a AdapterContext, Value) -> ToolFuture<'a> + Send + Sync + 'static>;
80
81/// One registered tool: its MCP descriptor, effect class, and handler.
82///
83/// Fields are `pub(crate)` so external callers cannot bypass
84/// [`ToolRegistry::call`]'s class gate by invoking the handler
85/// directly. Read-only accessors expose the bits external callers
86/// (integration tests, listing) actually need.
87#[derive(Clone)]
88pub struct ToolEntry {
89    /// MCP `Tool` descriptor (name, description, schemas) returned by
90    /// `tools/list`.
91    pub(crate) descriptor: Tool,
92    /// Effect class. Re-checked at dispatch time.
93    pub(crate) class: ToolClass,
94    /// Async handler invoked by `tools/call`.
95    pub(crate) handler: ToolHandlerFn,
96}
97
98impl ToolEntry {
99    /// MCP `Tool` descriptor returned by `tools/list`.
100    #[must_use]
101    pub fn descriptor(&self) -> &Tool {
102        &self.descriptor
103    }
104
105    /// Effect class of this tool.
106    #[must_use]
107    pub fn class(&self) -> ToolClass {
108        self.class
109    }
110}
111
112impl std::fmt::Debug for ToolEntry {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.debug_struct("ToolEntry")
115            .field("descriptor", &self.descriptor)
116            .field("class", &self.class)
117            .field("handler", &"<dyn Fn>")
118            .finish()
119    }
120}
121
122/// Registry of MCP tools the server exposes.
123///
124/// Frozen for the lifetime of the process: built at startup, read
125/// concurrently by every dispatch.
126#[derive(Debug, Default, Clone)]
127pub struct ToolRegistry {
128    entries: HashMap<String, ToolEntry>,
129}
130
131impl ToolRegistry {
132    /// Construct an empty registry.
133    #[must_use]
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Build the registry for a given context.
139    ///
140    /// Class-driven gating:
141    ///
142    /// - `Read` tools are always registered.
143    /// - `Account` tools are registered only when the configured
144    ///   credentials are present.
145    /// - `Trading` tools are registered only when both credentials are
146    ///   present **and** `--allow-trading` is set.
147    ///
148    /// Tools whose class is not currently enabled are simply not
149    /// inserted; defence-in-depth at dispatch time covers any future
150    /// path that might add them outside this builder.
151    #[must_use]
152    pub fn build(ctx: &AdapterContext) -> Self {
153        let mut registry = Self::new();
154        public::register(&mut registry);
155        if ctx.has_credentials() {
156            account::register(&mut registry);
157        }
158        if ctx.has_credentials() && ctx.config.allow_trading {
159            trading::register(&mut registry);
160        }
161        registry
162    }
163
164    /// Insert a tool. Returns the previous entry under the same name,
165    /// if any (caller treats that as a programmer error).
166    ///
167    /// `pub(crate)` because the registry's invariant is *frozen for
168    /// the lifetime of the process after [`Self::build`]*. The only
169    /// callers are the per-family `register()` hooks invoked from
170    /// [`Self::build`]. External callers go through `build` so the
171    /// class gating is always applied.
172    ///
173    /// `allow(dead_code)` because the per-family `register()` hooks
174    /// are empty in v0.1-06 — they fill in over v0.1-10 (`Read`),
175    /// v0.2 (`Account`), and v0.4 (`Trading`).
176    #[allow(dead_code)]
177    pub(crate) fn insert(&mut self, entry: ToolEntry) -> Option<ToolEntry> {
178        let name = entry.descriptor.name.to_string();
179        self.entries.insert(name, entry)
180    }
181
182    /// Snapshot the current tool list for a `tools/list` response.
183    #[must_use]
184    pub fn list(&self) -> Vec<Tool> {
185        let mut tools: Vec<Tool> = self
186            .entries
187            .values()
188            .map(|e| e.descriptor.clone())
189            .collect();
190        tools.sort_by(|a, b| a.name.cmp(&b.name));
191        tools
192    }
193
194    /// Number of registered tools.
195    #[must_use]
196    pub fn len(&self) -> usize {
197        self.entries.len()
198    }
199
200    /// Whether the registry has any tools registered.
201    #[must_use]
202    pub fn is_empty(&self) -> bool {
203        self.entries.is_empty()
204    }
205
206    /// Look up a tool by name.
207    #[must_use]
208    pub fn get(&self, name: &str) -> Option<&ToolEntry> {
209        self.entries.get(name)
210    }
211
212    /// Whether a tool of the given name is registered.
213    #[must_use]
214    pub fn contains(&self, name: &str) -> bool {
215        self.entries.contains_key(name)
216    }
217
218    /// Dispatch a `tools/call`.
219    ///
220    /// Returns:
221    ///
222    /// - `Ok(Value)` on success.
223    /// - [`AdapterError::NotEnabled`] when the tool is registered but
224    ///   the class precondition is not currently satisfied
225    ///   (defence-in-depth re-check after registration).
226    /// - [`AdapterError::Validation`] when no tool by that name is
227    ///   registered.
228    ///
229    /// # Errors
230    ///
231    /// Surfaces any [`AdapterError`] returned by the handler.
232    pub async fn call(
233        &self,
234        ctx: &AdapterContext,
235        name: &str,
236        input: Value,
237    ) -> Result<Value, AdapterError> {
238        let entry = self
239            .get(name)
240            .ok_or_else(|| AdapterError::validation("name", format!("unknown tool: `{name}`")))?;
241
242        check_class_enabled(entry.class, ctx, &entry.descriptor.name)?;
243
244        (entry.handler)(ctx, input).await
245    }
246}
247
248/// Defence-in-depth gate: even if a tool of a higher-effect class
249/// somehow lands in the registry, dispatch refuses to invoke it
250/// without the matching configuration.
251///
252/// The `flag` returned in [`AdapterError::NotEnabled`] reflects which
253/// precondition is actually missing — credentials, the trading flag,
254/// or both — so the LLM client gets actionable feedback.
255#[inline(never)]
256fn check_class_enabled(
257    class: ToolClass,
258    ctx: &AdapterContext,
259    name: &str,
260) -> Result<(), AdapterError> {
261    match class {
262        ToolClass::Read => Ok(()),
263        ToolClass::Account => {
264            if ctx.has_credentials() {
265                Ok(())
266            } else {
267                Err(AdapterError::NotEnabled {
268                    tool: name.to_string(),
269                    flag: ToolClass::Account.flag().to_string(),
270                })
271            }
272        }
273        ToolClass::Trading => {
274            let creds = ctx.has_credentials();
275            let trading = ctx.config.allow_trading;
276            if creds && trading {
277                return Ok(());
278            }
279            let flag = match (creds, trading) {
280                (false, false) => "DERIBIT_CLIENT_ID + DERIBIT_CLIENT_SECRET + --allow-trading",
281                (false, true) => ToolClass::Account.flag(),
282                (true, false) => "--allow-trading",
283                (true, true) => unreachable!("returned Ok above"),
284            };
285            Err(AdapterError::NotEnabled {
286                tool: name.to_string(),
287                flag: flag.to_string(),
288            })
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::config::{Config, LogFormat, OrderTransport, Transport};
297    use rmcp::model::Tool;
298    use serde_json::json;
299    use std::net::SocketAddr;
300    use std::sync::Arc;
301
302    fn cfg(with_creds: bool, allow_trading: bool) -> Config {
303        Config {
304            endpoint: "https://test.deribit.com".to_string(),
305            client_id: with_creds.then(|| "id".to_string()),
306            client_secret: with_creds.then(|| "secret".to_string()),
307            allow_trading,
308            max_order_usd: None,
309            transport: Transport::Stdio,
310            http_listen: SocketAddr::from(([127, 0, 0, 1], 8723)),
311            http_bearer_token: None,
312            log_format: LogFormat::Text,
313            order_transport: OrderTransport::Http,
314        }
315    }
316
317    fn ctx(with_creds: bool, allow_trading: bool) -> AdapterContext {
318        AdapterContext::new(Arc::new(cfg(with_creds, allow_trading))).expect("ctx")
319    }
320
321    fn empty_schema() -> Arc<serde_json::Map<String, Value>> {
322        Arc::new(serde_json::Map::new())
323    }
324
325    fn make_entry(name: &'static str, class: ToolClass) -> ToolEntry {
326        let descriptor = Tool::new(
327            std::borrow::Cow::Borrowed(name),
328            "test tool",
329            empty_schema(),
330        );
331        let handler: ToolHandlerFn =
332            Arc::new(|_ctx, _input| Box::pin(async move { Ok(json!({"ok": true})) }));
333        ToolEntry {
334            descriptor,
335            class,
336            handler,
337        }
338    }
339
340    #[test]
341    fn class_flags_match_documentation() {
342        assert_eq!(ToolClass::Read.flag(), "(always enabled)");
343        assert_eq!(
344            ToolClass::Account.flag(),
345            "DERIBIT_CLIENT_ID + DERIBIT_CLIENT_SECRET"
346        );
347        assert_eq!(ToolClass::Trading.flag(), "--allow-trading");
348    }
349
350    #[test]
351    fn registry_starts_empty() {
352        let r = ToolRegistry::new();
353        assert!(r.is_empty());
354        assert_eq!(r.len(), 0);
355        assert!(r.list().is_empty());
356    }
357
358    #[test]
359    fn registry_lists_sorted_by_name() {
360        let mut r = ToolRegistry::new();
361        r.insert(make_entry("get_ticker", ToolClass::Read));
362        r.insert(make_entry("get_book", ToolClass::Read));
363        let listed = r.list();
364        let names: Vec<&str> = listed.iter().map(|t| t.name.as_ref()).collect();
365        assert_eq!(names, vec!["get_book", "get_ticker"]);
366    }
367
368    #[test]
369    fn build_without_creds_includes_only_read() {
370        let registry = ToolRegistry::build(&ctx(false, false));
371        // v0.1-10 ships 5 per-instrument tools and v0.1-11 ships 9
372        // summary / meta tools — 14 Read-class tools total. Account /
373        // Trading families remain empty because v0.2 / v0.4 have not
374        // landed.
375        assert_eq!(registry.len(), 14);
376        for tool in registry.list() {
377            let entry = registry.get(tool.name.as_ref()).expect("entry");
378            assert_eq!(entry.class, ToolClass::Read, "{}", tool.name);
379        }
380    }
381
382    #[tokio::test]
383    async fn dispatch_unknown_tool_returns_validation() {
384        let registry = ToolRegistry::new();
385        let ctx = ctx(false, false);
386        let err = registry
387            .call(&ctx, "no_such_tool", Value::Null)
388            .await
389            .unwrap_err();
390        match err {
391            AdapterError::Validation { field, .. } => assert_eq!(field, "name"),
392            other => panic!("unexpected: {other:?}"),
393        }
394    }
395
396    #[tokio::test]
397    async fn dispatch_read_class_succeeds_without_creds() {
398        let mut registry = ToolRegistry::new();
399        registry.insert(make_entry("ping", ToolClass::Read));
400        let ctx = ctx(false, false);
401        let out = registry.call(&ctx, "ping", Value::Null).await.expect("ok");
402        assert_eq!(out, json!({"ok": true}));
403    }
404
405    #[tokio::test]
406    async fn dispatch_account_class_requires_credentials() {
407        let mut registry = ToolRegistry::new();
408        registry.insert(make_entry("get_account_summary", ToolClass::Account));
409        let ctx = ctx(false, false);
410        let err = registry
411            .call(&ctx, "get_account_summary", Value::Null)
412            .await
413            .unwrap_err();
414        match err {
415            AdapterError::NotEnabled { tool, flag } => {
416                assert_eq!(tool, "get_account_summary");
417                assert_eq!(flag, ToolClass::Account.flag());
418            }
419            other => panic!("unexpected: {other:?}"),
420        }
421    }
422
423    #[tokio::test]
424    async fn dispatch_account_class_succeeds_with_credentials() {
425        let mut registry = ToolRegistry::new();
426        registry.insert(make_entry("get_account_summary", ToolClass::Account));
427        let ctx = ctx(true, false);
428        registry
429            .call(&ctx, "get_account_summary", Value::Null)
430            .await
431            .expect("ok");
432    }
433
434    #[tokio::test]
435    async fn dispatch_trading_class_requires_allow_trading_flag() {
436        let mut registry = ToolRegistry::new();
437        registry.insert(make_entry("place_order", ToolClass::Trading));
438        let ctx = ctx(true, false);
439        let err = registry
440            .call(&ctx, "place_order", Value::Null)
441            .await
442            .unwrap_err();
443        match err {
444            AdapterError::NotEnabled { tool, flag } => {
445                assert_eq!(tool, "place_order");
446                assert_eq!(flag, "--allow-trading");
447            }
448            other => panic!("unexpected: {other:?}"),
449        }
450    }
451
452    #[tokio::test]
453    async fn dispatch_trading_class_succeeds_with_creds_and_flag() {
454        let mut registry = ToolRegistry::new();
455        registry.insert(make_entry("place_order", ToolClass::Trading));
456        let ctx = ctx(true, true);
457        registry
458            .call(&ctx, "place_order", Value::Null)
459            .await
460            .expect("ok");
461    }
462}