The deferred MCP tools section in the system prompt only listed tool names inside <available-deferred-tools> tags without any instruction telling the LLM to call tool_search to activate them. In daemon and Telegram mode, where conversations are shorter and less guided, the LLM never discovered it should call tool_search, so deferred tools were effectively unavailable. Add a "## Deferred Tools" heading with explicit instructions that the LLM MUST call tool_search before using any listed tool. This ensures the LLM knows to activate deferred tools in all modes (CLI, daemon, Telegram) consistently. Also add tests covering: - Instruction presence in the deferred section - Multiple-server deferred tool search - Cross-server keyword search ranking - Activation persistence across multiple tool_search calls - Idempotent re-activation
369 lines
12 KiB
Rust
369 lines
12 KiB
Rust
//! Built-in `tool_search` tool for on-demand MCP tool schema loading.
|
|
//!
|
|
//! When `mcp.deferred_loading` is enabled, this tool lets the LLM discover and
|
|
//! activate deferred MCP tools. Supports two query modes:
|
|
//! - `select:name1,name2` — fetch exact tools by prefixed name.
|
|
//! - Free-text keyword search — returns the best-matching stubs.
|
|
|
|
use std::fmt::Write;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use crate::tools::mcp_deferred::{ActivatedToolSet, DeferredMcpToolSet};
|
|
use crate::tools::traits::{Tool, ToolResult};
|
|
|
|
/// Default maximum number of search results.
|
|
const DEFAULT_MAX_RESULTS: usize = 5;
|
|
|
|
/// Built-in tool that fetches full schemas for deferred MCP tools.
|
|
pub struct ToolSearchTool {
|
|
deferred: DeferredMcpToolSet,
|
|
activated: Arc<Mutex<ActivatedToolSet>>,
|
|
}
|
|
|
|
impl ToolSearchTool {
|
|
pub fn new(deferred: DeferredMcpToolSet, activated: Arc<Mutex<ActivatedToolSet>>) -> Self {
|
|
Self {
|
|
deferred,
|
|
activated,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for ToolSearchTool {
|
|
fn name(&self) -> &str {
|
|
"tool_search"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Fetch full schema definitions for deferred MCP tools so they can be called. \
|
|
Use \"select:name1,name2\" for exact match or keywords to search."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {
|
|
"description": "Query to find deferred tools. Use \"select:<tool_name>\" for direct selection, or keywords to search.",
|
|
"type": "string"
|
|
},
|
|
"max_results": {
|
|
"description": "Maximum number of results to return (default: 5)",
|
|
"type": "number",
|
|
"default": DEFAULT_MAX_RESULTS
|
|
}
|
|
},
|
|
"required": ["query"]
|
|
})
|
|
}
|
|
|
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
let query = args
|
|
.get("query")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or_default()
|
|
.trim();
|
|
|
|
let max_results = args
|
|
.get("max_results")
|
|
.and_then(|v| v.as_u64())
|
|
.map(|v| usize::try_from(v).unwrap_or(DEFAULT_MAX_RESULTS))
|
|
.unwrap_or(DEFAULT_MAX_RESULTS);
|
|
|
|
if query.is_empty() {
|
|
return Ok(ToolResult {
|
|
success: false,
|
|
output: String::new(),
|
|
error: Some("query parameter is required".into()),
|
|
});
|
|
}
|
|
|
|
// Parse query mode
|
|
if let Some(names_str) = query.strip_prefix("select:") {
|
|
// Exact selection mode
|
|
let names: Vec<&str> = names_str.split(',').map(str::trim).collect();
|
|
return self.select_tools(&names);
|
|
}
|
|
|
|
// Keyword search mode
|
|
let results = self.deferred.search(query, max_results);
|
|
if results.is_empty() {
|
|
return Ok(ToolResult {
|
|
success: true,
|
|
output: "No matching deferred tools found.".into(),
|
|
error: None,
|
|
});
|
|
}
|
|
|
|
// Activate and return full specs
|
|
let mut output = String::from("<functions>\n");
|
|
let mut activated_count = 0;
|
|
let mut guard = self.activated.lock().unwrap();
|
|
|
|
for stub in &results {
|
|
if let Some(spec) = self.deferred.tool_spec(&stub.prefixed_name) {
|
|
if !guard.is_activated(&stub.prefixed_name) {
|
|
if let Some(tool) = self.deferred.activate(&stub.prefixed_name) {
|
|
guard.activate(stub.prefixed_name.clone(), Arc::from(tool));
|
|
activated_count += 1;
|
|
}
|
|
}
|
|
let _ = writeln!(
|
|
output,
|
|
"<function>{{\"name\": \"{}\", \"description\": \"{}\", \"parameters\": {}}}</function>",
|
|
spec.name,
|
|
spec.description.replace('"', "\\\""),
|
|
spec.parameters
|
|
);
|
|
}
|
|
}
|
|
|
|
output.push_str("</functions>\n");
|
|
drop(guard);
|
|
|
|
tracing::debug!(
|
|
"tool_search: query={query:?}, matched={}, activated={activated_count}",
|
|
results.len()
|
|
);
|
|
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output,
|
|
error: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ToolSearchTool {
|
|
fn select_tools(&self, names: &[&str]) -> anyhow::Result<ToolResult> {
|
|
let mut output = String::from("<functions>\n");
|
|
let mut not_found = Vec::new();
|
|
let mut activated_count = 0;
|
|
let mut guard = self.activated.lock().unwrap();
|
|
|
|
for name in names {
|
|
if name.is_empty() {
|
|
continue;
|
|
}
|
|
match self.deferred.tool_spec(name) {
|
|
Some(spec) => {
|
|
if !guard.is_activated(name) {
|
|
if let Some(tool) = self.deferred.activate(name) {
|
|
guard.activate(name.to_string(), Arc::from(tool));
|
|
activated_count += 1;
|
|
}
|
|
}
|
|
let _ = writeln!(
|
|
output,
|
|
"<function>{{\"name\": \"{}\", \"description\": \"{}\", \"parameters\": {}}}</function>",
|
|
spec.name,
|
|
spec.description.replace('"', "\\\""),
|
|
spec.parameters
|
|
);
|
|
}
|
|
None => {
|
|
not_found.push(*name);
|
|
}
|
|
}
|
|
}
|
|
|
|
output.push_str("</functions>\n");
|
|
drop(guard);
|
|
|
|
if !not_found.is_empty() {
|
|
let _ = write!(output, "\nNot found: {}", not_found.join(", "));
|
|
}
|
|
|
|
tracing::debug!(
|
|
"tool_search select: requested={}, activated={activated_count}, not_found={}",
|
|
names.len(),
|
|
not_found.len()
|
|
);
|
|
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output,
|
|
error: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::tools::mcp_client::McpRegistry;
|
|
use crate::tools::mcp_deferred::DeferredMcpToolStub;
|
|
use crate::tools::mcp_protocol::McpToolDef;
|
|
|
|
async fn make_deferred_set(stubs: Vec<DeferredMcpToolStub>) -> DeferredMcpToolSet {
|
|
let registry = Arc::new(McpRegistry::connect_all(&[]).await.unwrap());
|
|
DeferredMcpToolSet { stubs, registry }
|
|
}
|
|
|
|
fn make_stub(name: &str, desc: &str) -> DeferredMcpToolStub {
|
|
let def = McpToolDef {
|
|
name: name.to_string(),
|
|
description: Some(desc.to_string()),
|
|
input_schema: serde_json::json!({"type": "object", "properties": {}}),
|
|
};
|
|
DeferredMcpToolStub::new(name.to_string(), def)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_metadata() {
|
|
let tool = ToolSearchTool::new(
|
|
make_deferred_set(vec![]).await,
|
|
Arc::new(Mutex::new(ActivatedToolSet::new())),
|
|
);
|
|
assert_eq!(tool.name(), "tool_search");
|
|
assert!(!tool.description().is_empty());
|
|
assert!(tool.parameters_schema()["properties"]["query"].is_object());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_query_returns_error() {
|
|
let tool = ToolSearchTool::new(
|
|
make_deferred_set(vec![]).await,
|
|
Arc::new(Mutex::new(ActivatedToolSet::new())),
|
|
);
|
|
let result = tool
|
|
.execute(serde_json::json!({"query": ""}))
|
|
.await
|
|
.unwrap();
|
|
assert!(!result.success);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn select_nonexistent_tool_reports_not_found() {
|
|
let tool = ToolSearchTool::new(
|
|
make_deferred_set(vec![]).await,
|
|
Arc::new(Mutex::new(ActivatedToolSet::new())),
|
|
);
|
|
let result = tool
|
|
.execute(serde_json::json!({"query": "select:nonexistent"}))
|
|
.await
|
|
.unwrap();
|
|
assert!(result.success);
|
|
assert!(result.output.contains("Not found"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn keyword_search_no_matches() {
|
|
let tool = ToolSearchTool::new(
|
|
make_deferred_set(vec![make_stub("fs__read", "Read file")]).await,
|
|
Arc::new(Mutex::new(ActivatedToolSet::new())),
|
|
);
|
|
let result = tool
|
|
.execute(serde_json::json!({"query": "zzzzz_nonexistent"}))
|
|
.await
|
|
.unwrap();
|
|
assert!(result.success);
|
|
assert!(result.output.contains("No matching"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn keyword_search_finds_match() {
|
|
let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
|
|
let tool = ToolSearchTool::new(
|
|
make_deferred_set(vec![make_stub("fs__read", "Read a file from disk")]).await,
|
|
Arc::clone(&activated),
|
|
);
|
|
let result = tool
|
|
.execute(serde_json::json!({"query": "read file"}))
|
|
.await
|
|
.unwrap();
|
|
assert!(result.success);
|
|
assert!(result.output.contains("<function>"));
|
|
assert!(result.output.contains("fs__read"));
|
|
// Tool should now be activated
|
|
assert!(activated.lock().unwrap().is_activated("fs__read"));
|
|
}
|
|
|
|
/// Verify tool_search works with stubs from multiple MCP servers,
|
|
/// simulating a daemon-mode setup where several servers are deferred.
|
|
#[tokio::test]
|
|
async fn multiple_servers_stubs_all_searchable() {
|
|
let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
|
|
let stubs = vec![
|
|
make_stub("server_a__list_files", "List files on server A"),
|
|
make_stub("server_a__read_file", "Read file on server A"),
|
|
make_stub("server_b__query_db", "Query database on server B"),
|
|
make_stub("server_b__insert_row", "Insert row on server B"),
|
|
];
|
|
let tool = ToolSearchTool::new(make_deferred_set(stubs).await, Arc::clone(&activated));
|
|
|
|
// Search should find tools across both servers
|
|
let result = tool
|
|
.execute(serde_json::json!({"query": "file"}))
|
|
.await
|
|
.unwrap();
|
|
assert!(result.success);
|
|
assert!(result.output.contains("server_a__list_files"));
|
|
assert!(result.output.contains("server_a__read_file"));
|
|
|
|
// Server B tools should also be searchable
|
|
let result = tool
|
|
.execute(serde_json::json!({"query": "database query"}))
|
|
.await
|
|
.unwrap();
|
|
assert!(result.success);
|
|
assert!(result.output.contains("server_b__query_db"));
|
|
}
|
|
|
|
/// Verify select mode activates tools and they stay activated across calls,
|
|
/// matching the daemon-mode pattern where a single ActivatedToolSet persists.
|
|
#[tokio::test]
|
|
async fn select_activates_and_persists_across_calls() {
|
|
let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
|
|
let stubs = vec![
|
|
make_stub("srv__tool_a", "Tool A"),
|
|
make_stub("srv__tool_b", "Tool B"),
|
|
];
|
|
let tool = ToolSearchTool::new(make_deferred_set(stubs).await, Arc::clone(&activated));
|
|
|
|
// Activate tool_a
|
|
let result = tool
|
|
.execute(serde_json::json!({"query": "select:srv__tool_a"}))
|
|
.await
|
|
.unwrap();
|
|
assert!(result.success);
|
|
assert!(activated.lock().unwrap().is_activated("srv__tool_a"));
|
|
assert!(!activated.lock().unwrap().is_activated("srv__tool_b"));
|
|
|
|
// Activate tool_b in a separate call
|
|
let result = tool
|
|
.execute(serde_json::json!({"query": "select:srv__tool_b"}))
|
|
.await
|
|
.unwrap();
|
|
assert!(result.success);
|
|
|
|
// Both should remain activated
|
|
let guard = activated.lock().unwrap();
|
|
assert!(guard.is_activated("srv__tool_a"));
|
|
assert!(guard.is_activated("srv__tool_b"));
|
|
assert_eq!(guard.tool_specs().len(), 2);
|
|
}
|
|
|
|
/// Verify re-activating an already-activated tool does not duplicate it.
|
|
#[tokio::test]
|
|
async fn reactivation_is_idempotent() {
|
|
let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
|
|
let tool = ToolSearchTool::new(
|
|
make_deferred_set(vec![make_stub("srv__tool", "A tool")]).await,
|
|
Arc::clone(&activated),
|
|
);
|
|
|
|
tool.execute(serde_json::json!({"query": "select:srv__tool"}))
|
|
.await
|
|
.unwrap();
|
|
tool.execute(serde_json::json!({"query": "select:srv__tool"}))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(activated.lock().unwrap().tool_specs().len(), 1);
|
|
}
|
|
}
|