zeroclaw/src/tools/mcp_deferred.rs
Ricardo Madriz 9c30164aa3
fix(tools) Wire activated toolset into dispatch (#3747)
* fix(tools): wire ActivatedToolSet into tool dispatch and spec advertisement

When deferred MCP tools are activated via tool_search, they are stored
in ActivatedToolSet but never consulted by the tool call loop.
tool_specs is built once before the iteration loop and never refreshed,
so the provider API tools[] parameter never includes activated tools.
find_tool only searches the static registry, so execution dispatch also
fails silently.

Thread Arc<Mutex<ActivatedToolSet>> from creation sites through to
run_tool_call_loop. Rebuild tool_specs each iteration to merge base
registry specs with activated specs. Add fallback in execute_one_tool
to check the activated set when the static registry lookup misses.

Change ActivatedToolSet internal storage from Box<dyn Tool> to
Arc<dyn Tool> so we can clone the Arc out of the mutex guard before
awaiting tool.execute() (std::sync::MutexGuard is not Send).

* fix(tools): add activated_tools field to new ChannelRuntimeContext test site
2026-03-24 15:17:21 +03:00

362 lines
12 KiB
Rust

//! Deferred MCP tool loading — stubs and activated-tool tracking.
//!
//! When `mcp.deferred_loading` is enabled, MCP tool schemas are NOT eagerly
//! included in the LLM context window. Instead, only lightweight stubs (name +
//! description) are exposed in the system prompt. The LLM must call the built-in
//! `tool_search` tool to fetch full schemas, which moves them into the
//! [`ActivatedToolSet`] for the current conversation.
use std::collections::HashMap;
use std::sync::Arc;
use crate::tools::mcp_client::McpRegistry;
use crate::tools::mcp_protocol::McpToolDef;
use crate::tools::mcp_tool::McpToolWrapper;
use crate::tools::traits::{Tool, ToolSpec};
// ── DeferredMcpToolStub ──────────────────────────────────────────────────
/// A lightweight stub representing a known-but-not-yet-loaded MCP tool.
/// Contains only the prefixed name, a human-readable description, and enough
/// information to construct the full [`McpToolWrapper`] on activation.
#[derive(Debug, Clone)]
pub struct DeferredMcpToolStub {
/// Prefixed name: `<server_name>__<tool_name>`.
pub prefixed_name: String,
/// Human-readable description (extracted from the MCP tool definition).
pub description: String,
/// The full tool definition — stored so we can construct a wrapper later.
def: McpToolDef,
}
impl DeferredMcpToolStub {
pub fn new(prefixed_name: String, def: McpToolDef) -> Self {
let description = def
.description
.clone()
.unwrap_or_else(|| "MCP tool".to_string());
Self {
prefixed_name,
description,
def,
}
}
/// Materialize this stub into a live [`McpToolWrapper`].
pub fn activate(&self, registry: Arc<McpRegistry>) -> McpToolWrapper {
McpToolWrapper::new(self.prefixed_name.clone(), self.def.clone(), registry)
}
}
// ── DeferredMcpToolSet ───────────────────────────────────────────────────
/// Collection of all deferred MCP tool stubs discovered at startup.
/// Provides keyword search for `tool_search`.
#[derive(Clone)]
pub struct DeferredMcpToolSet {
/// All stubs — exposed for test construction.
pub stubs: Vec<DeferredMcpToolStub>,
/// Shared registry — exposed for test construction.
pub registry: Arc<McpRegistry>,
}
impl DeferredMcpToolSet {
/// Build the set from a connected [`McpRegistry`].
pub async fn from_registry(registry: Arc<McpRegistry>) -> Self {
let names = registry.tool_names();
let mut stubs = Vec::with_capacity(names.len());
for name in names {
if let Some(def) = registry.get_tool_def(&name).await {
stubs.push(DeferredMcpToolStub::new(name, def));
}
}
Self { stubs, registry }
}
/// All stub names (for rendering in the system prompt).
pub fn stub_names(&self) -> Vec<&str> {
self.stubs
.iter()
.map(|s| s.prefixed_name.as_str())
.collect()
}
/// Number of deferred stubs.
pub fn len(&self) -> usize {
self.stubs.len()
}
/// Whether the set is empty.
pub fn is_empty(&self) -> bool {
self.stubs.is_empty()
}
/// Look up stubs by exact name. Used for `select:name1,name2` queries.
pub fn get_by_name(&self, name: &str) -> Option<&DeferredMcpToolStub> {
self.stubs.iter().find(|s| s.prefixed_name == name)
}
/// Keyword search — returns stubs whose name or description contains any
/// of the query terms (case-insensitive). Results are ranked by number of
/// matching terms (descending).
pub fn search(&self, query: &str, max_results: usize) -> Vec<&DeferredMcpToolStub> {
let terms: Vec<String> = query
.split_whitespace()
.map(|t| t.to_ascii_lowercase())
.collect();
if terms.is_empty() {
return self.stubs.iter().take(max_results).collect();
}
let mut scored: Vec<(&DeferredMcpToolStub, usize)> = self
.stubs
.iter()
.filter_map(|stub| {
let haystack = format!(
"{} {}",
stub.prefixed_name.to_ascii_lowercase(),
stub.description.to_ascii_lowercase()
);
let hits = terms
.iter()
.filter(|t| haystack.contains(t.as_str()))
.count();
if hits > 0 {
Some((stub, hits))
} else {
None
}
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1));
scored
.into_iter()
.take(max_results)
.map(|(s, _)| s)
.collect()
}
/// Activate a stub by name, returning a boxed [`Tool`].
pub fn activate(&self, name: &str) -> Option<Box<dyn Tool>> {
self.get_by_name(name).map(|stub| {
let wrapper = stub.activate(Arc::clone(&self.registry));
Box::new(wrapper) as Box<dyn Tool>
})
}
/// Return the full [`ToolSpec`] for a stub (for inclusion in `tool_search` results).
pub fn tool_spec(&self, name: &str) -> Option<ToolSpec> {
self.get_by_name(name).map(|stub| {
let wrapper = stub.activate(Arc::clone(&self.registry));
wrapper.spec()
})
}
}
// ── ActivatedToolSet ─────────────────────────────────────────────────────
/// Per-conversation mutable state tracking which deferred tools have been
/// activated (i.e. their full schemas have been fetched via `tool_search`).
/// The agent loop consults this each iteration to decide which tool_specs
/// to include in the LLM request.
pub struct ActivatedToolSet {
tools: HashMap<String, Arc<dyn Tool>>,
}
impl ActivatedToolSet {
pub fn new() -> Self {
Self {
tools: HashMap::new(),
}
}
pub fn activate(&mut self, name: String, tool: Arc<dyn Tool>) {
self.tools.insert(name, tool);
}
pub fn is_activated(&self, name: &str) -> bool {
self.tools.contains_key(name)
}
/// Clone the Arc so the caller can drop the mutex guard before awaiting.
pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
self.tools.get(name).cloned()
}
pub fn tool_specs(&self) -> Vec<ToolSpec> {
self.tools.values().map(|t| t.spec()).collect()
}
pub fn tool_names(&self) -> Vec<&str> {
self.tools.keys().map(|s| s.as_str()).collect()
}
}
impl Default for ActivatedToolSet {
fn default() -> Self {
Self::new()
}
}
// ── System prompt helper ─────────────────────────────────────────────────
/// Build the `<available-deferred-tools>` section for the system prompt.
/// Lists only tool names so the LLM knows what is available without
/// consuming context window on full schemas.
pub fn build_deferred_tools_section(deferred: &DeferredMcpToolSet) -> String {
if deferred.is_empty() {
return String::new();
}
let mut out = String::from("<available-deferred-tools>\n");
for stub in &deferred.stubs {
out.push_str(&stub.prefixed_name);
out.push('\n');
}
out.push_str("</available-deferred-tools>\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
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)
}
#[test]
fn stub_uses_description_from_def() {
let stub = make_stub("fs__read", "Read a file");
assert_eq!(stub.description, "Read a file");
}
#[test]
fn stub_defaults_description_when_none() {
let def = McpToolDef {
name: "mystery".into(),
description: None,
input_schema: serde_json::json!({}),
};
let stub = DeferredMcpToolStub::new("srv__mystery".into(), def);
assert_eq!(stub.description, "MCP tool");
}
#[test]
fn activated_set_tracks_activation() {
use crate::tools::traits::ToolResult;
use async_trait::async_trait;
struct FakeTool;
#[async_trait]
impl Tool for FakeTool {
fn name(&self) -> &str {
"fake"
}
fn description(&self) -> &str {
"fake tool"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({})
}
async fn execute(&self, _: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult {
success: true,
output: String::new(),
error: None,
})
}
}
let mut set = ActivatedToolSet::new();
assert!(!set.is_activated("fake"));
set.activate("fake".into(), Arc::new(FakeTool));
assert!(set.is_activated("fake"));
assert!(set.get("fake").is_some());
assert_eq!(set.tool_specs().len(), 1);
}
#[test]
fn build_deferred_section_empty_when_no_stubs() {
let set = DeferredMcpToolSet {
stubs: vec![],
registry: std::sync::Arc::new(
tokio::runtime::Runtime::new()
.unwrap()
.block_on(McpRegistry::connect_all(&[]))
.unwrap(),
),
};
assert!(build_deferred_tools_section(&set).is_empty());
}
#[test]
fn build_deferred_section_lists_names() {
let stubs = vec![
make_stub("fs__read_file", "Read a file"),
make_stub("git__status", "Git status"),
];
let set = DeferredMcpToolSet {
stubs,
registry: std::sync::Arc::new(
tokio::runtime::Runtime::new()
.unwrap()
.block_on(McpRegistry::connect_all(&[]))
.unwrap(),
),
};
let section = build_deferred_tools_section(&set);
assert!(section.contains("<available-deferred-tools>"));
assert!(section.contains("fs__read_file"));
assert!(section.contains("git__status"));
assert!(section.contains("</available-deferred-tools>"));
}
#[test]
fn keyword_search_ranks_by_hits() {
let stubs = vec![
make_stub("fs__read_file", "Read a file from disk"),
make_stub("fs__write_file", "Write a file to disk"),
make_stub("git__log", "Show git log"),
];
let set = DeferredMcpToolSet {
stubs,
registry: std::sync::Arc::new(
tokio::runtime::Runtime::new()
.unwrap()
.block_on(McpRegistry::connect_all(&[]))
.unwrap(),
),
};
// "file read" should rank fs__read_file highest (2 hits vs 1)
let results = set.search("file read", 5);
assert!(!results.is_empty());
assert_eq!(results[0].prefixed_name, "fs__read_file");
}
#[test]
fn get_by_name_returns_correct_stub() {
let stubs = vec![
make_stub("a__one", "Tool one"),
make_stub("b__two", "Tool two"),
];
let set = DeferredMcpToolSet {
stubs,
registry: std::sync::Arc::new(
tokio::runtime::Runtime::new()
.unwrap()
.block_on(McpRegistry::connect_all(&[]))
.unwrap(),
),
};
assert!(set.get_by_name("a__one").is_some());
assert!(set.get_by_name("nonexistent").is_none());
}
}