use std::collections::BTreeSet;
use super::cmd::ToolDefinition;
use super::state::{McpServerEntry, McpServerStatus, McpToolSpec, State};
pub const TOOL_SEARCH_NAME: &str = "tool_search";
pub const MAX_TOOL_SEARCH_RESULTS: usize = 8;
pub const MAX_PROMOTED_TOOLS: usize = 32;
fn server_defers(state: &State, entry: &McpServerEntry) -> bool {
entry
.config
.defer
.unwrap_or_else(|| state.settings.mcp_deferral_enabled())
}
fn ready_servers(state: &State) -> Vec<(&String, &McpServerEntry)> {
let mut servers: Vec<_> = state
.mcp
.servers
.iter()
.filter(|(_, entry)| matches!(entry.status, McpServerStatus::Ready))
.collect();
servers.sort_by(|a, b| a.0.cmp(b.0));
servers
}
fn allowed_tools(entry: &McpServerEntry) -> impl Iterator<Item = &McpToolSpec> {
entry
.tools
.iter()
.filter(|tool| entry.config.tool_allowed(&tool.raw_name))
}
pub fn deferred_unpromoted(state: &State) -> Vec<(&String, &McpToolSpec)> {
ready_servers(state)
.into_iter()
.filter(|(_, entry)| server_defers(state, entry))
.flat_map(|(name, entry)| allowed_tools(entry).map(move |tool| (name, tool)))
.filter(|(_, tool)| !state.mcp.promoted.contains(&tool.name))
.collect()
}
pub fn mcp_tool_definitions(state: &State) -> Vec<ToolDefinition> {
let mut defs = Vec::new();
for (_, entry) in ready_servers(state) {
let defers = server_defers(state, entry);
for tool in allowed_tools(entry) {
if !defers || state.mcp.promoted.contains(&tool.name) {
defs.push(ToolDefinition {
name: tool.name.clone(),
description: tool.description.clone(),
input_schema: tool.input_schema.clone(),
});
}
}
}
if !deferred_unpromoted(state).is_empty() {
defs.push(tool_search_definition(state));
}
defs
}
pub fn tool_search_definition(state: &State) -> ToolDefinition {
let deferred = deferred_unpromoted(state);
let mut per_server: Vec<(String, usize)> = Vec::new();
for (server, _) in &deferred {
match per_server.last_mut() {
Some((name, count)) if name == *server => *count += 1,
_ => per_server.push(((*server).clone(), 1)),
}
}
let listing = per_server
.iter()
.map(|(name, count)| format!("{name} ({count} tools)"))
.collect::<Vec<_>>()
.join(", ");
ToolDefinition {
name: TOOL_SEARCH_NAME.to_string(),
description: format!(
"Search tools provided by connected MCP servers. Matching tools \
become directly callable on your next step. Deferred servers: {listing}."
),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search terms matched against tool names and descriptions. Empty lists the first tools alphabetically."
}
},
"required": ["query"]
}),
}
}
pub struct SearchOutcome {
pub text: String,
pub summary: String,
pub promote: Vec<String>,
}
pub fn run_tool_search(state: &State, args: &serde_json::Value) -> SearchOutcome {
let query = args
.get("query")
.and_then(|q| q.as_str())
.unwrap_or("")
.trim()
.to_lowercase();
let terms: Vec<&str> = query.split_whitespace().collect();
let deferred = deferred_unpromoted(state);
let matches: Vec<&McpToolSpec> = deferred
.iter()
.map(|(_, tool)| *tool)
.filter(|tool| {
if terms.is_empty() {
return true;
}
let haystack = format!(
"{} {} {}",
tool.name.to_lowercase(),
tool.raw_name.to_lowercase(),
tool.description.to_lowercase()
);
terms.iter().all(|term| haystack.contains(term))
})
.take(MAX_TOOL_SEARCH_RESULTS)
.collect();
if matches.is_empty() {
let text = if deferred.is_empty() {
"No deferred MCP tools are available.".to_string()
} else {
format!(
"No MCP tools match \"{query}\". Try broader terms; an empty query lists tools."
)
};
return SearchOutcome {
summary: "tool_search: 0 matches".to_string(),
text,
promote: Vec::new(),
};
}
let budget = MAX_PROMOTED_TOOLS.saturating_sub(state.mcp.promoted.len());
let promote: Vec<String> = matches
.iter()
.take(budget)
.map(|tool| tool.name.clone())
.collect();
let promoted_all = promote.len() == matches.len();
let schemas: Vec<serde_json::Value> = matches
.iter()
.map(|tool| {
serde_json::json!({
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
})
})
.collect();
let mut text = serde_json::to_string_pretty(&schemas).unwrap_or_else(|_| "[]".to_string());
if promoted_all {
text.push_str("\n\nThese tools are now directly callable.");
} else {
text.push_str(
"\n\nThe promoted-tool limit was reached; unpromoted matches above are \
usable from their schemas but stay deferred.",
);
}
let note = if terms.is_empty() {
" (empty query lists alphabetically; narrow your query for more)"
} else {
""
};
let summary = format!(
"tool_search: {} match(es), {} promoted{note}",
matches.len(),
promote.len()
);
SearchOutcome {
text,
summary,
promote,
}
}
pub fn apply_promotions(promoted: &mut BTreeSet<String>, names: Vec<String>) {
for name in names {
if promoted.len() >= MAX_PROMOTED_TOOLS {
break;
}
promoted.insert(name);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{Config, McpServerConfig};
use crate::domain::McpServerEntry;
use chrono::TimeZone;
use std::path::PathBuf;
fn spec(server: &str, tool: &str, desc: &str) -> McpToolSpec {
McpToolSpec {
name: format!("mcp__{server}__{tool}"),
raw_name: tool.to_string(),
description: desc.to_string(),
input_schema: serde_json::json!({"type": "object"}),
read_only_hint: false,
}
}
fn state_with_server(
defer_global: Option<bool>,
entries: Vec<(&str, McpServerEntry)>,
) -> State {
let now = chrono::Local.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap();
let config = Config {
mcp_defer_tools: defer_global,
..Config::default()
};
let mut state = State::new(config, PathBuf::from("/tmp"), "test/model".into(), now);
for (name, entry) in entries {
state.mcp.servers.insert(name.to_string(), entry);
}
state
}
fn ready_entry(tools: Vec<McpToolSpec>) -> McpServerEntry {
McpServerEntry {
config: McpServerConfig::default(),
status: McpServerStatus::Ready,
tools,
}
}
#[test]
fn deferral_on_advertises_only_tool_search() {
let state = state_with_server(
None,
vec![("srv", ready_entry(vec![spec("srv", "alpha", "does alpha")]))],
);
let defs = mcp_tool_definitions(&state);
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].name, TOOL_SEARCH_NAME);
assert!(defs[0].description.contains("srv (1 tools)"));
}
#[test]
fn deferral_off_advertises_tools_without_tool_search() {
let state = state_with_server(
Some(false),
vec![("srv", ready_entry(vec![spec("srv", "alpha", "does alpha")]))],
);
let defs = mcp_tool_definitions(&state);
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].name, "mcp__srv__alpha");
}
#[test]
fn per_server_defer_false_overrides_global_on() {
let mut entry = ready_entry(vec![spec("srv", "alpha", "does alpha")]);
entry.config.defer = Some(false);
let state = state_with_server(None, vec![("srv", entry)]);
let defs = mcp_tool_definitions(&state);
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].name, "mcp__srv__alpha");
}
#[test]
fn promoted_tools_are_advertised_and_leave_the_deferred_pool() {
let mut state = state_with_server(
None,
vec![(
"srv",
ready_entry(vec![
spec("srv", "alpha", "does alpha"),
spec("srv", "beta", "does beta"),
]),
)],
);
state.mcp.promoted.insert("mcp__srv__alpha".to_string());
let defs = mcp_tool_definitions(&state);
let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, vec!["mcp__srv__alpha", TOOL_SEARCH_NAME]);
}
#[test]
fn all_promoted_drops_tool_search() {
let mut state = state_with_server(
None,
vec![("srv", ready_entry(vec![spec("srv", "alpha", "does alpha")]))],
);
state.mcp.promoted.insert("mcp__srv__alpha".to_string());
let defs = mcp_tool_definitions(&state);
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].name, "mcp__srv__alpha");
}
#[test]
fn search_matches_all_terms_across_name_and_description() {
let state = state_with_server(
None,
vec![(
"srv",
ready_entry(vec![
spec("srv", "list_issues", "List repository issues"),
spec("srv", "create_pr", "Open a pull request"),
]),
)],
);
let out = run_tool_search(&state, &serde_json::json!({"query": "repository issues"}));
assert_eq!(out.promote, vec!["mcp__srv__list_issues".to_string()]);
assert!(out.text.contains("mcp__srv__list_issues"));
assert!(!out.text.contains("create_pr"));
}
#[test]
fn empty_query_lists_head_alphabetically() {
let tools: Vec<McpToolSpec> = (0..12)
.map(|i| spec("srv", &format!("tool{i:02}"), "desc"))
.collect();
let state = state_with_server(None, vec![("srv", ready_entry(tools))]);
let out = run_tool_search(&state, &serde_json::json!({"query": ""}));
assert_eq!(out.promote.len(), MAX_TOOL_SEARCH_RESULTS);
assert!(out.summary.contains("narrow your query"));
}
#[test]
fn no_match_returns_guidance_without_promotion() {
let state = state_with_server(
None,
vec![("srv", ready_entry(vec![spec("srv", "alpha", "does alpha")]))],
);
let out = run_tool_search(&state, &serde_json::json!({"query": "zzz-nothing"}));
assert!(out.promote.is_empty());
assert!(out.text.contains("No MCP tools match"));
}
#[test]
fn promotion_cap_is_enforced() {
let tools: Vec<McpToolSpec> = (0..40)
.map(|i| spec("srv", &format!("tool{i:02}"), "desc"))
.collect();
let mut state = state_with_server(None, vec![("srv", ready_entry(tools))]);
for i in 0..(MAX_PROMOTED_TOOLS - 2) {
state.mcp.promoted.insert(format!("mcp__srv__pre{i}"));
}
let out = run_tool_search(&state, &serde_json::json!({"query": "desc"}));
assert_eq!(out.promote.len(), 2);
assert!(out.text.contains("promoted-tool limit"));
apply_promotions(&mut state.mcp.promoted, out.promote);
assert_eq!(state.mcp.promoted.len(), MAX_PROMOTED_TOOLS);
}
}