🧪 test(kebab-mcp): error mapping — bad config → error.v1 (fb-30)

Adds integration test schema_tool_emits_error_v1_when_db_missing that
verifies NotIndexed errors are emitted as error.v1 JSON with isError=true.
Also fixes ErrorV1 struct to include required schema_version field per
error.v1 wire contract (docs/wire-schema/v1/error.schema.json).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-07 15:58:52 +09:00
parent c8e04c65e0
commit f9a1548b53
2 changed files with 46 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ use crate::error_signal::{ConfigInvalid, LlmError, NotIndexed};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorV1 {
pub schema_version: String,
pub code: String,
pub message: String,
pub details: Value,
@@ -22,6 +23,7 @@ pub struct ErrorV1 {
pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
if let Some(s) = err.downcast_ref::<ConfigInvalid>() {
return ErrorV1 {
schema_version: "error.v1".to_string(),
code: "config_invalid".to_string(),
message: s.to_string(),
details: json!({
@@ -33,6 +35,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
}
if let Some(s) = err.downcast_ref::<NotIndexed>() {
return ErrorV1 {
schema_version: "error.v1".to_string(),
code: "not_indexed".to_string(),
message: s.to_string(),
details: json!({
@@ -47,6 +50,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
}
if let Some(io) = err.downcast_ref::<std::io::Error>() {
return ErrorV1 {
schema_version: "error.v1".to_string(),
code: "io_error".to_string(),
message: io.to_string(),
details: json!({"kind": format!("{:?}", io.kind())}),
@@ -59,6 +63,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
details = json!({"chain": chain});
}
ErrorV1 {
schema_version: "error.v1".to_string(),
code: "generic".to_string(),
message: err.to_string(),
details,
@@ -69,6 +74,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
fn classify_llm(s: &LlmError) -> ErrorV1 {
match s {
LlmError::Unreachable { endpoint, source } => ErrorV1 {
schema_version: "error.v1".to_string(),
code: "model_unreachable".to_string(),
message: format!("ollama unreachable at {endpoint}"),
details: json!({
@@ -78,24 +84,28 @@ fn classify_llm(s: &LlmError) -> ErrorV1 {
hint: Some(format!("ensure `ollama serve` is reachable at {endpoint}")),
},
LlmError::ModelNotPulled(model) => ErrorV1 {
schema_version: "error.v1".to_string(),
code: "model_not_pulled".to_string(),
message: format!("ollama model `{model}` is not pulled"),
details: json!({"model": model}),
hint: Some(format!("run `ollama pull {model}`")),
},
LlmError::Timeout(e) => ErrorV1 {
schema_version: "error.v1".to_string(),
code: "timeout".to_string(),
message: format!("ollama timeout: {e}"),
details: json!({"source": e.to_string()}),
hint: Some("increase timeout or check Ollama load".to_string()),
},
LlmError::Stream(body) => ErrorV1 {
schema_version: "error.v1".to_string(),
code: "generic".to_string(),
message: format!("ollama HTTP error: {body}"),
details: json!({"body": body}),
hint: None,
},
LlmError::Malformed(line) => ErrorV1 {
schema_version: "error.v1".to_string(),
code: "generic".to_string(),
message: format!("malformed response line: {line}"),
details: json!({"line": line}),

View File

@@ -0,0 +1,36 @@
//! tools/call with bad config → isError=true + error.v1 content.
use kebab_config::Config;
use kebab_mcp::{KebabAppState, KebabHandler};
use rmcp::model::RawContent;
#[tokio::test]
async fn schema_tool_emits_error_v1_when_db_missing() {
// Point at a directory that does NOT have kebab.sqlite.
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config::defaults();
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
cfg.workspace.root = dir.path().join("notes").to_string_lossy().into_owned();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
// Note: NO ingest call — kebab.sqlite is absent → schema_with_config
// calls open_existing → NotIndexed → tool error.
let state = KebabAppState::new(cfg, None);
let handler = KebabHandler::new(state);
let result = kebab_mcp::tools::schema::handle(
handler.state(),
kebab_mcp::tools::schema::SchemaInput::default(),
);
assert_eq!(result.is_error, Some(true), "expected isError=true on missing DB");
let content = result.content.first().unwrap();
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("error.v1"));
assert_eq!(v.get("code").and_then(|s| s.as_str()), Some("not_indexed"));
}