🧪 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:
@@ -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}),
|
||||
|
||||
36
crates/kebab-mcp/tests/error_mapping.rs
Normal file
36
crates/kebab-mcp/tests/error_mapping.rs
Normal 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"));
|
||||
}
|
||||
Reference in New Issue
Block a user