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)" + ); }