From f9a1548b5303abe0ebdde9db864bf977902b7112 Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Thu, 7 May 2026 15:58:52 +0900
Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20test(kebab-mcp):=20error=20mappi?=
=?UTF-8?q?ng=20=E2=80=94=20bad=20config=20=E2=86=92=20error.v1=20(fb-30)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
crates/kebab-app/src/error_wire.rs | 10 +++++++
crates/kebab-mcp/tests/error_mapping.rs | 36 +++++++++++++++++++++++++
2 files changed, 46 insertions(+)
create mode 100644 crates/kebab-mcp/tests/error_mapping.rs
diff --git a/crates/kebab-app/src/error_wire.rs b/crates/kebab-app/src/error_wire.rs
index 62108c6..192cf7c 100644
--- a/crates/kebab-app/src/error_wire.rs
+++ b/crates/kebab-app/src/error_wire.rs
@@ -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::() {
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::() {
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::() {
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}),
diff --git a/crates/kebab-mcp/tests/error_mapping.rs b/crates/kebab-mcp/tests/error_mapping.rs
new file mode 100644
index 0000000..739d986
--- /dev/null
+++ b/crates/kebab-mcp/tests/error_mapping.rs
@@ -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"));
+}