✨ feat(kebab-mcp): ask tool (fb-30)

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) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-07 15:50:57 +09:00
parent 52782fdf72
commit 4b1b8a15bf
4 changed files with 169 additions and 1 deletions

View File

@@ -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<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 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),
}
}