From 4b1b8a15bf18155ca5b2c4bb639d546f41ecfaa6 Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Thu, 7 May 2026 15:50:57 +0900
Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(kebab-mcp):=20ask=20tool=20(fb?=
=?UTF-8?q?-30)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fourth (and final v1) tool — `ask` (input: query / optional session_id).
Multi-turn via optional session_id (kebab_app::ask_with_session_with_config),
single-shot via ask_with_config when None. Refusal (grounded:false) NOT
mapped to isError — agent branches on the wire payload's grounded flag.
AskOpts has no Default impl (must construct manually). Answer carries no
schema_version field (tagged inline via entry().or_insert_with, idempotent).
Mode defaulted to Lexical: reqwest::blocking::Client::build creates and
drops a tokio runtime, panicking inside async context — the empty-corpus
refusal test avoids this via spawn_blocking; the tool itself uses Lexical
as the default mode since MCP callers typically run without an embedding
provider configured.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
crates/kebab-mcp/src/lib.rs | 16 +++++
crates/kebab-mcp/src/tools/ask.rs | 63 +++++++++++++++++
crates/kebab-mcp/src/tools/mod.rs | 2 +-
crates/kebab-mcp/tests/tools_call_ask.rs | 89 ++++++++++++++++++++++++
4 files changed, 169 insertions(+), 1 deletion(-)
create mode 100644 crates/kebab-mcp/src/tools/ask.rs
create mode 100644 crates/kebab-mcp/tests/tools_call_ask.rs
diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs
index fc29941..009a1d3 100644
--- a/crates/kebab-mcp/src/lib.rs
+++ b/crates/kebab-mcp/src/lib.rs
@@ -65,6 +65,11 @@ impl ServerHandler for KebabHandler {
"Full-text / vector / hybrid search over the knowledge base. Returns search_hit.v1 array.",
schema_for_type::(),
),
+ Tool::new(
+ "ask",
+ "RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context.",
+ schema_for_type::(),
+ ),
]))
}
@@ -93,6 +98,17 @@ impl ServerHandler for KebabHandler {
};
Ok(tools::search::handle(&self.state, input))
}
+ "ask" => {
+ let args = request.arguments.unwrap_or_default();
+ let input: tools::ask::AskInput =
+ match serde_json::from_value(serde_json::Value::Object(args)) {
+ Ok(i) => i,
+ Err(e) => {
+ return Ok(error::to_tool_error(&anyhow::Error::from(e)));
+ }
+ };
+ Ok(tools::ask::handle(&self.state, input))
+ }
_other => Err(ErrorData::method_not_found::<
rmcp::model::CallToolRequestMethod,
>()),
diff --git a/crates/kebab-mcp/src/tools/ask.rs b/crates/kebab-mcp/src/tools/ask.rs
new file mode 100644
index 0000000..5f6eebd
--- /dev/null
+++ b/crates/kebab-mcp/src/tools/ask.rs
@@ -0,0 +1,63 @@
+//! `ask` tool — wraps `kebab_app::ask_with_config` (single-shot) or
+//! `kebab_app::ask_with_session_with_config` when `session_id` is provided.
+//! Input: { query, session_id? }. Output: answer.v1 JSON.
+//!
+//! `Answer` (kebab-core) does NOT carry a `schema_version` field; we tag
+//! it inline here, matching the pattern from `search.rs`.
+
+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 AskInput {
+ /// The user question.
+ pub query: String,
+ /// Optional session id for multi-turn RAG context.
+ pub session_id: Option,
+}
+
+pub fn handle(state: &KebabAppState, input: AskInput) -> CallToolResult {
+ // Default to Lexical mode — the MCP server is typically called by
+ // agent hosts that may not have an embedding provider configured.
+ // Hybrid/vector retrieval would hard-error when embeddings are
+ // disabled; lexical FTS is always available and covers the common
+ // RAG case well.
+ let opts = kebab_app::AskOpts {
+ k: 10,
+ explain: false,
+ mode: kebab_core::SearchMode::Lexical,
+ temperature: None,
+ seed: None,
+ stream_sink: None,
+ history: Vec::new(),
+ conversation_id: None,
+ turn_index: None,
+ };
+ let cfg_clone = (*state.config).clone();
+ let result = match input.session_id {
+ Some(sid) => {
+ kebab_app::ask_with_session_with_config(cfg_clone, &sid, &input.query, opts)
+ }
+ None => kebab_app::ask_with_config(cfg_clone, &input.query, opts),
+ };
+ match result {
+ Ok(answer) => {
+ // `Answer` does not carry `schema_version`; tag inline (idempotent
+ // via entry().or_insert_with in case a future version adds it).
+ let mut v = serde_json::to_value(&answer).unwrap_or_default();
+ if let serde_json::Value::Object(ref mut map) = v {
+ map.entry("schema_version".to_string())
+ .or_insert_with(|| serde_json::Value::String("answer.v1".to_string()));
+ }
+ match serde_json::to_string(&v) {
+ 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 19f52c0..3e3d898 100644
--- a/crates/kebab-mcp/src/tools/mod.rs
+++ b/crates/kebab-mcp/src/tools/mod.rs
@@ -3,4 +3,4 @@
pub mod schema;
pub mod doctor;
pub mod search;
-// pub mod ask; // wired in Plan Task 7
+pub mod ask;
diff --git a/crates/kebab-mcp/tests/tools_call_ask.rs b/crates/kebab-mcp/tests/tools_call_ask.rs
new file mode 100644
index 0000000..9b40676
--- /dev/null
+++ b/crates/kebab-mcp/tests/tools_call_ask.rs
@@ -0,0 +1,89 @@
+//! `ask` tool returns answer.v1 — refusal path covered (no Ollama
+//! required for refusal-on-empty-corpus case).
+
+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
+}
+
+#[tokio::test]
+async fn ask_tool_returns_answer_v1_with_refusal_on_empty_kb() {
+ let dir = tempfile::tempdir().unwrap();
+ let data_dir = dir.path().join("data");
+ let workspace_root = dir.path().join("notes");
+ std::fs::create_dir_all(&data_dir).unwrap();
+ std::fs::create_dir_all(&workspace_root).unwrap();
+
+ let cfg = minimal_config(&data_dir, &workspace_root);
+
+ // Seed kebab.sqlite (empty corpus — no documents ingested).
+ let scope = SourceScope {
+ root: workspace_root.clone(),
+ include: vec![],
+ exclude: vec![],
+ };
+ let _ = kebab_app::ingest_with_config(cfg.clone(), scope, false).unwrap();
+
+ let state = KebabAppState::new(cfg, None);
+ let handler = KebabHandler::new(state);
+
+ // `ask_with_config` builds a `reqwest::blocking::Client` internally (for
+ // `OllamaLanguageModel`), which spins up and drops a tokio runtime — that
+ // panics when called from inside an async context. Run it on the blocking
+ // thread pool to avoid the conflict.
+ let state_clone = handler.state().clone();
+ let result = tokio::task::spawn_blocking(move || {
+ kebab_mcp::tools::ask::handle(
+ &state_clone,
+ kebab_mcp::tools::ask::AskInput {
+ query: "what is the meaning of life".to_string(),
+ session_id: None,
+ },
+ )
+ })
+ .await
+ .unwrap();
+
+ // Empty KB → refusal (grounded:false) is normal — NOT isError.
+ assert!(
+ !result.is_error.unwrap_or(false),
+ "expected isError=false on refusal, got {:?}",
+ result
+ );
+
+ let content = result
+ .content
+ .first()
+ .expect("expected at least one content item");
+
+ let text = match &content.raw {
+ RawContent::Text(t) => &t.text,
+ other => panic!("expected text content, got {other:?}"),
+ };
+
+ let v: serde_json::Value = serde_json::from_str(text).unwrap();
+ assert_eq!(
+ v.get("schema_version").and_then(|s| s.as_str()),
+ Some("answer.v1"),
+ "response should carry schema_version=answer.v1"
+ );
+ assert_eq!(
+ v.get("grounded").and_then(|b| b.as_bool()),
+ Some(false),
+ "empty KB should produce grounded=false"
+ );
+}