Skip to content

Commit a2f3ac9

Browse files
committed
feat(meta-tools): add mcpmux_search_tools for keyword tool discovery
`mcpmux_list_all_tools` returns the entire tool catalog for the resolved Space in one payload — expensive in token terms once a Space has many servers. Add a read-only `mcpmux_search_tools` so an agent that knows roughly what it wants can find tools by keyword instead of dumping everything. - Case-insensitive substring match over qualified_name, server_id, and description, scoped to the caller''s resolved Space. - `limit` (default 25, hard max 100); results sorted by qualified_name before truncation; response carries match_count / returned / truncated so the caller knows when to narrow the query. - Registered as a read tool (no approval) alongside list_all_tools; added to the Tool Optimization built-in descriptor so the desktop shelf lists it. Integration tests cover name/server/description matching, empty results, missing-query rejection, and limit/truncation. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent fbf3c5c commit a2f3ac9

4 files changed

Lines changed: 222 additions & 4 deletions

File tree

crates/mcpmux-core/src/domain/builtin.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ pub fn builtin_servers() -> Vec<BuiltinServerDescriptor> {
6161
description: "Browse every tool available in the resolved Space, unfiltered.",
6262
write: false,
6363
},
64+
BuiltinToolDescriptor {
65+
name: "mcpmux_search_tools",
66+
description: "Find tools by keyword without pulling the whole catalog.",
67+
write: false,
68+
},
6469
BuiltinToolDescriptor {
6570
name: "mcpmux_list_feature_sets",
6671
description: "See the feature sets defined in the Space.",

crates/mcpmux-gateway/src/services/meta_tools/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,11 @@ pub fn build_default_registry(
7878
let mut registry = MetaToolRegistry::new(ctx);
7979
// Reads — no approval needed.
8080
registry.register(Box::new(tools::ListAllToolsTool));
81+
registry.register(Box::new(tools::SearchToolsTool));
8182
registry.register(Box::new(tools::ListFeatureSetsTool));
8283
// Both `describe_resolution` and `describe_workspace` were removed by
83-
// user request — the read surface is just the two list_* tools above,
84-
// which an LLM can stitch into the same picture without an extra hop.
84+
// user request — the read surface is the list_* tools above plus
85+
// `search_tools`, which an LLM can stitch into the same picture.
8586
// Writes — gated by ApprovalBroker.
8687
registry.register(Box::new(tools::ManageFeatureSetTool));
8788
registry.register(Box::new(tools::BindCurrentWorkspaceTool));

crates/mcpmux-gateway/src/services/meta_tools/tools.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,117 @@ impl MetaTool for ListAllToolsTool {
110110
}
111111
}
112112

113+
// ---------------------------------------------------------------------------
114+
// mcpmux_search_tools — read
115+
// ---------------------------------------------------------------------------
116+
117+
pub struct SearchToolsTool;
118+
119+
/// Default cap on returned matches — keeps the payload (and the agent's token
120+
/// spend) bounded when a broad query matches many tools.
121+
const SEARCH_TOOLS_DEFAULT_LIMIT: usize = 25;
122+
/// Hard ceiling so a caller can't request an unbounded dump via `limit`.
123+
const SEARCH_TOOLS_MAX_LIMIT: usize = 100;
124+
125+
#[async_trait]
126+
impl MetaTool for SearchToolsTool {
127+
fn name(&self) -> &'static str {
128+
"mcpmux_search_tools"
129+
}
130+
131+
fn description(&self) -> &'static str {
132+
"Search the tools installed in the caller's resolved Space by keyword, \
133+
without the current FeatureSet filter applied. Prefer this over \
134+
`mcpmux_list_all_tools` when you know roughly what you're looking for — \
135+
it returns only matches, so it's far cheaper than dumping the whole \
136+
catalog. `query` is matched case-insensitively against each tool's \
137+
qualified name, description, and server id. Optional `limit` (default \
138+
25, max 100). Returns an array of \
139+
{server_id, qualified_name, description, available}."
140+
}
141+
142+
fn input_schema(&self) -> Value {
143+
json!({
144+
"type": "object",
145+
"required": ["query"],
146+
"properties": {
147+
"query": {
148+
"type": "string",
149+
"description": "keyword(s) matched against tool name, description, and server id"
150+
},
151+
"limit": {
152+
"type": "integer",
153+
"minimum": 1,
154+
"maximum": SEARCH_TOOLS_MAX_LIMIT,
155+
"description": "max matches to return (default 25)"
156+
}
157+
}
158+
})
159+
}
160+
161+
async fn call(&self, call: MetaToolCall<'_>) -> Result<CallToolResult, MetaToolError> {
162+
let query = opt_str_arg(&call.args, "query").ok_or_else(|| {
163+
MetaToolError::InvalidArgument("search requires a non-empty `query`".into())
164+
})?;
165+
let needle = query.to_lowercase();
166+
167+
// `limit`: clamp to [1, MAX]; fall back to the default when absent or
168+
// not a positive integer.
169+
let limit = call
170+
.args
171+
.get("limit")
172+
.and_then(|v| v.as_u64())
173+
.map(|n| (n as usize).clamp(1, SEARCH_TOOLS_MAX_LIMIT))
174+
.unwrap_or(SEARCH_TOOLS_DEFAULT_LIMIT);
175+
176+
let space_id = caller_space_id(&call).await?;
177+
let features = call
178+
.ctx
179+
.server_feature_repo
180+
.list_for_space(&space_id.to_string())
181+
.await?;
182+
183+
let mut matches: Vec<&ServerFeature> = features
184+
.iter()
185+
.filter(|f| f.feature_type == FeatureType::Tool)
186+
.filter(|f| {
187+
f.qualified_name().to_lowercase().contains(&needle)
188+
|| f.server_id.to_lowercase().contains(&needle)
189+
|| f.description
190+
.as_deref()
191+
.map(|d| d.to_lowercase().contains(&needle))
192+
.unwrap_or(false)
193+
})
194+
.collect();
195+
196+
// Stable, predictable ordering before truncating to `limit`.
197+
matches.sort_by_key(|f| f.qualified_name());
198+
let total = matches.len();
199+
let truncated = total > limit;
200+
201+
let tools: Vec<_> = matches
202+
.into_iter()
203+
.take(limit)
204+
.map(|f| {
205+
json!({
206+
"server_id": f.server_id,
207+
"qualified_name": f.qualified_name(),
208+
"description": f.description,
209+
"available": f.is_available,
210+
})
211+
})
212+
.collect();
213+
214+
Ok(text_result(json!({
215+
"query": query,
216+
"match_count": total,
217+
"returned": tools.len(),
218+
"truncated": truncated,
219+
"tools": tools,
220+
})))
221+
}
222+
}
223+
113224
// ---------------------------------------------------------------------------
114225
// mcpmux_list_feature_sets — read
115226
// ---------------------------------------------------------------------------

