🏗️ refactor(kebab-mcp): fix ask tool production panic + mode default (fb-30)

Two issues from Task 7 review:

1. CRITICAL — call_tool "ask" arm called blocking ask handle from async
   context. OllamaLanguageModel::new builds reqwest::blocking::Client
   which creates+drops a tokio runtime → panic inside async. Fix:
   tokio::task::spawn_blocking wrap. Also applied preemptively to
   "search" arm (SqliteStore + Lance open are blocking IO too).

2. IMPORTANT — ask tool's retrieval mode hardcoded to Lexical (test
   workaround for provider="none"); CLI default is Hybrid. Fix: add
   `mode: Option<String>` field to AskInput, default Hybrid in handle,
   test passes mode=Some("lexical") explicitly to keep test functional
   on provider="none".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-07 15:55:02 +09:00
parent 4b1b8a15bf
commit c8e04c65e0
3 changed files with 30 additions and 9 deletions

View File

@@ -96,7 +96,15 @@ impl ServerHandler for KebabHandler {
return Ok(error::to_tool_error(&anyhow::Error::from(e)));
}
};
Ok(tools::search::handle(&self.state, input))
let state = self.state.clone();
let result = tokio::task::spawn_blocking(move || {
tools::search::handle(&state, input)
})
.await
.map_err(|e| {
ErrorData::internal_error(e.to_string(), None)
})?;
Ok(result)
}
"ask" => {
let args = request.arguments.unwrap_or_default();
@@ -107,7 +115,15 @@ impl ServerHandler for KebabHandler {
return Ok(error::to_tool_error(&anyhow::Error::from(e)));
}
};
Ok(tools::ask::handle(&self.state, input))
let state = self.state.clone();
let result = tokio::task::spawn_blocking(move || {
tools::ask::handle(&state, input)
})
.await
.map_err(|e| {
ErrorData::internal_error(e.to_string(), None)
})?;
Ok(result)
}
_other => Err(ErrorData::method_not_found::<
rmcp::model::CallToolRequestMethod,

View File

@@ -1,6 +1,6 @@
//! `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.
//! Input: { query, session_id?, mode? }. 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`.
@@ -18,18 +18,20 @@ pub struct AskInput {
pub query: String,
/// Optional session id for multi-turn RAG context.
pub session_id: Option<String>,
/// Optional retrieval mode override ("lexical" / "vector" / "hybrid"). Default "hybrid".
pub mode: Option<String>,
}
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 mode = match input.mode.as_deref() {
Some("lexical") => kebab_core::SearchMode::Lexical,
Some("vector") => kebab_core::SearchMode::Vector,
_ => kebab_core::SearchMode::Hybrid, // default + "hybrid" + unknown
};
let opts = kebab_app::AskOpts {
k: 10,
explain: false,
mode: kebab_core::SearchMode::Lexical,
mode,
temperature: None,
seed: None,
stream_sink: None,

View File

@@ -52,6 +52,9 @@ async fn ask_tool_returns_answer_v1_with_refusal_on_empty_kb() {
kebab_mcp::tools::ask::AskInput {
query: "what is the meaning of life".to_string(),
session_id: None,
// Test env uses provider="none" — Hybrid would hard-error on embedding.
// Pass Lexical explicitly so the test stays functional.
mode: Some("lexical".to_string()),
},
)
})