diff --git a/crates/kebab-mcp/src/tools/search.rs b/crates/kebab-mcp/src/tools/search.rs index 167cb61..722dbdd 100644 --- a/crates/kebab-mcp/src/tools/search.rs +++ b/crates/kebab-mcp/src/tools/search.rs @@ -47,6 +47,10 @@ pub struct SearchInput { pub ingested_after: Option, /// p9-fb-36: filter to a single doc. pub doc_id: Option, + /// p9-fb-37: when true, include a `trace` field on the response + /// with pre-fusion lexical/vector candidate lists + per-stage timing. + /// Bypasses cache (debug intent — fresh run guaranteed). Default false. + pub trace: Option, } pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult { @@ -118,7 +122,7 @@ pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult { max_tokens: input.max_tokens, snippet_chars: input.snippet_chars, cursor: input.cursor, - trace: false, + trace: input.trace.unwrap_or(false), }; let cfg_clone = (*state.config).clone(); match kebab_app::search_with_opts_with_config(cfg_clone, query, opts) { @@ -139,12 +143,19 @@ pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult { v }) .collect(); - let envelope = serde_json::json!({ + let mut envelope = serde_json::json!({ "schema_version": "search_response.v1", "hits": tagged, "next_cursor": resp.next_cursor, "truncated": resp.truncated, }); + if let Some(trace) = &resp.trace { + let trace_v = + serde_json::to_value(trace).unwrap_or(serde_json::Value::Null); + if let serde_json::Value::Object(ref mut map) = envelope { + map.insert("trace".to_string(), trace_v); + } + } 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_fetch.rs b/crates/kebab-mcp/tests/tools_call_fetch.rs index 8da70a7..821db4d 100644 --- a/crates/kebab-mcp/tests/tools_call_fetch.rs +++ b/crates/kebab-mcp/tests/tools_call_fetch.rs @@ -69,6 +69,7 @@ async fn fetch_tool_chunk_returns_fetch_result_v1() { media: None, ingested_after: None, doc_id: None, + trace: None, }, ); let search_text = match &search_result.content.first().unwrap().raw { diff --git a/crates/kebab-mcp/tests/tools_call_search.rs b/crates/kebab-mcp/tests/tools_call_search.rs index 58a32d8..58456f7 100644 --- a/crates/kebab-mcp/tests/tools_call_search.rs +++ b/crates/kebab-mcp/tests/tools_call_search.rs @@ -65,6 +65,7 @@ async fn search_tool_returns_search_response_v1() { media: None, ingested_after: None, doc_id: None, + trace: None, }, ); @@ -166,6 +167,7 @@ async fn search_with_doc_id_filter_returns_only_target() { media: None, ingested_after: None, doc_id: None, + trace: None, }, ); assert!( @@ -204,6 +206,7 @@ async fn search_with_doc_id_filter_returns_only_target() { media: None, ingested_after: None, doc_id: Some(target_doc_id.clone()), + trace: None, }, ); assert!( @@ -260,6 +263,7 @@ async fn search_with_invalid_ingested_after_returns_invalid_input() { media: None, ingested_after: Some("garbage".to_string()), doc_id: None, + trace: None, }, ); diff --git a/crates/kebab-mcp/tests/tools_call_search_trace.rs b/crates/kebab-mcp/tests/tools_call_search_trace.rs new file mode 100644 index 0000000..1cb07cd --- /dev/null +++ b/crates/kebab-mcp/tests/tools_call_search_trace.rs @@ -0,0 +1,104 @@ +//! p9-fb-37: integration test for `mcp__kebab__search` trace input/output. + +use std::fs; + +use kebab_config::Config; +use kebab_core::SourceScope; +use kebab_mcp::{KebabAppState, KebabHandler}; +use rmcp::model::RawContent; + +fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config { + let mut cfg = Config::defaults(); + cfg.storage.data_dir = data_dir.to_string_lossy().into_owned(); + cfg.storage.model_dir = data_dir.join("models").to_string_lossy().into_owned(); + cfg.workspace.root = workspace_root.to_string_lossy().into_owned(); + cfg.workspace.exclude.clear(); + cfg.models.embedding.provider = "none".to_string(); + cfg.models.embedding.dimensions = 0; + cfg +} + +fn setup() -> (tempfile::TempDir, KebabHandler) { + let dir = tempfile::tempdir().unwrap(); + let data_dir = dir.path().join("data"); + let workspace_root = dir.path().join("notes"); + fs::create_dir_all(&data_dir).unwrap(); + fs::create_dir_all(&workspace_root).unwrap(); + let config = minimal_config(&data_dir, &workspace_root); + fs::write( + workspace_root.join("a.md"), + "# Alpha\n\nThis document mentions kebab and bread.", + ) + .unwrap(); + let scope = SourceScope { + root: workspace_root.clone(), + include: vec![], + exclude: vec![], + }; + let _ = kebab_app::ingest_with_config(config.clone(), scope, false).unwrap(); + let state = KebabAppState::new(config, None); + let handler = KebabHandler::new(state); + (dir, handler) +} + +fn make_input(trace: Option) -> kebab_mcp::tools::search::SearchInput { + kebab_mcp::tools::search::SearchInput { + query: "kebab".to_string(), + mode: Some("lexical".to_string()), + k: Some(5), + max_tokens: None, + snippet_chars: None, + cursor: None, + tags: None, + lang: None, + path_glob: None, + trust_min: None, + media: None, + ingested_after: None, + doc_id: None, + trace, + } +} + +fn extract_json(result: &rmcp::model::CallToolResult) -> serde_json::Value { + assert!( + !result.is_error.unwrap_or(false), + "expected isError=false, got {result:?}" + ); + let content = result.content.first().expect("at least one content item"); + let text = match &content.raw { + RawContent::Text(t) => &t.text, + other => panic!("expected Text content, got {other:?}"), + }; + serde_json::from_str(text).expect("valid JSON") +} + +#[tokio::test] +async fn search_with_trace_true_returns_trace_field() { + let (_dir, handler) = setup(); + let result = kebab_mcp::tools::search::handle(handler.state(), make_input(Some(true))); + let v = extract_json(&result); + assert_eq!(v["schema_version"], "search_response.v1"); + assert!(v["trace"].is_object(), "trace field present when trace:true"); + assert!(v["trace"]["timing"]["total_ms"].is_number()); + assert!(v["trace"]["lexical"].is_array()); + assert!(v["trace"]["vector"].is_array()); + assert!(v["trace"]["rrf_inputs"].is_array()); +} + +#[tokio::test] +async fn search_without_trace_omits_trace_field() { + let (_dir, handler) = setup(); + let result = kebab_mcp::tools::search::handle(handler.state(), make_input(None)); + let v = extract_json(&result); + assert_eq!(v["schema_version"], "search_response.v1"); + assert!(v.get("trace").is_none(), "trace absent when None"); +} + +#[tokio::test] +async fn search_with_trace_false_omits_trace_field() { + let (_dir, handler) = setup(); + let result = kebab_mcp::tools::search::handle(handler.state(), make_input(Some(false))); + let v = extract_json(&result); + assert!(v.get("trace").is_none(), "trace absent when false"); +}