gglib_core/domain/mcp/
tool_index.rs

1//! In-memory index for progressive tool disclosure.
2//!
3//! # Problem
4//!
5//! Dumping the full JSON schema of every MCP tool to an external client (e.g.
6//! VS Code Copilot) on every `tools/list` call costs 100 k+ tokens at 100+
7//! tools and causes context-window timeouts.  The Progressive Disclosure
8//! pattern fixes this by exposing three meta-tools instead of the full
9//! registry:
10//!
11//! | Meta-tool         | Purpose                                        |
12//! |-------------------|------------------------------------------------|
13//! | `search_tools`    | Keyword search; returns lightweight summaries  |
14//! | `get_tool_schema` | Lazily fetches one tool's full JSON schema     |
15//! | `invoke_tool`     | Forwards execution to the upstream MCP server  |
16//!
17//! # Design
18//!
19//! `ToolIndex` is a pure-data, allocation-once structure built from the
20//! complete `McpTool` list that the `McpService` already holds in memory.
21//! It lives in `gglib-core` so that every frontend (CLI, Axum, Tauri) can
22//! share the same type without depending on `gglib-mcp`.
23//!
24//! The index intentionally does **not** cache itself inside `AppState`.
25//! Rebuilding from the in-memory `McpService` on each `search_tools` or
26//! `get_tool_schema` call is microseconds and automatically reflects MCP
27//! server start / stop events.
28
29use std::collections::HashMap;
30
31use serde::{Deserialize, Serialize};
32
33use super::McpTool;
34
35/// Maximum number of [`ToolSummary`] entries returned by a single
36/// [`ToolIndex::search`] call.
37///
38/// This cap exists to prevent "blank conditioning" — a failure mode observed
39/// when an LLM receives more candidate tools than it can effectively
40/// discriminate between.  Searches that match more than this number of tools
41/// should be refined with a more specific query.
42pub const SEARCH_RESULTS_CAP: usize = 30;
43
44// ─── ToolSummary ─────────────────────────────────────────────────────────────
45
46/// A lightweight, schema-free description of a single MCP tool.
47///
48/// Returned by [`ToolIndex::search`] so that an external client can discover
49/// what tools exist without receiving every tool's full JSON input schema.
50/// Once the client has identified the specific tool it needs, it calls
51/// `get_tool_schema` with the [`tool_id`](ToolSummary::tool_id) to retrieve
52/// the full schema lazily.
53///
54/// # Naming convention
55///
56/// `tool_id` uses the double-underscore qualified format
57/// `"<server_name>__<tool_name>"` so that the ID is both human-readable and
58/// usable as the `tool_id` argument to `get_tool_schema` and `invoke_tool`
59/// without any further look-up.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ToolSummary {
62    /// Qualified tool identifier: `"<server_name>__<tool_name>"`.
63    ///
64    /// Pass this string directly to `get_tool_schema` or `invoke_tool`.
65    pub tool_id: String,
66
67    /// One-line human-readable description, or an empty string if the
68    /// upstream MCP server did not provide one.
69    pub description: String,
70}
71
72// ─── ToolIndex ───────────────────────────────────────────────────────────────
73
74/// An in-memory index over all tools from all running MCP servers.
75///
76/// Build one via [`ToolIndex::from_tools`], then use [`search`] and
77/// [`get_schema`] to serve progressive-disclosure meta-tool calls without
78/// exposing the full registry to external clients.
79///
80/// [`search`]: ToolIndex::search
81/// [`get_schema`]: ToolIndex::get_schema
82#[derive(Debug, Default)]
83pub struct ToolIndex {
84    /// Keyed by `"<server_name>__<tool_name>"`.
85    by_id: HashMap<String, McpTool>,
86}
87
88impl ToolIndex {
89    /// Build a `ToolIndex` from an iterator of `(qualified_id, tool)` pairs.
90    ///
91    /// The caller is responsible for constructing `qualified_id` in the format
92    /// `"<server_name>__<tool_name>"`.  See [`build_tool_index`] in
93    /// `gglib-proxy` for the canonical construction path.
94    ///
95    /// Duplicate IDs are silently overwritten by the last occurrence (mirrors
96    /// the behaviour of the MCP server registry, which does not permit two
97    /// servers with the same name to be active simultaneously).
98    pub fn from_tools(iter: impl IntoIterator<Item = (String, McpTool)>) -> Self {
99        Self {
100            by_id: iter.into_iter().collect(),
101        }
102    }
103
104    /// Search the index by keyword and return at most [`SEARCH_RESULTS_CAP`]
105    /// lightweight [`ToolSummary`] entries.
106    ///
107    /// # Matching
108    ///
109    /// Both the `tool_id` and the tool's `description` field are searched
110    /// using case-insensitive substring matching.  A tool is included if the
111    /// lowercased `query` appears anywhere in either field.
112    ///
113    /// An **empty `query`** returns the first `SEARCH_RESULTS_CAP` tools in
114    /// an unspecified (but deterministic within a single process run) order —
115    /// useful for an LLM that wants a broad overview before deciding what to
116    /// fetch.
117    ///
118    /// # Hard cap
119    ///
120    /// At most [`SEARCH_RESULTS_CAP`] results are returned regardless of how
121    /// many tools match.  This is intentional: searches that return too many
122    /// results should be narrowed with a more specific keyword.
123    pub fn search(&self, query: &str) -> Vec<ToolSummary> {
124        let q = query.to_lowercase();
125        self.by_id
126            .iter()
127            .filter(|(id, tool)| {
128                if q.is_empty() {
129                    return true;
130                }
131                let desc = tool.description.as_deref().unwrap_or("").to_lowercase();
132                id.to_lowercase().contains(&q) || desc.contains(&q)
133            })
134            .take(SEARCH_RESULTS_CAP)
135            .map(|(id, tool)| ToolSummary {
136                tool_id: id.clone(),
137                description: tool.description.clone().unwrap_or_default(),
138            })
139            .collect()
140    }
141
142    /// Retrieve the full JSON input schema for a single tool by its qualified
143    /// `tool_id`.
144    ///
145    /// Returns `None` when no tool with that ID exists in the index, or when
146    /// the tool exists but its upstream server did not expose a schema.
147    ///
148    /// # Token cost
149    ///
150    /// Calling this for a single tool is the central efficiency gain of the
151    /// Progressive Disclosure pattern — the client pays only for the one
152    /// schema it actually needs, not for all schemas in the registry.
153    pub fn get_schema(&self, tool_id: &str) -> Option<&serde_json::Value> {
154        self.by_id
155            .get(tool_id)
156            .and_then(|t| t.input_schema.as_ref())
157    }
158
159    /// Returns `true` if the index contains a tool with the given `tool_id`.
160    ///
161    /// Used by `invoke_tool` to validate the ID before attempting resolution
162    /// against the live MCP service.
163    pub fn contains(&self, tool_id: &str) -> bool {
164        self.by_id.contains_key(tool_id)
165    }
166
167    /// Total number of tools in the index across all MCP servers.
168    pub fn len(&self) -> usize {
169        self.by_id.len()
170    }
171
172    /// Returns `true` if no tools are currently indexed (no MCP servers
173    /// running or all servers have zero tools).
174    pub fn is_empty(&self) -> bool {
175        self.by_id.is_empty()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use serde_json::json;
182
183    use super::*;
184
185    fn make_tool(description: Option<&str>, schema: Option<serde_json::Value>) -> McpTool {
186        let mut t = McpTool::new("dummy");
187        if let Some(d) = description {
188            t = t.with_description(d);
189        }
190        if let Some(s) = schema {
191            t = t.with_input_schema(s);
192        }
193        t
194    }
195
196    fn sample_index() -> ToolIndex {
197        ToolIndex::from_tools([
198            (
199                "files__read_file".to_string(),
200                make_tool(
201                    Some("Read the contents of a file"),
202                    Some(json!({"type": "object", "properties": {"path": {"type": "string"}}})),
203                ),
204            ),
205            (
206                "files__write_file".to_string(),
207                make_tool(Some("Write text to a file"), None),
208            ),
209            (
210                "search__web_search".to_string(),
211                make_tool(Some("Search the web"), Some(json!({}))),
212            ),
213            ("no_desc__tool".to_string(), make_tool(None, None)),
214        ])
215    }
216
217    #[test]
218    fn search_by_keyword_in_id() {
219        let idx = sample_index();
220        let results = idx.search("web");
221        assert_eq!(results.len(), 1);
222        assert_eq!(results[0].tool_id, "search__web_search");
223    }
224
225    #[test]
226    fn search_by_keyword_in_description() {
227        let idx = sample_index();
228        let results = idx.search("contents");
229        assert_eq!(results.len(), 1);
230        assert_eq!(results[0].tool_id, "files__read_file");
231    }
232
233    #[test]
234    fn search_case_insensitive() {
235        let idx = sample_index();
236        let results = idx.search("FILE");
237        // matches "files__read_file", "files__write_file" (id), and "Read the contents of a FILE" (desc)
238        assert!(!results.is_empty());
239        assert!(results.iter().any(|r| r.tool_id == "files__read_file"));
240    }
241
242    #[test]
243    fn empty_query_returns_all_up_to_cap() {
244        let idx = sample_index();
245        let results = idx.search("");
246        assert_eq!(results.len(), 4);
247    }
248
249    #[test]
250    fn search_respects_cap() {
251        // Build an index with SEARCH_RESULTS_CAP + 5 tools.
252        let tools: Vec<(String, McpTool)> = (0..SEARCH_RESULTS_CAP + 5)
253            .map(|i| (format!("srv__tool_{i}"), make_tool(Some("match"), None)))
254            .collect();
255        let idx = ToolIndex::from_tools(tools);
256        let results = idx.search("match");
257        assert_eq!(results.len(), SEARCH_RESULTS_CAP);
258    }
259
260    #[test]
261    fn get_schema_returns_schema() {
262        let idx = sample_index();
263        let schema = idx.get_schema("files__read_file");
264        assert!(schema.is_some());
265    }
266
267    #[test]
268    fn get_schema_missing_tool_returns_none() {
269        let idx = sample_index();
270        assert!(idx.get_schema("nonexistent__tool").is_none());
271    }
272
273    #[test]
274    fn get_schema_tool_without_schema_returns_none() {
275        let idx = sample_index();
276        assert!(idx.get_schema("files__write_file").is_none());
277    }
278
279    #[test]
280    fn contains_and_len() {
281        let idx = sample_index();
282        assert!(idx.contains("files__read_file"));
283        assert!(!idx.contains("nothing__here"));
284        assert_eq!(idx.len(), 4);
285        assert!(!idx.is_empty());
286    }
287
288    #[test]
289    fn empty_index_is_empty() {
290        let idx = ToolIndex::default();
291        assert!(idx.is_empty());
292        assert_eq!(idx.len(), 0);
293        assert!(idx.search("anything").is_empty());
294        assert!(idx.get_schema("anything").is_none());
295    }
296}