1use 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
81pub type SharedDiscoveryIndex = Arc<RwLock<DiscoveryRegistry>>;
83
84pub async fn build_index(proxy: &mut McpProxy, separator: &str) -> SharedDiscoveryIndex {
89 build_index_with_schemas(proxy, separator).await.0
90}
91
92pub 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
133pub async fn reindex(index: &SharedDiscoveryIndex, proxy: &mut McpProxy, separator: &str) {
138 reindex_with_schemas(index, &SchemaStore::new(), proxy, separator).await;
139}
140
141pub 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
182fn index_tools(registry: &mut DiscoveryRegistry, tools: &[ToolDefinition], separator: &str) {
187 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
221fn tool_definition_to_spec(tool: &ToolDefinition, separator: &str) -> ToolSpec {
223 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 let params = extract_params(&tool.input_schema);
232
233 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 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
273fn 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#[derive(Debug, Deserialize, JsonSchema)]
305struct SearchInput {
306 query: String,
308 #[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: String,
321}
322
323#[derive(Debug, Deserialize, JsonSchema)]
324struct SimilarInput {
325 tool_id: String,
327 #[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#[derive(Clone, Default)]
369pub struct SchemaStore(Arc<RwLock<std::collections::HashMap<String, ToolDefinition>>>);
370
371impl SchemaStore {
372 pub fn new() -> Self {
374 Self::default()
375 }
376 pub async fn insert(&self, id: String, tool: ToolDefinition) {
378 self.0.write().await.insert(id, tool);
379 }
380 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
394pub 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
401pub 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 #[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 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 let results = registry.query("destructive", 10);
599 assert!(!results.is_empty());
600 }
601
602 #[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 #[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 #[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}