Skip to content

Commit 0f34925

Browse files
committed
feat(tools): add MCP lazy loading with search/call proxy tools
Introduce MCP lazy mode when >15 MCP tools are available: - Replace all individual MCP schemas with 2 fixed proxy tools - `mcp_tool_search`: regex search MCP tools, returns schema text - `mcp_call`: execute any MCP tool by exact name + args - Inject session hint with MCP tool index (name:description) - Cache-safe: tool list fixed for entire session lifetime Update marketplace index to 120 tools. Minor fixes to Mermaid error handling.
1 parent d7bb5e4 commit 0f34925

8 files changed

Lines changed: 1725 additions & 20 deletions

File tree

refact-agent/engine/src/chat/generation.rs

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ use crate::chat::trajectory_ops::approx_token_count;
3333

3434
const TOKEN_BUDGET_CADENCE: usize = 6;
3535
const TOKEN_BUDGET_MARKER: &str = "token_budget_info";
36+
const MCP_LAZY_INDEX_MARKER: &str = "mcp_lazy_index";
3637

3738
fn maybe_inject_token_budget_instruction(
3839
session: &mut ChatSession,
@@ -105,6 +106,28 @@ fn maybe_inject_token_budget_instruction(
105106

106107

107108

109+
fn build_mcp_index_message(index: &[(String, String)], total: usize) -> String {
110+
let mut lines = vec![
111+
format!(
112+
"💿 MCP Tools — Lazy Mode Active ({} tools available). \
113+
You MUST call `mcp_tool_search` before using any MCP tool. \
114+
Example: mcp_tool_search({{\"query\": \"github.*pull|pr\"}})",
115+
total
116+
),
117+
String::new(),
118+
"Available MCP tools (name: description):".to_string(),
119+
];
120+
for (name, desc) in index {
121+
let short = if desc.chars().count() > 100 {
122+
format!("{}…", desc.chars().take(100).collect::<String>())
123+
} else {
124+
desc.clone()
125+
};
126+
lines.push(format!("- {}: {}", name, short));
127+
}
128+
lines.join("\n")
129+
}
130+
108131
pub async fn prepare_session_preamble_and_knowledge(
109132
gcx: Arc<ARwLock<GlobalContext>>,
110133
session_arc: Arc<AMutex<ChatSession>>,
@@ -121,6 +144,9 @@ pub async fn prepare_session_preamble_and_knowledge(
121144

122145
let needs_preamble = !has_system || (!has_project_context && thread.include_project_info);
123146

147+
// Populated inside `needs_preamble`; used after to inject the MCP index hint message.
148+
let mut mcp_for_index: Option<(Vec<(String, String)>, usize)> = None;
149+
124150
if needs_preamble {
125151
let caps = match crate::global_context::try_load_caps_quickly_if_not_present(gcx.clone(), 0).await {
126152
Ok(caps) => caps,
@@ -137,14 +163,16 @@ pub async fn prepare_session_preamble_and_knowledge(
137163
}
138164
};
139165

140-
let tools: Vec<crate::tools::tools_description::ToolDesc> =
166+
let raw_tools =
141167
crate::tools::tools_list::get_tools_for_mode(gcx.clone(), &thread.mode, Some(&model_rec.base.id))
142-
.await
143-
.into_iter()
144-
.map(|tool| tool.tool_description())
145-
.collect();
168+
.await;
169+
let tools_for_mode =
170+
crate::tools::tools_list::apply_mcp_lazy_filter(raw_tools);
171+
if tools_for_mode.mcp_lazy_mode {
172+
mcp_for_index = Some((tools_for_mode.mcp_tool_index.clone(), tools_for_mode.mcp_total_count));
173+
}
146174
let tool_names: std::collections::HashSet<String> =
147-
tools.iter().map(|t| t.name.clone()).collect();
175+
tools_for_mode.tools.iter().map(|t| t.tool_description().name.clone()).collect();
148176

149177
let meta = ChatMeta {
150178
chat_id: chat_id.clone(),
@@ -238,6 +266,33 @@ pub async fn prepare_session_preamble_and_knowledge(
238266
}
239267
}
240268

269+
// Inject MCP lazy-mode index hint (once per session, idempotent via marker)
270+
if let Some((mcp_index, mcp_total)) = mcp_for_index {
271+
let already_has_index = {
272+
let session = session_arc.lock().await;
273+
session.messages.iter().any(|m| {
274+
m.role == "cd_instruction" && m.tool_call_id == MCP_LAZY_INDEX_MARKER
275+
})
276+
};
277+
if !already_has_index {
278+
let index_text = build_mcp_index_message(&mcp_index, mcp_total);
279+
let mut session = session_arc.lock().await;
280+
let insert_pos = session
281+
.messages
282+
.iter()
283+
.position(|m| m.role == "system")
284+
.map(|i| i + 1)
285+
.unwrap_or(0);
286+
session.insert_message(insert_pos, ChatMessage {
287+
role: "cd_instruction".to_string(),
288+
tool_call_id: MCP_LAZY_INDEX_MARKER.to_string(),
289+
content: ChatContent::SimpleText(index_text),
290+
..Default::default()
291+
});
292+
info!("Injected MCP lazy index hint with {} tools", mcp_total);
293+
}
294+
}
295+
241296
// Knowledge enrichment for agentic mode
242297
let last_is_user = {
243298
let session = session_arc.lock().await;
@@ -600,14 +655,18 @@ pub async fn run_llm_generation(
600655
.map_err(|e| e.message)?;
601656
let model_rec = crate::caps::resolve_chat_model(caps.clone(), &thread.model)?;
602657

603-
let tools: Vec<crate::tools::tools_description::ToolDesc> =
658+
let raw_tools_for_gen =
604659
crate::tools::tools_list::get_tools_for_mode(gcx.clone(), &thread.mode, Some(&model_rec.base.id))
605-
.await
606-
.into_iter()
607-
.map(|tool| tool.tool_description())
608-
.collect();
609-
610-
info!("session generation: model={}, tools count = {}", model_rec.base.id, tools.len());
660+
.await;
661+
let tools_for_gen =
662+
crate::tools::tools_list::apply_mcp_lazy_filter(raw_tools_for_gen);
663+
let mcp_lazy_active = tools_for_gen.mcp_lazy_mode;
664+
let tools: Vec<crate::tools::tools_description::ToolDesc> = tools_for_gen.tools
665+
.into_iter()
666+
.map(|tool| tool.tool_description())
667+
.collect();
668+
669+
info!("session generation: model={}, tools count = {} (mcp_lazy={})", model_rec.base.id, tools.len(), mcp_lazy_active);
611670

612671
let model_n_ctx = if model_rec.base.n_ctx > 0 {
613672
model_rec.base.n_ctx

refact-agent/engine/src/chat/tools.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,7 +1083,8 @@ async fn instantiate_tool_for_call(
10831083
model_id: Option<&str>,
10841084
tool_name: &str,
10851085
) -> Option<Box<dyn crate::tools::tools_description::Tool + Send>> {
1086-
let tools = crate::tools::tools_list::get_tools_for_mode(gcx, mode_id, model_id).await;
1086+
let raw_tools = crate::tools::tools_list::get_tools_for_mode(gcx, mode_id, model_id).await;
1087+
let tools = crate::tools::tools_list::apply_mcp_lazy_filter(raw_tools).tools;
10871088
for tool in tools {
10881089
if tool.tool_description().name == tool_name {
10891090
return Some(tool);
@@ -1319,7 +1320,8 @@ async fn execute_tools_inner(
13191320
) -> (Vec<ChatMessage>, bool) {
13201321
let max_parallel = limits().max_parallel_tools.max(1);
13211322

1322-
let available_tools = crate::tools::tools_list::get_tools_for_mode(gcx.clone(), mode_id, model_id).await;
1323+
let raw_available_tools = crate::tools::tools_list::get_tools_for_mode(gcx.clone(), mode_id, model_id).await;
1324+
let available_tools = crate::tools::tools_list::apply_mcp_lazy_filter(raw_available_tools).tools;
13231325

13241326
let mut tool_allow_parallel: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
13251327
let mut serial_registry: SerialToolRegistry = std::collections::HashMap::new();

refact-agent/engine/src/tools/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ mod tool_web;
3131
mod tool_web_search;
3232
mod tool_compress_chat;
3333
mod tool_handoff_to_mode;
34+
mod tool_mcp_search;
35+
mod tool_mcp_call;
3436

3537
pub mod file_edit;
3638
mod tool_create_knowledge;
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
use std::collections::HashMap;
2+
use std::sync::Arc;
3+
4+
use async_trait::async_trait;
5+
use serde_json::{json, Value};
6+
use tokio::sync::Mutex as AMutex;
7+
8+
use crate::at_commands::at_commands::AtCommandsContext;
9+
use crate::call_validation::ContextEnum;
10+
use crate::tools::tools_description::{MatchConfirmDenyResult, Tool, ToolConfig, ToolDesc, ToolGroupCategory, ToolSource, ToolSourceType};
11+
use crate::tools::tools_list::get_integration_tools;
12+
13+
pub struct ToolMcpCall {}
14+
15+
#[async_trait]
16+
impl Tool for ToolMcpCall {
17+
fn tool_description(&self) -> ToolDesc {
18+
ToolDesc {
19+
name: "mcp_call".to_string(),
20+
experimental: false,
21+
allow_parallel: true,
22+
description: "Execute any MCP tool by name with the given arguments. \
23+
Use `mcp_tool_search` first to discover the tool name and its input schema, \
24+
then call this with the exact arguments the schema requires."
25+
.to_string(),
26+
input_schema: json!({
27+
"type": "object",
28+
"properties": {
29+
"tool_name": {
30+
"type": "string",
31+
"description": "Exact MCP tool name as returned by mcp_tool_search"
32+
},
33+
"args": {
34+
"type": "object",
35+
"description": "Arguments object matching the tool's input schema"
36+
}
37+
},
38+
"required": ["tool_name", "args"]
39+
}),
40+
output_schema: None,
41+
annotations: None,
42+
display_name: "MCP Call".to_string(),
43+
source: ToolSource {
44+
source_type: ToolSourceType::Builtin,
45+
config_path: String::new(),
46+
},
47+
}
48+
}
49+
50+
fn config(&self) -> Result<ToolConfig, String> {
51+
Ok(ToolConfig { enabled: true, allow_parallel: None })
52+
}
53+
54+
async fn tool_execute(
55+
&mut self,
56+
ccx: Arc<AMutex<AtCommandsContext>>,
57+
tool_call_id: &String,
58+
args: &HashMap<String, Value>,
59+
) -> Result<(bool, Vec<ContextEnum>), String> {
60+
let tool_name = args.get("tool_name")
61+
.and_then(|v| v.as_str())
62+
.ok_or_else(|| "mcp_call: missing required argument 'tool_name'".to_string())?
63+
.to_string();
64+
65+
let tool_args: HashMap<String, Value> = args.get("args")
66+
.and_then(|v| v.as_object())
67+
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
68+
.unwrap_or_default();
69+
70+
let gcx = ccx.lock().await.global_context.clone();
71+
let mut integration_groups = get_integration_tools(gcx).await;
72+
73+
// Find the named MCP tool and extract it (needs &mut self for tool_execute).
74+
let mut found_tool: Option<Box<dyn Tool + Send>> = None;
75+
'outer: for group in &mut integration_groups {
76+
if !matches!(group.category, ToolGroupCategory::MCP) {
77+
continue;
78+
}
79+
if let Some(pos) = group.tools.iter().position(|t| t.tool_description().name == tool_name) {
80+
found_tool = Some(group.tools.remove(pos));
81+
break 'outer;
82+
}
83+
}
84+
85+
let mut tool = found_tool.ok_or_else(|| {
86+
format!(
87+
"MCP tool '{}' not found. Use mcp_tool_search to discover available tools.",
88+
tool_name
89+
)
90+
})?;
91+
92+
let confirm_result = tool.match_against_confirm_deny(ccx.clone(), &tool_args).await
93+
.map_err(|e| format!("mcp_call confirmation check failed: {}", e))?;
94+
if confirm_result.result == MatchConfirmDenyResult::DENY {
95+
return Err(format!(
96+
"MCP tool '{}' was denied by rule '{}'",
97+
tool_name, confirm_result.rule
98+
));
99+
}
100+
if confirm_result.result == MatchConfirmDenyResult::CONFIRMATION {
101+
return Err(format!(
102+
"MCP tool '{}' requires user confirmation (rule '{}'). Use the tool directly instead of via mcp_call to enable the confirmation flow.",
103+
tool_name, confirm_result.rule
104+
));
105+
}
106+
107+
tool.tool_execute(ccx, tool_call_id, &tool_args).await
108+
}
109+
}

0 commit comments

Comments
 (0)