Skip to main content

aprender_mcp/tools/
registry.rs

1//! HELIX-IDEA-002 — link-time tool registration via `inventory`.
2//!
3//! Contract: `contracts/apr-mcp-tool-inventory-v1.yaml`.
4//!
5//! Each `tools/<tool>.rs` submits one [`McpToolEntry`] to the global
6//! inventory using the [`register_mcp_tool!`] macro. The dispatcher
7//! ([`AprMcpServer`](crate::server::AprMcpServer)) iterates
8//! `inventory::iter::<McpToolEntry>` once at startup and builds:
9//!
10//! * a `Vec<ToolDefinition>` (replaces the hardcoded vec at
11//!   `server.rs:221-233`),
12//! * a `BTreeMap<&str, DispatchFn>` (replaces the hardcoded match arms
13//!   at `server.rs:461-483`).
14//!
15//! Adding a new tool is therefore a one-file edit under `tools/`: write
16//! the dispatcher, the definition factory, and one
17//! `register_mcp_tool!` invocation. No more edits to `server.rs`.
18//!
19//! `inputSchema` remains contracts-driven (FALSIFY-MCP-008); this
20//! module only owns the *registration* path.
21
22use crate::server::NotificationSink;
23use crate::types::{ToolCallResult, ToolDefinition};
24use std::collections::BTreeMap;
25use std::sync::mpsc;
26
27/// Unified dispatch signature shared by every MCP tool. Tools that do
28/// not need cancel / sink simply ignore those parameters.
29///
30/// * `args` — the `params.arguments` JSON value from the MCP request.
31/// * `cancel_rx` — an mpsc channel signalled by `notifications/cancelled`.
32///   Honoured today only by `apr.run`; other tools accept it for ABI
33///   uniformity.
34/// * `sink` — `Some` when the client supplied `params._meta.progressToken`
35///   AND the tool supports streaming. Honoured today by `apr.finetune`
36///   and `apr.run`; other tools accept it for ABI uniformity.
37/// * `progress_token` — the JSON value of `params._meta.progressToken`
38///   passed back verbatim in each `notifications/progress`.
39pub type DispatchFn = fn(
40    &serde_json::Value,
41    &mpsc::Receiver<()>,
42    Option<&NotificationSink>,
43    Option<serde_json::Value>,
44) -> ToolCallResult;
45
46/// Submitted by every tool module via [`register_mcp_tool!`]. The
47/// dispatcher reads these out of [`inventory`] at startup.
48#[cfg(feature = "apr-tools")]
49#[derive(Debug)]
50pub struct McpToolEntry {
51    /// MCP tool name advertised in `tools/list` and matched in
52    /// `tools/call`. Examples: `"apr.version"`, `"apr.run"`.
53    pub name: &'static str,
54    /// Returns the full [`ToolDefinition`] (name + description + input
55    /// schema). Called once per `tools/list` request.
56    pub definition_fn: fn() -> ToolDefinition,
57    /// Returns the [`ToolCallResult`] for a `tools/call` request.
58    pub dispatch_fn: DispatchFn,
59}
60
61#[cfg(feature = "apr-tools")]
62inventory::collect!(McpToolEntry);
63
64/// One-time index built from the global inventory at startup. Holds
65/// definitions in registration order (so `tools/list` output is
66/// deterministic across builds — `inventory::iter` itself orders entries
67/// by submission link order, but we sort by name to remove that
68/// dependency for tests) and a name → dispatch map for `tools/call`.
69#[derive(Debug)]
70pub struct ToolIndex {
71    definitions: Vec<ToolDefinition>,
72    /// Keyed by an OWNED name (PMAT-3954): a caller-supplied tool's name is a `String`
73    /// in its [`ToolDefinition`], never `'static`.
74    dispatch: BTreeMap<String, DispatchFn>,
75}
76
77impl ToolIndex {
78    /// Build an index from a caller-supplied tool set (PMAT-3954) — a downstream server
79    /// serving ITS OWN tools on this crate, with no `inventory` and no apr tool linked in.
80    ///
81    /// Definitions are sorted by name, as [`Self::from_inventory`] sorts them.
82    ///
83    /// # Errors
84    ///
85    /// Two entries with one name are refused, naming the tool — the same rule as
86    /// FALSIFY-INVENTORY-002, returned rather than panicked because the caller built it.
87    pub fn from_entries(entries: Vec<(ToolDefinition, DispatchFn)>) -> Result<Self, String> {
88        let mut definitions = Vec::with_capacity(entries.len());
89        let mut dispatch: BTreeMap<String, DispatchFn> = BTreeMap::new();
90        for (definition, dispatch_fn) in entries {
91            if dispatch
92                .insert(definition.name.clone(), dispatch_fn)
93                .is_some()
94            {
95                return Err(format!(
96                    "duplicate MCP tool name {:?}: two entries advertise the same name",
97                    definition.name
98                ));
99            }
100            definitions.push(definition);
101        }
102        definitions.sort_by(|a, b| a.name.cmp(&b.name));
103        Ok(Self {
104            definitions,
105            dispatch,
106        })
107    }
108
109    /// Build the index from `inventory::iter::<McpToolEntry>()`.
110    ///
111    /// FALSIFY-INVENTORY-002: panics with a clear diagnostic if two
112    /// entries share the same `name`. The panic fires the first time
113    /// any test or production path constructs an `AprMcpServer`, so a
114    /// duplicate-name regression cannot escape CI.
115    #[cfg(feature = "apr-tools")]
116    #[must_use]
117    pub fn from_inventory() -> Self {
118        let mut by_name: BTreeMap<&'static str, &'static McpToolEntry> = BTreeMap::new();
119        for entry in inventory::iter::<McpToolEntry> {
120            if let Some(prior) = by_name.insert(entry.name, entry) {
121                // Restore the prior entry so the panic message lists both.
122                by_name.insert(prior.name, prior);
123                panic!(
124                    "FALSIFY-INVENTORY-002: duplicate MCP tool name {:?} registered twice in the \
125                     inventory. Two `register_mcp_tool!` invocations advertise the same name; \
126                     pick one. Existing: {:p}, duplicate: {:p}.",
127                    entry.name, prior, entry,
128                );
129            }
130        }
131
132        // Deterministic order — sorted by name so test goldens are stable
133        // across linker reorderings of `inventory::submit!`.
134        let mut definitions: Vec<ToolDefinition> =
135            by_name.values().map(|e| (e.definition_fn)()).collect();
136        definitions.sort_by(|a, b| a.name.cmp(&b.name));
137
138        let dispatch: BTreeMap<String, DispatchFn> = by_name
139            .iter()
140            .map(|(name, entry)| ((*name).to_string(), entry.dispatch_fn))
141            .collect();
142
143        Self {
144            definitions,
145            dispatch,
146        }
147    }
148
149    /// All tool definitions advertised by `tools/list`, sorted by name.
150    #[must_use]
151    pub fn definitions(&self) -> &[ToolDefinition] {
152        &self.definitions
153    }
154
155    /// Look up the dispatch function for a `tools/call` request.
156    /// Returns `None` if no tool with that name is registered — the
157    /// caller is responsible for emitting the appropriate `isError`
158    /// envelope.
159    #[must_use]
160    pub fn dispatch_for(&self, name: &str) -> Option<&DispatchFn> {
161        self.dispatch.get(name)
162    }
163
164    /// Names of all registered tools, alphabetically sorted. Used by
165    /// FALSIFY-INVENTORY-001 to assert the migrated set matches the
166    /// pre-migration golden list.
167    #[must_use]
168    pub fn names(&self) -> Vec<&str> {
169        self.dispatch.keys().map(String::as_str).collect()
170    }
171}
172
173/// Submit one [`McpToolEntry`] to the global inventory.
174///
175/// Usage (one invocation per `tools/<tool>.rs`):
176///
177/// ```ignore
178/// register_mcp_tool!(
179///     name: "apr.foo",
180///     definition: foo_tool_definition,
181///     dispatch: dispatch,
182/// );
183/// ```
184///
185/// The `dispatch` argument MUST point at a function with signature
186/// [`DispatchFn`] — typically a thin shim that calls the tool's
187/// existing `call` / `call_with_sink`.
188#[cfg(feature = "apr-tools")]
189#[macro_export]
190macro_rules! register_mcp_tool {
191    (name: $name:expr, definition: $def:path, dispatch: $dispatch:path $(,)?) => {
192        ::inventory::submit! {
193            $crate::tools::registry::McpToolEntry {
194                name: $name,
195                definition_fn: $def,
196                dispatch_fn: $dispatch,
197            }
198        }
199    };
200}
201
202#[cfg(all(test, feature = "apr-tools"))] // from_inventory exists only with apr's tools
203mod tests {
204    use super::*;
205
206    /// Sanity: building the index from the live inventory does not panic
207    /// and yields the 9 Phase-1 tool names in alphabetical order.
208    #[test]
209    fn live_inventory_yields_phase_one_tool_set() {
210        let index = ToolIndex::from_inventory();
211        let names = index.names();
212        let expected = [
213            "apr.bench",
214            "apr.finetune",
215            "apr.qa",
216            "apr.run",
217            "apr.serve",
218            "apr.tensors",
219            "apr.trace",
220            "apr.validate",
221            "apr.version",
222        ];
223        assert_eq!(names, expected);
224        assert_eq!(index.definitions().len(), 9);
225    }
226
227    #[test]
228    fn dispatch_for_known_tool_returns_some() {
229        let index = ToolIndex::from_inventory();
230        assert!(index.dispatch_for("apr.version").is_some());
231        assert!(index.dispatch_for("apr.qa").is_some());
232    }
233
234    #[test]
235    fn dispatch_for_unknown_tool_returns_none() {
236        let index = ToolIndex::from_inventory();
237        assert!(index.dispatch_for("apr.never").is_none());
238    }
239}