Skip to content

Commit 292952e

Browse files
feat(tools/mcp): add MCP subsystem tools layer with multi-transport client (zeroclaw-labs#3394)
* feat(tools/mcp): add MCP subsystem tools layer with multi-transport client Introduces a new MCP (Model Context Protocol) subsystem to the tools layer, providing a multi-transport client implementation (stdio, HTTP, SSE) that allows ZeroClaw agents to connect to external MCP servers and register their exposed tools into the runtime tool registry. New files: - src/tools/mcp_client.rs: McpRegistry — lifecycle manager for MCP server connections - src/tools/mcp_protocol.rs: protocol types (request/response/notifications) - src/tools/mcp_tool.rs: McpToolWrapper — bridges MCP tools to ZeroClaw Tool trait - src/tools/mcp_transport.rs: transport abstraction (Stdio, Http, Sse) Wiring changes: - src/tools/mod.rs: pub mod + pub use for new MCP modules - src/config/schema.rs: McpTransport, McpServerConfig, McpConfig types; mcp field on Config; validate_mcp_config; mcp unit tests - src/config/mod.rs: re-exports McpConfig, McpServerConfig, McpTransport - src/channels/mod.rs: MCP server init block in start_channels() - src/agent/loop_.rs: MCP registry init in run() and process_message() - src/onboard/wizard.rs: mcp: McpConfig::default() in both wizard constructors * fix(tools/mcp): inject MCP tools after built-in tool filter, not before MCP servers are user-declared external integrations. The built-in agent.allowed_tools / agent.denied_tools filter (filter_primary_agent_tools_or_fail) governs built-in tool governance only. Injecting MCP tools before that filter would silently drop all MCP tools when a restrictive allowlist is configured. Add ordering comments at both call sites (run() CLI path and process_message() path) to make this contract explicit for reviewers and future merges. Identified via: shady831213/zeroclaw-agent-mcp@3f90b78 * fix(tools/mcp): strip approved field from MCP tool args before forwarding ZeroClaw's security model injects `approved: bool` into built-in tool args for supervised-mode confirmation. MCP servers have no knowledge of this field and reject calls that include it as an unexpected parameter. Strip `approved` from object-typed args in McpToolWrapper::execute() before forwarding to the MCP server. Non-object args pass through unchanged (no silent conversion or rejection). Add two unit tests: - execute_strips_approved_field_from_object_args: verifies removal - execute_handles_non_object_args_without_panic: verifies non-object shapes are not broken by the stripping logic Identified via: shady831213/zeroclaw-agent-mcp@c68be01 --------- Co-authored-by: argenis de la rosa <theonlyhennygod@gmail.com>
1 parent d115b28 commit 292952e

9 files changed

Lines changed: 1234 additions & 51 deletions

File tree

src/agent/loop_.rs

Lines changed: 174 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,91 @@ const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10;
3131
/// Matches the channel-side constant in `channels/mod.rs`.
3232
const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20;
3333

34+
fn glob_match(pattern: &str, name: &str) -> bool {
35+
match pattern.find('*') {
36+
None => pattern == name,
37+
Some(star) => {
38+
let prefix = &pattern[..star];
39+
let suffix = &pattern[star + 1..];
40+
name.starts_with(prefix)
41+
&& name.ends_with(suffix)
42+
&& name.len() >= prefix.len() + suffix.len()
43+
}
44+
}
45+
}
46+
47+
/// Returns the subset of `tool_specs` that should be sent to the LLM for this turn.
48+
///
49+
/// Rules (mirrors NullClaw `filterToolSpecsForTurn`):
50+
/// - Built-in tools (names that do not start with `"mcp_"`) always pass through.
51+
/// - When `groups` is empty, all tools pass through (backward compatible default).
52+
/// - An MCP tool is included if at least one group matches it:
53+
/// - `always` group: included unconditionally if any pattern matches the tool name.
54+
/// - `dynamic` group: included if any pattern matches AND the user message contains
55+
/// at least one keyword (case-insensitive substring).
56+
pub(crate) fn filter_tool_specs_for_turn(
57+
tool_specs: Vec<crate::tools::ToolSpec>,
58+
groups: &[crate::config::schema::ToolFilterGroup],
59+
user_message: &str,
60+
) -> Vec<crate::tools::ToolSpec> {
61+
use crate::config::schema::ToolFilterGroupMode;
62+
63+
if groups.is_empty() {
64+
return tool_specs;
65+
}
66+
67+
let msg_lower = user_message.to_ascii_lowercase();
68+
69+
tool_specs
70+
.into_iter()
71+
.filter(|spec| {
72+
// Built-in tools always pass through.
73+
if !spec.name.starts_with("mcp_") {
74+
return true;
75+
}
76+
// MCP tool: include if any active group matches.
77+
groups.iter().any(|group| {
78+
let pattern_matches = group.tools.iter().any(|pat| glob_match(pat, &spec.name));
79+
if !pattern_matches {
80+
return false;
81+
}
82+
match group.mode {
83+
ToolFilterGroupMode::Always => true,
84+
ToolFilterGroupMode::Dynamic => group
85+
.keywords
86+
.iter()
87+
.any(|kw| msg_lower.contains(&kw.to_ascii_lowercase())),
88+
}
89+
})
90+
})
91+
.collect()
92+
}
93+
94+
/// Computes the list of MCP tool names that should be excluded for a given turn
95+
/// based on `tool_filter_groups` and the user message.
96+
///
97+
/// Returns an empty `Vec` when `groups` is empty (no filtering).
98+
fn compute_excluded_mcp_tools(
99+
tools_registry: &[Box<dyn Tool>],
100+
groups: &[crate::config::schema::ToolFilterGroup],
101+
user_message: &str,
102+
) -> Vec<String> {
103+
if groups.is_empty() {
104+
return Vec::new();
105+
}
106+
let filtered_specs = filter_tool_specs_for_turn(
107+
tools_registry.iter().map(|t| t.spec()).collect(),
108+
groups,
109+
user_message,
110+
);
111+
let included: HashSet<&str> = filtered_specs.iter().map(|s| s.name.as_str()).collect();
112+
tools_registry
113+
.iter()
114+
.filter(|t| t.name().starts_with("mcp_") && !included.contains(t.name()))
115+
.map(|t| t.name().to_string())
116+
.collect()
117+
}
118+
34119
static SENSITIVE_KEY_PATTERNS: LazyLock<RegexSet> = LazyLock::new(|| {
35120
RegexSet::new([
36121
r"(?i)token",
@@ -2880,6 +2965,45 @@ pub async fn run(
28802965
tools_registry.extend(peripheral_tools);
28812966
}
28822967

2968+
// ── Wire MCP tools (non-fatal) — CLI path ────────────────────
2969+
// NOTE: MCP tools are injected after built-in tool filtering
2970+
// (filter_primary_agent_tools_or_fail / agent.allowed_tools / agent.denied_tools).
2971+
// MCP servers are user-declared external integrations; the built-in allow/deny
2972+
// filter is not appropriate for them and would silently drop all MCP tools when
2973+
// a restrictive allowlist is configured. Keep this block after any such filter call.
2974+
if config.mcp.enabled && !config.mcp.servers.is_empty() {
2975+
tracing::info!(
2976+
"Initializing MCP client — {} server(s) configured",
2977+
config.mcp.servers.len()
2978+
);
2979+
match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
2980+
Ok(registry) => {
2981+
let registry = std::sync::Arc::new(registry);
2982+
let names = registry.tool_names();
2983+
let mut registered = 0usize;
2984+
for name in names {
2985+
if let Some(def) = registry.get_tool_def(&name).await {
2986+
let wrapper = crate::tools::McpToolWrapper::new(
2987+
name,
2988+
def,
2989+
std::sync::Arc::clone(&registry),
2990+
);
2991+
tools_registry.push(Box::new(wrapper));
2992+
registered += 1;
2993+
}
2994+
}
2995+
tracing::info!(
2996+
"MCP: {} tool(s) registered from {} server(s)",
2997+
registered,
2998+
registry.server_count()
2999+
);
3000+
}
3001+
Err(e) => {
3002+
tracing::error!("MCP registry failed to initialize: {e:#}");
3003+
}
3004+
}
3005+
}
3006+
28833007
// ── Resolve provider ─────────────────────────────────────────
28843008
let provider_name = provider_override
28853009
.as_deref()
@@ -3111,6 +3235,10 @@ pub async fn run(
31113235
ChatMessage::user(&enriched),
31123236
];
31133237

3238+
// Compute per-turn excluded MCP tools from tool_filter_groups.
3239+
let excluded_tools =
3240+
compute_excluded_mcp_tools(&tools_registry, &config.agent.tool_filter_groups, &msg);
3241+
31143242
let response = run_tool_call_loop(
31153243
provider.as_ref(),
31163244
&mut history,
@@ -3127,7 +3255,7 @@ pub async fn run(
31273255
None,
31283256
None,
31293257
None,
3130-
&[],
3258+
&excluded_tools,
31313259
&config.agent.tool_call_dedup_exempt,
31323260
)
31333261
.await?;
@@ -3245,6 +3373,13 @@ pub async fn run(
32453373

32463374
history.push(ChatMessage::user(&enriched));
32473375

3376+
// Compute per-turn excluded MCP tools from tool_filter_groups.
3377+
let excluded_tools = compute_excluded_mcp_tools(
3378+
&tools_registry,
3379+
&config.agent.tool_filter_groups,
3380+
&user_input,
3381+
);
3382+
32483383
let response = match run_tool_call_loop(
32493384
provider.as_ref(),
32503385
&mut history,
@@ -3261,7 +3396,7 @@ pub async fn run(
32613396
None,
32623397
None,
32633398
None,
3264-
&[],
3399+
&excluded_tools,
32653400
&config.agent.tool_call_dedup_exempt,
32663401
)
32673402
.await
@@ -3359,6 +3494,43 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
33593494
crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
33603495
tools_registry.extend(peripheral_tools);
33613496

3497+
// ── Wire MCP tools (non-fatal) — process_message path ────────
3498+
// NOTE: Same ordering contract as the CLI path above — MCP tools must be
3499+
// injected after filter_primary_agent_tools_or_fail (or equivalent built-in
3500+
// tool allow/deny filtering) to avoid MCP tools being silently dropped.
3501+
if config.mcp.enabled && !config.mcp.servers.is_empty() {
3502+
tracing::info!(
3503+
"Initializing MCP client — {} server(s) configured",
3504+
config.mcp.servers.len()
3505+
);
3506+
match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
3507+
Ok(registry) => {
3508+
let registry = std::sync::Arc::new(registry);
3509+
let names = registry.tool_names();
3510+
let mut registered = 0usize;
3511+
for name in names {
3512+
if let Some(def) = registry.get_tool_def(&name).await {
3513+
let wrapper = crate::tools::McpToolWrapper::new(
3514+
name,
3515+
def,
3516+
std::sync::Arc::clone(&registry),
3517+
);
3518+
tools_registry.push(Box::new(wrapper));
3519+
registered += 1;
3520+
}
3521+
}
3522+
tracing::info!(
3523+
"MCP: {} tool(s) registered from {} server(s)",
3524+
registered,
3525+
registry.server_count()
3526+
);
3527+
}
3528+
Err(e) => {
3529+
tracing::error!("MCP registry failed to initialize: {e:#}");
3530+
}
3531+
}
3532+
}
3533+
33623534
let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
33633535
let model_name = config
33643536
.default_model

src/channels/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3408,7 +3408,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
34083408
};
34093409
// Build system prompt from workspace identity files + skills
34103410
let workspace = config.workspace_dir.clone();
3411-
let mut built_tools = tools::all_tools_with_runtime(
3411+
let mut built_tools: Vec<Box<dyn Tool>> = tools::all_tools_with_runtime(
34123412
Arc::new(config.clone()),
34133413
&security,
34143414
runtime,
@@ -3430,14 +3430,14 @@ pub async fn start_channels(config: Config) -> Result<()> {
34303430
"Initializing MCP client — {} server(s) configured",
34313431
config.mcp.servers.len()
34323432
);
3433-
match crate::tools::mcp_client::McpRegistry::connect_all(&config.mcp.servers).await {
3433+
match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
34343434
Ok(registry) => {
34353435
let registry = std::sync::Arc::new(registry);
34363436
let names = registry.tool_names();
34373437
let mut registered = 0usize;
34383438
for name in names {
34393439
if let Some(def) = registry.get_tool_def(&name).await {
3440-
let wrapper = crate::tools::mcp_tool::McpToolWrapper::new(
3440+
let wrapper = crate::tools::McpToolWrapper::new(
34413441
name,
34423442
def,
34433443
std::sync::Arc::clone(&registry),

src/config/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ pub use schema::{
1717
QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig,
1818
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SkillsConfig,
1919
SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
20-
StorageProviderSection, StreamMode, TelegramConfig, TranscriptionConfig, TtsConfig,
21-
TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
20+
StorageProviderSection, StreamMode, TelegramConfig, ToolFilterGroup, ToolFilterGroupMode,
21+
TranscriptionConfig, TtsConfig, TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
2222
};
2323

2424
pub fn name_and_presence<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {

0 commit comments

Comments
 (0)