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