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#[derive(Debug)]
49pub struct McpToolEntry {
50 /// MCP tool name advertised in `tools/list` and matched in
51 /// `tools/call`. Examples: `"apr.version"`, `"apr.run"`.
52 pub name: &'static str,
53 /// Returns the full [`ToolDefinition`] (name + description + input
54 /// schema). Called once per `tools/list` request.
55 pub definition_fn: fn() -> ToolDefinition,
56 /// Returns the [`ToolCallResult`] for a `tools/call` request.
57 pub dispatch_fn: DispatchFn,
58}
59
60inventory::collect!(McpToolEntry);
61
62/// One-time index built from the global inventory at startup. Holds
63/// definitions in registration order (so `tools/list` output is
64/// deterministic across builds — `inventory::iter` itself orders entries
65/// by submission link order, but we sort by name to remove that
66/// dependency for tests) and a name → dispatch map for `tools/call`.
67#[derive(Debug)]
68pub struct ToolIndex {
69 definitions: Vec<ToolDefinition>,
70 dispatch: BTreeMap<&'static str, DispatchFn>,
71}
72
73impl ToolIndex {
74 /// Build the index from `inventory::iter::<McpToolEntry>()`.
75 ///
76 /// FALSIFY-INVENTORY-002: panics with a clear diagnostic if two
77 /// entries share the same `name`. The panic fires the first time
78 /// any test or production path constructs an `AprMcpServer`, so a
79 /// duplicate-name regression cannot escape CI.
80 #[must_use]
81 pub fn from_inventory() -> Self {
82 let mut by_name: BTreeMap<&'static str, &'static McpToolEntry> = BTreeMap::new();
83 for entry in inventory::iter::<McpToolEntry> {
84 if let Some(prior) = by_name.insert(entry.name, entry) {
85 // Restore the prior entry so the panic message lists both.
86 by_name.insert(prior.name, prior);
87 panic!(
88 "FALSIFY-INVENTORY-002: duplicate MCP tool name {:?} registered twice in the \
89 inventory. Two `register_mcp_tool!` invocations advertise the same name; \
90 pick one. Existing: {:p}, duplicate: {:p}.",
91 entry.name, prior, entry,
92 );
93 }
94 }
95
96 // Deterministic order — sorted by name so test goldens are stable
97 // across linker reorderings of `inventory::submit!`.
98 let mut definitions: Vec<ToolDefinition> =
99 by_name.values().map(|e| (e.definition_fn)()).collect();
100 definitions.sort_by(|a, b| a.name.cmp(&b.name));
101
102 let dispatch: BTreeMap<&'static str, DispatchFn> = by_name
103 .iter()
104 .map(|(name, entry)| (*name, entry.dispatch_fn))
105 .collect();
106
107 Self {
108 definitions,
109 dispatch,
110 }
111 }
112
113 /// All tool definitions advertised by `tools/list`, sorted by name.
114 #[must_use]
115 pub fn definitions(&self) -> &[ToolDefinition] {
116 &self.definitions
117 }
118
119 /// Look up the dispatch function for a `tools/call` request.
120 /// Returns `None` if no tool with that name is registered — the
121 /// caller is responsible for emitting the appropriate `isError`
122 /// envelope.
123 #[must_use]
124 pub fn dispatch_for(&self, name: &str) -> Option<&DispatchFn> {
125 self.dispatch.get(name)
126 }
127
128 /// Names of all registered tools, alphabetically sorted. Used by
129 /// FALSIFY-INVENTORY-001 to assert the migrated set matches the
130 /// pre-migration golden list.
131 #[must_use]
132 pub fn names(&self) -> Vec<&'static str> {
133 self.dispatch.keys().copied().collect()
134 }
135}
136
137/// Submit one [`McpToolEntry`] to the global inventory.
138///
139/// Usage (one invocation per `tools/<tool>.rs`):
140///
141/// ```ignore
142/// register_mcp_tool!(
143/// name: "apr.foo",
144/// definition: foo_tool_definition,
145/// dispatch: dispatch,
146/// );
147/// ```
148///
149/// The `dispatch` argument MUST point at a function with signature
150/// [`DispatchFn`] — typically a thin shim that calls the tool's
151/// existing `call` / `call_with_sink`.
152#[macro_export]
153macro_rules! register_mcp_tool {
154 (name: $name:expr, definition: $def:path, dispatch: $dispatch:path $(,)?) => {
155 ::inventory::submit! {
156 $crate::tools::registry::McpToolEntry {
157 name: $name,
158 definition_fn: $def,
159 dispatch_fn: $dispatch,
160 }
161 }
162 };
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 /// Sanity: building the index from the live inventory does not panic
170 /// and yields the 9 Phase-1 tool names in alphabetical order.
171 #[test]
172 fn live_inventory_yields_phase_one_tool_set() {
173 let index = ToolIndex::from_inventory();
174 let names = index.names();
175 let expected = [
176 "apr.bench",
177 "apr.finetune",
178 "apr.qa",
179 "apr.run",
180 "apr.serve",
181 "apr.tensors",
182 "apr.trace",
183 "apr.validate",
184 "apr.version",
185 ];
186 assert_eq!(names, expected);
187 assert_eq!(index.definitions().len(), 9);
188 }
189
190 #[test]
191 fn dispatch_for_known_tool_returns_some() {
192 let index = ToolIndex::from_inventory();
193 assert!(index.dispatch_for("apr.version").is_some());
194 assert!(index.dispatch_for("apr.qa").is_some());
195 }
196
197 #[test]
198 fn dispatch_for_unknown_tool_returns_none() {
199 let index = ToolIndex::from_inventory();
200 assert!(index.dispatch_for("apr.never").is_none());
201 }
202}