tests/rust/tests/integration/meta_tools.rs

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,9 +266,109 @@ async fn list_feature_sets_returns_space_contents() {
266266
assert_eq!(sets.len(), 3, "Default + 2 custom expected");
267267
}
268268

269+
#[tokio::test(flavor = "multi_thread")]
270+
async fn search_tools_matches_name_server_and_description() {
271+
let f = Fixture::new().await;
272+
273+
// By qualified name / server id.
274+
let by_name = Fixture::result_json(
275+
&f.registry
276+
.call(
277+
"mcpmux_search_tools",
278+
&f.client_id,
279+
Some(&f.session_id),
280+
json!({ "query": "github" }),
281+
)
282+
.await
283+
.unwrap(),
284+
);
285+
assert_eq!(by_name.get("match_count").unwrap().as_u64().unwrap(), 1);
286+
let tools = by_name.get("tools").unwrap().as_array().unwrap();
287+
assert_eq!(
288+
tools[0].get("qualified_name").unwrap().as_str().unwrap(),
289+
"github_create_issue"
290+
);
291+
292+
// By description text ("Deploy to Firebase").
293+
let by_desc = Fixture::result_json(
294+
&f.registry
295+
.call(
296+
"mcpmux_search_tools",
297+
&f.client_id,
298+
Some(&f.session_id),
299+
json!({ "query": "deploy" }),
300+
)
301+
.await
302+
.unwrap(),
303+
);
304+
let desc_tools = by_desc.get("tools").unwrap().as_array().unwrap();
305+
assert_eq!(desc_tools.len(), 1);
306+
assert_eq!(
307+
desc_tools[0]
308+
.get("qualified_name")
309+
.unwrap()
310+
.as_str()
311+
.unwrap(),
312+
"firebase_deploy"
313+
);
314+
315+
// No match → empty, not an error.
316+
let none = Fixture::result_json(
317+
&f.registry
318+
.call(
319+
"mcpmux_search_tools",
320+
&f.client_id,
321+
Some(&f.session_id),
322+
json!({ "query": "zzzznotathing" }),
323+
)
324+
.await
325+
.unwrap(),
326+
);
327+
assert_eq!(none.get("match_count").unwrap().as_u64().unwrap(), 0);
328+
assert!(none.get("tools").unwrap().as_array().unwrap().is_empty());
329+
}
330+
331+
#[tokio::test(flavor = "multi_thread")]
332+
async fn search_tools_requires_query() {
333+
let f = Fixture::new().await;
334+
let res = f
335+
.call_tool_as_handler_would("mcpmux_search_tools", json!({}))
336+
.await;
337+
assert!(Fixture::is_error(&res));
338+
assert_eq!(
339+
Fixture::result_json(&res)
340+
.get("error")
341+
.unwrap()
342+
.as_str()
343+
.unwrap(),
344+
"invalid_argument"
345+
);
346+
}
347+
348+
#[tokio::test(flavor = "multi_thread")]
349+
async fn search_tools_caps_results_at_limit_and_flags_truncation() {
350+
let f = Fixture::new().await;
351+
// "e" appears in both seeded tools' names/descriptions → 2 matches.
352+
let body = Fixture::result_json(
353+
&f.registry
354+
.call(
355+
"mcpmux_search_tools",
356+
&f.client_id,
357+
Some(&f.session_id),
358+
json!({ "query": "e", "limit": 1 }),
359+
)
360+
.await
361+
.unwrap(),
362+
);
363+
assert_eq!(body.get("match_count").unwrap().as_u64().unwrap(), 2);
364+
assert_eq!(body.get("returned").unwrap().as_u64().unwrap(), 1);
365+
assert!(body.get("truncated").unwrap().as_bool().unwrap());
366+
assert_eq!(body.get("tools").unwrap().as_array().unwrap().len(), 1);
367+
}
368+
269369
// `describe_resolution` and `describe_workspace` were both removed at the
270-
// user's request — the read surface is now just `list_all_tools` and
271-
// `list_feature_sets`. Behavior previously asserted here is covered by
370+
// user's request — the read surface is now `list_all_tools`, `search_tools`,
371+
// and `list_feature_sets`. Behavior previously asserted here is covered by
272372
// `FeatureSetResolverService`'s own tests in
273373
// `tests/rust/tests/integration/feature_set_resolver.rs`.
274374

@@ -693,6 +793,7 @@ async fn registry_advertises_every_default_tool_with_annotations() {
693793
let names: Vec<_> = tools.iter().map(|t| t.name.to_string()).collect();
694794
for expected in [
695795
"mcpmux_list_all_tools",
796+
"mcpmux_search_tools",
696797
"mcpmux_list_feature_sets",
697798
"mcpmux_manage_feature_set",
698799
"mcpmux_bind_current_workspace",

0 commit comments

Comments
 (0)