From 6ab0d782efd0e39eb9aa19e94d066114383cd7a2 Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Sun, 10 May 2026 20:31:20 +0900
Subject: [PATCH] feat(mcp): kebab__bulk_search tool (fb-42)
Exposes bulk multi-query search via MCP `bulk_search` tool:
- Input: { queries: [SearchInput shapes...] }, capped at 100
- Output: bulk_search_response.v1 with per-query results + summary
- Sequential execution reuses App instance for cache amortization
- Per-query errors embed error.v1 JSON; never aborts bulk call
Updates tool count from 7 to 8 in lib.rs comment + tools_list test.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
crates/kebab-mcp/src/lib.rs | 18 ++++++--
crates/kebab-mcp/src/tools/bulk_search.rs | 55 +++++++++++++++++++++++
crates/kebab-mcp/src/tools/mod.rs | 1 +
crates/kebab-mcp/tests/tools_list.rs | 7 +--
4 files changed, 75 insertions(+), 6 deletions(-)
create mode 100644 crates/kebab-mcp/src/tools/bulk_search.rs
diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs
index 93f39df..2475d39 100644
--- a/crates/kebab-mcp/src/lib.rs
+++ b/crates/kebab-mcp/src/lib.rs
@@ -1,7 +1,7 @@
-//! MCP (Model Context Protocol) server over stdio. Exposes 7 tools
+//! MCP (Model Context Protocol) server over stdio. Exposes 8 tools
//! (`search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`
-//! / `fetch`) backed by `kebab-app` facade methods. Used by `kebab-cli`'s
-//! `Cmd::Mcp` arm.
+//! / `fetch` / `bulk_search`) backed by `kebab-app` facade methods. Used by
+//! `kebab-cli`'s `Cmd::Mcp` arm.
//!
//! See spec `docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md`.
@@ -67,6 +67,11 @@ pub fn build_tools_vec() -> Vec {
"Verbatim fetch — chunk / doc / span modes. Returns fetch_result.v1 with the indexed text (no LLM rewrite).",
schema_for_type::(),
),
+ Tool::new(
+ "bulk_search",
+ "Bulk multi-query search — N queries per call (cap 100). Each query mirrors the `search` input shape; returns `bulk_search_response.v1` with per-query results + summary. Sequential execution reuses one App instance so cache / embedder cold-start cost amortizes.",
+ schema_for_type::(),
+ ),
]
}
@@ -170,6 +175,13 @@ impl ServerHandler for KebabHandler {
})
.await
}
+ "bulk_search" => {
+ let args = request.arguments.unwrap_or_default();
+ self.spawn_tool(args, |state, input| {
+ tools::bulk_search::handle(&state, input)
+ })
+ .await
+ }
_other => Err(ErrorData::method_not_found::<
rmcp::model::CallToolRequestMethod,
>()),
diff --git a/crates/kebab-mcp/src/tools/bulk_search.rs b/crates/kebab-mcp/src/tools/bulk_search.rs
new file mode 100644
index 0000000..8be8a13
--- /dev/null
+++ b/crates/kebab-mcp/src/tools/bulk_search.rs
@@ -0,0 +1,55 @@
+//! `bulk_search` tool — wraps `kebab_app::bulk_search_with_config`.
+//! Input: `{ queries: [, ...] }`.
+//! Output: `bulk_search_response.v1` envelope (results + summary).
+
+use rmcp::model::CallToolResult;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+use crate::error::{to_tool_error, to_tool_success};
+use crate::state::KebabAppState;
+
+#[derive(Debug, Deserialize, Serialize, JsonSchema)]
+pub struct BulkSearchInput {
+ /// Per-query inputs. Each item mirrors the single-query `search`
+ /// tool's input shape — `query` is required, all other fields are
+ /// optional and default to single-search defaults. Capped at 100
+ /// items; exceeding returns an `invalid_input` tool error without
+ /// running any query.
+ pub queries: Vec,
+}
+
+pub fn handle(state: &KebabAppState, input: BulkSearchInput) -> CallToolResult {
+ let cfg_clone = (*state.config).clone();
+ match kebab_app::bulk_search_with_config(cfg_clone, input.queries) {
+ Ok((items, summary)) => {
+ let tagged_items: Vec = items
+ .iter()
+ .map(|it| {
+ let mut v = serde_json::to_value(it).unwrap_or(serde_json::Value::Null);
+ if let serde_json::Value::Object(ref mut map) = v {
+ map.insert(
+ "schema_version".to_string(),
+ serde_json::Value::String("bulk_search_item.v1".to_string()),
+ );
+ }
+ v
+ })
+ .collect();
+ let envelope = serde_json::json!({
+ "schema_version": "bulk_search_response.v1",
+ "results": tagged_items,
+ "summary": {
+ "total": summary.total,
+ "succeeded": summary.succeeded,
+ "failed": summary.failed,
+ },
+ });
+ match serde_json::to_string(&envelope) {
+ Ok(json) => to_tool_success(json),
+ Err(e) => to_tool_error(&anyhow::anyhow!(e)),
+ }
+ }
+ Err(e) => to_tool_error(&e),
+ }
+}
diff --git a/crates/kebab-mcp/src/tools/mod.rs b/crates/kebab-mcp/src/tools/mod.rs
index 22b5569..f06f91b 100644
--- a/crates/kebab-mcp/src/tools/mod.rs
+++ b/crates/kebab-mcp/src/tools/mod.rs
@@ -7,3 +7,4 @@ pub mod ask;
pub mod ingest_file;
pub mod ingest_stdin;
pub mod fetch;
+pub mod bulk_search;
diff --git a/crates/kebab-mcp/tests/tools_list.rs b/crates/kebab-mcp/tests/tools_list.rs
index c56c4a3..5746bdf 100644
--- a/crates/kebab-mcp/tests/tools_list.rs
+++ b/crates/kebab-mcp/tests/tools_list.rs
@@ -1,13 +1,13 @@
-//! Integration: `build_tools_vec` returns 7 tools with correct names and
+//! Integration: `build_tools_vec` returns 8 tools with correct names and
//! inputSchema. Uses the extracted `pub fn build_tools_vec()` helper — no
//! transport or RequestContext needed.
use kebab_mcp::build_tools_vec;
#[test]
-fn tools_list_returns_seven_tools() {
+fn tools_list_returns_eight_tools() {
let tools = build_tools_vec();
- assert_eq!(tools.len(), 7, "expected exactly 7 tools, got {}", tools.len());
+ assert_eq!(tools.len(), 8, "expected exactly 8 tools, got {}", tools.len());
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
assert!(names.contains(&"schema"), "missing 'schema' tool");
@@ -17,6 +17,7 @@ fn tools_list_returns_seven_tools() {
assert!(names.contains(&"ingest_file"), "missing 'ingest_file' tool");
assert!(names.contains(&"ingest_stdin"), "missing 'ingest_stdin' tool");
assert!(names.contains(&"fetch"), "missing 'fetch' tool");
+ assert!(names.contains(&"bulk_search"), "missing 'bulk_search' tool");
}
#[test]