From 5e0cff1b92c4c148fd8f484e8c0cd0e13f31c300 Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Sat, 9 May 2026 20:12:05 +0900
Subject: [PATCH] feat(mcp): search tool emits search_response.v1 + budget
inputs (fb-34)
SearchInput gains max_tokens / snippet_chars / cursor (all optional).
Output wrapped in search_response.v1 to match CLI; existing
tools_call_search test updated to read v["hits"] instead of the bare
array.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
crates/kebab-mcp/src/tools/search.rs | 50 +++++++++++++--------
crates/kebab-mcp/tests/tools_call_search.rs | 34 +++++++++++---
2 files changed, 58 insertions(+), 26 deletions(-)
diff --git a/crates/kebab-mcp/src/tools/search.rs b/crates/kebab-mcp/src/tools/search.rs
index 3496a22..e5f7b4e 100644
--- a/crates/kebab-mcp/src/tools/search.rs
+++ b/crates/kebab-mcp/src/tools/search.rs
@@ -1,5 +1,6 @@
-//! `search` tool — wraps `kebab_app::search_with_config`.
-//! Input: { query, mode?, k? }. Output: search_hit.v1 array JSON.
+//! `search` tool — wraps `kebab_app::search_with_opts_with_config`.
+//! Input: { query, mode?, k?, max_tokens?, snippet_chars?, cursor? }.
+//! Output: search_response.v1 envelope (hits + next_cursor + truncated).
//!
//! First tool with a non-empty `inputSchema`: `SearchInput` derives
//! `JsonSchema` and `Tool::new` uses
@@ -17,23 +18,21 @@ pub struct SearchInput {
/// User query (free text).
pub query: String,
/// Retrieval mode: "hybrid" (default), "lexical", or "vector".
- #[serde(default = "default_mode")]
- pub mode: String,
+ pub mode: Option,
/// Top-K results. Defaults to 10. Clamped to 1–100.
- #[serde(default = "default_k")]
- pub k: usize,
-}
-
-fn default_mode() -> String {
- "hybrid".to_string()
-}
-fn default_k() -> usize {
- 10
+ pub k: Option,
+ /// p9-fb-34: cap result wire size at ~N tokens (chars/4 estimate).
+ pub max_tokens: Option,
+ /// p9-fb-34: per-hit snippet character cap.
+ pub snippet_chars: Option,
+ /// p9-fb-34: opaque cursor from a previous response.
+ pub cursor: Option,
}
pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult {
- let k = input.k.clamp(1, 100);
- let mode = match input.mode.as_str() {
+ let k = input.k.unwrap_or(10).clamp(1, 100);
+ let mode_str = input.mode.as_deref().unwrap_or("hybrid");
+ let mode = match mode_str {
"lexical" => kebab_core::SearchMode::Lexical,
"vector" => kebab_core::SearchMode::Vector,
_ => kebab_core::SearchMode::Hybrid,
@@ -44,11 +43,18 @@ pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult {
k,
filters: kebab_core::SearchFilters::default(),
};
- match kebab_app::search_with_config((*state.config).clone(), query) {
- Ok(hits) => {
+ let opts = kebab_core::SearchOpts {
+ max_tokens: input.max_tokens,
+ snippet_chars: input.snippet_chars,
+ cursor: input.cursor,
+ };
+ let cfg_clone = (*state.config).clone();
+ match kebab_app::search_with_opts_with_config(cfg_clone, query, opts) {
+ Ok(resp) => {
// SearchHit (kebab-core) does not carry a `schema_version` field,
// so we tag each element inline before serialising.
- let tagged: Vec = hits
+ let tagged: Vec = resp
+ .hits
.iter()
.map(|h| {
let mut v = serde_json::to_value(h).unwrap_or_default();
@@ -61,7 +67,13 @@ pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult {
v
})
.collect();
- match serde_json::to_string(&serde_json::Value::Array(tagged)) {
+ let envelope = serde_json::json!({
+ "schema_version": "search_response.v1",
+ "hits": tagged,
+ "next_cursor": resp.next_cursor,
+ "truncated": resp.truncated,
+ });
+ match serde_json::to_string(&envelope) {
Ok(json) => to_tool_success(json),
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
}
diff --git a/crates/kebab-mcp/tests/tools_call_search.rs b/crates/kebab-mcp/tests/tools_call_search.rs
index 5f734eb..5995292 100644
--- a/crates/kebab-mcp/tests/tools_call_search.rs
+++ b/crates/kebab-mcp/tests/tools_call_search.rs
@@ -1,4 +1,4 @@
-//! Integration: tools/call name=search — verify response is search_hit.v1 array.
+//! Integration: tools/call name=search — verify response is search_response.v1.
use std::fs;
@@ -22,7 +22,7 @@ fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path)
}
#[tokio::test]
-async fn search_tool_returns_search_hits_array() {
+async fn search_tool_returns_search_response_v1() {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path().join("data");
let workspace_root = dir.path().join("notes");
@@ -53,8 +53,11 @@ async fn search_tool_returns_search_hits_array() {
handler.state(),
kebab_mcp::tools::search::SearchInput {
query: "kebab".to_string(),
- mode: "lexical".to_string(),
- k: 5,
+ mode: Some("lexical".to_string()),
+ k: Some(5),
+ max_tokens: None,
+ snippet_chars: None,
+ cursor: None,
},
);
@@ -75,16 +78,33 @@ async fn search_tool_returns_search_hits_array() {
};
let v: serde_json::Value = serde_json::from_str(text).unwrap();
- let arr = v.as_array().expect("search returns a JSON array");
+ assert_eq!(
+ v.get("schema_version").and_then(|s| s.as_str()),
+ Some("search_response.v1"),
+ "envelope should carry schema_version=search_response.v1"
+ );
+ let hits = v
+ .get("hits")
+ .and_then(|h| h.as_array())
+ .expect("hits must be a JSON array");
assert!(
- !arr.is_empty(),
+ !hits.is_empty(),
"expected at least one hit for 'kebab' in 'a.md'"
);
assert_eq!(
- arr[0]
+ hits[0]
.get("schema_version")
.and_then(|s| s.as_str()),
Some("search_hit.v1"),
"first hit should carry schema_version=search_hit.v1"
);
+ // truncated must be present (bool); next_cursor may be null on last page.
+ assert!(
+ v.get("truncated").and_then(|t| t.as_bool()).is_some(),
+ "envelope should carry truncated:bool"
+ );
+ assert!(
+ v.get("next_cursor").is_some(),
+ "envelope should carry next_cursor (possibly null)"
+ );
}