Skip to main content

mcp_proxy/
discovery.rs

1//! BM25 full-text tool discovery and search.
2//!
3//! When a proxy aggregates many backends, clients (and the LLMs behind them)
4//! need a way to find relevant tools without scanning a long flat list. This
5//! module builds a BM25 search index over all registered tools using
6//! [`jpx-engine`](jpx_engine) and exposes four discovery tools under the
7//! `proxy/` namespace:
8//!
9//! | Tool | Description |
10//! |---|---|
11//! | `proxy/search_tools` | Full-text search across tool names, descriptions, parameters, and tags |
12//! | `proxy/get_tool` | Retrieve a tool description and complete input schema by search result ID |
13//! | `proxy/similar_tools` | Find tools related to a given tool by BM25 term similarity |
14//! | `proxy/tool_categories` | Browse tools grouped by backend namespace with counts |
15//!
16//! # Enabling discovery
17//!
18//! Set `tool_discovery = true` in the `[proxy]` section:
19//!
20//! ```toml
21//! [proxy]
22//! name = "my-proxy"
23//! tool_discovery = true
24//!
25//! [proxy.listen]
26//! # ...
27//! ```
28//!
29//! The four `proxy/` discovery tools are added to the proxy's tool list
30//! alongside the backend tools.
31//!
32//! # Search mode (`tool_exposure = "search"`)
33//!
34//! For proxies aggregating a large number of tools (100+), listing every tool
35//! in `tools/list` responses can overwhelm LLM context windows. Setting
36//! `tool_exposure = "search"` hides individual backend tools from listings
37//! while keeping them invokable. Only the `proxy/` meta-tools appear in
38//! `tools/list`; clients use `proxy/search_tools` to discover and then call
39//! backend tools by name. Use `proxy/get_tool` with a search result ID to inspect
40//! its input schema before calling it.
41//!
42//! ```toml
43//! [proxy]
44//! name = "my-proxy"
45//! tool_exposure = "search"
46//! # tool_discovery is implied when tool_exposure = "search"
47//! ```
48//!
49//! # Indexing architecture
50//!
51//! At startup, [`build_index`] sends a `ListTools` request through the proxy
52//! to collect all registered tools, groups them by backend namespace (derived
53//! from the configured separator), and registers each group as a
54//! [`DiscoverySpec`] in a
55//! [`DiscoveryRegistry`]. Tool annotations
56//! (destructive, read-only, idempotent, open-world) are extracted as
57//! searchable tags.
58//!
59//! The index is stored as a [`SharedDiscoveryIndex`] (`Arc<RwLock<DiscoveryRegistry>>`)
60//! for concurrent read access from tool handlers.
61//!
62//! # Hot reload re-indexing
63//!
64//! When the proxy configuration is hot-reloaded (backends added, removed, or
65//! updated), [`reindex`] rebuilds the search index from scratch with the new
66//! tool set. This keeps search results consistent with the proxy's current
67//! state without requiring a restart.
68
69use std::sync::Arc;
70
71use jpx_engine::{
72    CategorySummary, DiscoveryRegistry, DiscoverySpec, ParamSpec, ServerInfo, ToolQueryResult,
73    ToolSpec,
74};
75use schemars::JsonSchema;
76use serde::{Deserialize, Serialize};
77use tokio::sync::RwLock;
78use tower_mcp::proxy::McpProxy;
79use tower_mcp::{CallToolResult, NoParams, ToolBuilder, ToolDefinition};
80
81/// Shared discovery index, wrapped for concurrent access from tool handlers.
82pub type SharedDiscoveryIndex = Arc<RwLock<DiscoveryRegistry>>;
83
84/// Build a discovery index from the proxy's current tool list.
85///
86/// Sends a `ListTools` request through the proxy to collect all registered
87/// tools, then indexes them using jpx-engine's BM25 search.
88pub async fn build_index(proxy: &mut McpProxy, separator: &str) -> SharedDiscoveryIndex {
89    build_index_with_schemas(proxy, separator).await.0
90}
91
92/// Build a discovery index and retain tool definitions for `proxy/get_tool`.
93pub async fn build_index_with_schemas(
94    proxy: &mut McpProxy,
95    separator: &str,
96) -> (SharedDiscoveryIndex, SchemaStore) {
97    use tower::Service;
98    use tower_mcp::protocol::{ListToolsParams, McpRequest, McpResponse, RequestId};
99    use tower_mcp::router::{Extensions, RouterRequest};
100
101    let req = RouterRequest {
102        id: RequestId::Number(0),
103        inner: McpRequest::ListTools(ListToolsParams::default()),
104        extensions: Extensions::new(),
105    };
106
107    let tools = match proxy.call(req).await {
108        Ok(resp) => match resp.inner {
109            Ok(McpResponse::ListTools(result)) => result.tools,
110            _ => {
111                tracing::warn!("Failed to list tools for discovery indexing");
112                vec![]
113            }
114        },
115        Err(_) => vec![],
116    };
117
118    let mut registry = DiscoveryRegistry::new();
119    index_tools(&mut registry, &tools, separator);
120
121    let schemas = SchemaStore::new();
122    for tool in &tools {
123        schemas
124            .insert(discovery_id(tool, separator), tool.clone())
125            .await;
126    }
127
128    tracing::info!(tools_indexed = tools.len(), "Built tool discovery index");
129
130    (Arc::new(RwLock::new(registry)), schemas)
131}
132
133/// Re-index all tools into an existing shared discovery index.
134///
135/// Called after hot reload adds, removes, or replaces backends to keep
136/// the search index in sync with the proxy's current tool set.
137pub async fn reindex(index: &SharedDiscoveryIndex, proxy: &mut McpProxy, separator: &str) {
138    reindex_with_schemas(index, &SchemaStore::new(), proxy, separator).await;
139}
140
141/// Refresh both discovery search results and retained tool definitions.
142pub async fn reindex_with_schemas(
143    index: &SharedDiscoveryIndex,
144    schemas: &SchemaStore,
145    proxy: &mut McpProxy,
146    separator: &str,
147) {
148    use tower::Service;
149    use tower_mcp::protocol::{ListToolsParams, McpRequest, McpResponse, RequestId};
150    use tower_mcp::router::{Extensions, RouterRequest};
151
152    let req = RouterRequest {
153        id: RequestId::Number(0),
154        inner: McpRequest::ListTools(ListToolsParams::default()),
155        extensions: Extensions::new(),
156    };
157
158    let tools = match proxy.call(req).await {
159        Ok(resp) => match resp.inner {
160            Ok(McpResponse::ListTools(result)) => result.tools,
161            _ => vec![],
162        },
163        Err(_) => vec![],
164    };
165
166    let mut registry = DiscoveryRegistry::new();
167    index_tools(&mut registry, &tools, separator);
168    {
169        let mut store = schemas.0.write().await;
170        store.clear();
171        for tool in &tools {
172            store.insert(discovery_id(tool, separator), tool.clone());
173        }
174    }
175
176    let mut guard = index.write().await;
177    *guard = registry;
178
179    tracing::info!(tools_indexed = tools.len(), "Re-indexed tool discovery");
180}
181
182/// Index MCP tool definitions into the discovery registry.
183///
184/// Groups tools by backend namespace (derived from the separator) and registers
185/// each group as a discovery "server" with its tools.
186fn index_tools(registry: &mut DiscoveryRegistry, tools: &[ToolDefinition], separator: &str) {
187    // Group tools by backend namespace
188    let mut by_namespace: std::collections::HashMap<String, Vec<&ToolDefinition>> =
189        std::collections::HashMap::new();
190
191    for tool in tools {
192        let namespace = tool
193            .name
194            .split_once(separator)
195            .map(|(ns, _)| ns.to_string())
196            .unwrap_or_else(|| "default".to_string());
197        by_namespace.entry(namespace).or_default().push(tool);
198    }
199
200    for (namespace, ns_tools) in &by_namespace {
201        let tool_specs: Vec<ToolSpec> = ns_tools
202            .iter()
203            .map(|t| tool_definition_to_spec(t, separator))
204            .collect();
205
206        let spec = DiscoverySpec {
207            schema: None,
208            server: ServerInfo {
209                name: namespace.clone(),
210                version: None,
211                description: None,
212            },
213            tools: tool_specs,
214            categories: std::collections::HashMap::new(),
215        };
216
217        registry.register(spec, true);
218    }
219}
220
221/// Convert an MCP ToolDefinition to a jpx ToolSpec for indexing.
222fn tool_definition_to_spec(tool: &ToolDefinition, separator: &str) -> ToolSpec {
223    // Extract the local tool name (without namespace prefix)
224    let local_name = tool
225        .name
226        .split_once(separator)
227        .map(|(_, name)| name.to_string())
228        .unwrap_or_else(|| tool.name.clone());
229
230    // Extract parameter names from input schema
231    let params = extract_params(&tool.input_schema);
232
233    // Extract tags from annotations if available
234    let mut tags = Vec::new();
235    if let Some(annotations) = &tool.annotations {
236        if annotations.destructive_hint {
237            tags.push("destructive".to_string());
238        }
239        if annotations.read_only_hint {
240            tags.push("read-only".to_string());
241        }
242        if annotations.idempotent_hint {
243            tags.push("idempotent".to_string());
244        }
245        if annotations.open_world_hint {
246            tags.push("open-world".to_string());
247        }
248    }
249
250    // Extract category from namespace
251    let category = tool
252        .name
253        .split_once(separator)
254        .map(|(ns, _)| ns.to_string());
255
256    ToolSpec {
257        name: local_name,
258        aliases: vec![],
259        category,
260        subcategory: None,
261        tags,
262        summary: tool.description.clone(),
263        description: tool.description.clone(),
264        params,
265        returns: None,
266        examples: vec![],
267        related: vec![],
268        since: None,
269        stability: None,
270    }
271}
272
273/// Extract parameter specs from a JSON Schema input_schema.
274fn extract_params(schema: &serde_json::Value) -> Vec<ParamSpec> {
275    let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) else {
276        return vec![];
277    };
278    let required: std::collections::HashSet<&str> = schema
279        .get("required")
280        .and_then(|r| r.as_array())
281        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
282        .unwrap_or_default();
283
284    properties
285        .iter()
286        .map(|(name, prop)| ParamSpec {
287            name: name.clone(),
288            param_type: prop.get("type").and_then(|t| t.as_str()).map(String::from),
289            required: required.contains(name.as_str()),
290            description: prop
291                .get("description")
292                .and_then(|d| d.as_str())
293                .map(String::from),
294            enum_values: None,
295            default: None,
296        })
297        .collect()
298}
299
300// ---------------------------------------------------------------------------
301// Discovery tool handlers
302// ---------------------------------------------------------------------------
303
304#[derive(Debug, Deserialize, JsonSchema)]
305struct SearchInput {
306    /// Search query (e.g. "read file", "database query", "math operations")
307    query: String,
308    /// Maximum number of results to return (default: 10)
309    #[serde(default = "default_top_k")]
310    top_k: usize,
311}
312
313fn default_top_k() -> usize {
314    10
315}
316
317#[derive(Debug, Deserialize, JsonSchema)]
318struct GetToolInput {
319    /// Tool ID from a search_tools/similar_tools result (e.g. "signoz:signoz_query_metrics")
320    tool_id: String,
321}
322
323#[derive(Debug, Deserialize, JsonSchema)]
324struct SimilarInput {
325    /// Tool ID to find similar tools for (e.g. "math:add")
326    tool_id: String,
327    /// Maximum number of results to return (default: 5)
328    #[serde(default = "default_similar_k")]
329    top_k: usize,
330}
331
332fn default_similar_k() -> usize {
333    5
334}
335
336#[derive(Serialize)]
337struct SearchResultEntry {
338    id: String,
339    server: String,
340    name: String,
341    description: Option<String>,
342    score: f64,
343    tags: Vec<String>,
344    category: Option<String>,
345}
346
347impl From<ToolQueryResult> for SearchResultEntry {
348    fn from(r: ToolQueryResult) -> Self {
349        Self {
350            id: r.id,
351            server: r.server,
352            name: r.tool.name,
353            description: r.tool.description,
354            score: r.score,
355            tags: r.tool.tags,
356            category: r.tool.category,
357        }
358    }
359}
360
361#[derive(Serialize)]
362struct CategoriesResult {
363    categories: Vec<CategorySummary>,
364    total_categories: usize,
365}
366
367/// Tool definitions keyed by the same `server:tool` ID used by search results.
368#[derive(Clone, Default)]
369pub struct SchemaStore(Arc<RwLock<std::collections::HashMap<String, ToolDefinition>>>);
370
371impl SchemaStore {
372    /// Create an empty tool definition store.
373    pub fn new() -> Self {
374        Self::default()
375    }
376    /// Insert or replace the definition associated with a discovery ID.
377    pub async fn insert(&self, id: String, tool: ToolDefinition) {
378        self.0.write().await.insert(id, tool);
379    }
380    /// Look up a retained tool definition by its discovery ID.
381    pub async fn get(&self, id: &str) -> Option<ToolDefinition> {
382        self.0.read().await.get(id).cloned()
383    }
384}
385
386fn discovery_id(tool: &ToolDefinition, separator: &str) -> String {
387    let (namespace, local) = tool
388        .name
389        .split_once(separator)
390        .unwrap_or(("default", &tool.name));
391    format!("{namespace}:{local}")
392}
393
394/// Build the three search and category tools without schema retrieval.
395pub fn build_discovery_tools(index: SharedDiscoveryIndex) -> Vec<tower_mcp::Tool> {
396    let mut tools = build_discovery_tools_with_schemas(index, SchemaStore::new());
397    tools.retain(|tool| tool.name != "get_tool");
398    tools
399}
400
401/// Build all discovery tools, including retrieval of full tool definitions.
402pub fn build_discovery_tools_with_schemas(
403    index: SharedDiscoveryIndex,
404    schemas: SchemaStore,
405) -> Vec<tower_mcp::Tool> {
406    let index_for_search = Arc::clone(&index);
407    let search_tools = ToolBuilder::new("search_tools")
408        .description(
409            "Search for tools across all backends using BM25 full-text search. \
410             Searches tool names, descriptions, parameters, and tags.",
411        )
412        .handler(move |input: SearchInput| {
413            let idx = Arc::clone(&index_for_search);
414            async move {
415                let registry = idx.read().await;
416                let results = registry.query(&input.query, input.top_k);
417                let entries: Vec<SearchResultEntry> =
418                    results.into_iter().map(SearchResultEntry::from).collect();
419                Ok(CallToolResult::text(
420                    serde_json::to_string_pretty(&entries).unwrap(),
421                ))
422            }
423        })
424        .build();
425
426    let schemas_for_get = schemas.clone();
427    let get_tool = ToolBuilder::new("get_tool")
428        .description(
429            "Get the full input JSON Schema and description for a tool by its id \
430             (e.g. \"signoz:signoz_query_metrics\"). Call this after search_tools \
431             before invoking an unfamiliar tool through proxy/call_tool.",
432        )
433        .handler(move |input: GetToolInput| {
434            let store = schemas_for_get.clone();
435            async move {
436                match store.get(&input.tool_id).await {
437                    Some(tool) => {
438                        let out = serde_json::json!({
439                            "id": input.tool_id,
440                            "name": tool.name,
441                            "description": tool.description,
442                            "input_schema": tool.input_schema,
443                        });
444                        Ok(CallToolResult::text(
445                            serde_json::to_string_pretty(&out).unwrap(),
446                        ))
447                    }
448                    None => Ok(CallToolResult::error(format!(
449                        "Unknown tool id '{}' \u{2014} run proxy/search_tools first and use its \"id\" field",
450                        input.tool_id
451                    ))),
452                }
453            }
454        })
455        .build();
456
457    let index_for_similar = Arc::clone(&index);
458    let similar_tools = ToolBuilder::new("similar_tools")
459        .description(
460            "Find tools similar to a given tool. Uses BM25 similarity based on \
461             shared terms in descriptions, parameters, and tags.",
462        )
463        .handler(move |input: SimilarInput| {
464            let idx = Arc::clone(&index_for_similar);
465            async move {
466                let registry = idx.read().await;
467                let results = registry.similar(&input.tool_id, input.top_k);
468                let entries: Vec<SearchResultEntry> =
469                    results.into_iter().map(SearchResultEntry::from).collect();
470                Ok(CallToolResult::text(
471                    serde_json::to_string_pretty(&entries).unwrap(),
472                ))
473            }
474        })
475        .build();
476
477    let index_for_categories = Arc::clone(&index);
478    let tool_categories = ToolBuilder::new("tool_categories")
479        .description(
480            "List all tool categories (backend namespaces) with tool counts. \
481             Useful for browsing available capabilities by domain.",
482        )
483        .handler(move |_: NoParams| {
484            let idx = Arc::clone(&index_for_categories);
485            async move {
486                let registry = idx.read().await;
487                let categories = registry.list_categories();
488                let mut cats: Vec<CategorySummary> = categories.into_values().collect();
489                cats.sort_by_key(|b| std::cmp::Reverse(b.tool_count));
490                let result = CategoriesResult {
491                    total_categories: cats.len(),
492                    categories: cats,
493                };
494                Ok(CallToolResult::text(
495                    serde_json::to_string_pretty(&result).unwrap(),
496                ))
497            }
498        })
499        .build();
500
501    vec![search_tools, get_tool, similar_tools, tool_categories]
502}
503
504#[cfg(test)]
505mod tests {
506    use jpx_engine::DiscoveryRegistry;
507    use tower_mcp::ToolDefinition;
508    use tower_mcp_types::protocol::ToolAnnotations;
509
510    use super::*;
511
512    fn make_tool(
513        name: &str,
514        description: Option<&str>,
515        annotations: Option<ToolAnnotations>,
516    ) -> ToolDefinition {
517        ToolDefinition {
518            name: name.to_string(),
519            title: None,
520            description: description.map(|d| d.to_string()),
521            input_schema: serde_json::json!({"type": "object"}),
522            output_schema: None,
523            icons: None,
524            annotations,
525            execution: None,
526            meta: None,
527        }
528    }
529
530    // -- index_tools tests ---------------------------------------------------
531
532    #[test]
533    fn index_tools_empty_list() {
534        let mut registry = DiscoveryRegistry::new();
535        index_tools(&mut registry, &[], "/");
536        let cats = registry.list_categories();
537        assert!(cats.is_empty());
538    }
539
540    #[test]
541    fn index_tools_groups_by_namespace() {
542        let tools = vec![
543            make_tool("fs/read", Some("Read a file"), None),
544            make_tool("fs/write", Some("Write a file"), None),
545            make_tool("db/query", Some("Run a query"), None),
546        ];
547
548        let mut registry = DiscoveryRegistry::new();
549        index_tools(&mut registry, &tools, "/");
550
551        let cats = registry.list_categories();
552        assert_eq!(cats.len(), 2);
553        assert!(cats.contains_key("fs"));
554        assert!(cats.contains_key("db"));
555    }
556
557    #[test]
558    fn index_tools_no_separator_uses_default_namespace() {
559        let tools = vec![make_tool("standalone", Some("No namespace"), None)];
560
561        let mut registry = DiscoveryRegistry::new();
562        index_tools(&mut registry, &tools, "/");
563
564        // The tool is registered under the "default" server; search finds it
565        let results = registry.query("namespace", 10);
566        assert!(!results.is_empty());
567    }
568
569    #[test]
570    fn index_tools_without_descriptions() {
571        let tools = vec![make_tool("ns/tool", None, None)];
572
573        let mut registry = DiscoveryRegistry::new();
574        index_tools(&mut registry, &tools, "/");
575
576        let cats = registry.list_categories();
577        assert_eq!(cats.len(), 1);
578    }
579
580    #[test]
581    fn index_tools_with_annotations() {
582        let tools = vec![make_tool(
583            "ns/dangerous",
584            Some("Dangerous tool"),
585            Some(ToolAnnotations {
586                title: None,
587                destructive_hint: true,
588                read_only_hint: false,
589                idempotent_hint: false,
590                open_world_hint: true,
591            }),
592        )];
593
594        let mut registry = DiscoveryRegistry::new();
595        index_tools(&mut registry, &tools, "/");
596
597        // The tool was indexed; search for its annotation tag
598        let results = registry.query("destructive", 10);
599        assert!(!results.is_empty());
600    }
601
602    // -- tool_definition_to_spec tests ----------------------------------------
603
604    #[test]
605    fn tool_definition_to_spec_extracts_local_name() {
606        let tool = make_tool("backend/read_file", Some("Reads files"), None);
607        let spec = tool_definition_to_spec(&tool, "/");
608        assert_eq!(spec.name, "read_file");
609        assert_eq!(spec.category.as_deref(), Some("backend"));
610    }
611
612    #[test]
613    fn tool_definition_to_spec_no_separator() {
614        let tool = make_tool("read_file", Some("Reads files"), None);
615        let spec = tool_definition_to_spec(&tool, "/");
616        assert_eq!(spec.name, "read_file");
617        assert!(spec.category.is_none());
618    }
619
620    #[test]
621    fn tool_definition_to_spec_annotation_tags() {
622        let tool = make_tool(
623            "ns/tool",
624            Some("desc"),
625            Some(ToolAnnotations {
626                title: None,
627                destructive_hint: true,
628                read_only_hint: true,
629                idempotent_hint: true,
630                open_world_hint: true,
631            }),
632        );
633        let spec = tool_definition_to_spec(&tool, "/");
634        assert_eq!(spec.tags.len(), 4);
635        assert!(spec.tags.contains(&"destructive".to_string()));
636        assert!(spec.tags.contains(&"read-only".to_string()));
637        assert!(spec.tags.contains(&"idempotent".to_string()));
638        assert!(spec.tags.contains(&"open-world".to_string()));
639    }
640
641    #[test]
642    fn tool_definition_to_spec_no_annotations_no_tags() {
643        let tool = make_tool("ns/tool", Some("desc"), None);
644        let spec = tool_definition_to_spec(&tool, "/");
645        assert!(spec.tags.is_empty());
646    }
647
648    #[test]
649    fn tool_definition_to_spec_preserves_description() {
650        let tool = make_tool("ns/tool", Some("My description"), None);
651        let spec = tool_definition_to_spec(&tool, "/");
652        assert_eq!(spec.summary.as_deref(), Some("My description"));
653        assert_eq!(spec.description.as_deref(), Some("My description"));
654    }
655
656    // -- extract_params tests ------------------------------------------------
657
658    #[test]
659    fn extract_params_empty_schema() {
660        let schema = serde_json::json!({"type": "object"});
661        let params = extract_params(&schema);
662        assert!(params.is_empty());
663    }
664
665    #[test]
666    fn extract_params_with_properties() {
667        let schema = serde_json::json!({
668            "type": "object",
669            "properties": {
670                "path": {
671                    "type": "string",
672                    "description": "File path"
673                },
674                "recursive": {
675                    "type": "boolean"
676                }
677            },
678            "required": ["path"]
679        });
680        let params = extract_params(&schema);
681        assert_eq!(params.len(), 2);
682
683        let path_param = params.iter().find(|p| p.name == "path").unwrap();
684        assert!(path_param.required);
685        assert_eq!(path_param.param_type.as_deref(), Some("string"));
686        assert_eq!(path_param.description.as_deref(), Some("File path"));
687
688        let recursive_param = params.iter().find(|p| p.name == "recursive").unwrap();
689        assert!(!recursive_param.required);
690        assert_eq!(recursive_param.param_type.as_deref(), Some("boolean"));
691    }
692
693    #[test]
694    fn extract_params_no_required_field() {
695        let schema = serde_json::json!({
696            "type": "object",
697            "properties": {
698                "name": {"type": "string"}
699            }
700        });
701        let params = extract_params(&schema);
702        assert_eq!(params.len(), 1);
703        assert!(!params[0].required);
704    }
705
706    // -- build_discovery_tools tests -----------------------------------------
707
708    #[test]
709    fn build_discovery_tools_returns_three_tools() {
710        let index = Arc::new(RwLock::new(DiscoveryRegistry::new()));
711        let tools = build_discovery_tools(index);
712        assert_eq!(tools.len(), 3);
713        assert_eq!(tools[0].name, "search_tools");
714        assert_eq!(tools[1].name, "similar_tools");
715        assert_eq!(tools[2].name, "tool_categories");
716    }
717}