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) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-09 20:12:05 +09:00
parent 603061fb86
commit 5e0cff1b92
2 changed files with 58 additions and 26 deletions

View File

@@ -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<String>,
/// Top-K results. Defaults to 10. Clamped to 1100.
#[serde(default = "default_k")]
pub k: usize,
}
fn default_mode() -> String {
"hybrid".to_string()
}
fn default_k() -> usize {
10
pub k: Option<usize>,
/// p9-fb-34: cap result wire size at ~N tokens (chars/4 estimate).
pub max_tokens: Option<usize>,
/// p9-fb-34: per-hit snippet character cap.
pub snippet_chars: Option<usize>,
/// p9-fb-34: opaque cursor from a previous response.
pub cursor: Option<String>,
}
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<serde_json::Value> = hits
let tagged: Vec<serde_json::Value> = 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)),
}

View File

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