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