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}