use rmcp::model::{CallToolResult, Content};
use memstead_base::runtime_validator::ValidationError;
pub fn validation_envelope(err: ValidationError) -> CallToolResult {
let code = err.code();
let details = err.details();
let message = err.prose_render();
let payload = serde_json::json!({
"code": code,
"message": message,
"details": details,
});
let text = format!("ERROR [{code}]: {message}");
let mut result = CallToolResult::error(vec![Content::text(text)]);
result.structured_content = Some(payload);
result
}
pub fn engine_lock_poisoned() -> CallToolResult {
crate::error_envelope::tool_error(
"ENGINE_LOCK_POISONED",
"engine lock poisoned by a prior panic — restart the MCP server \
(state reloads from the mem-repo on boot)",
)
}
#[cfg(test)]
mod inline_list_tests {
use super::*;
use memstead_base::runtime_validator::RelationshipHint;
fn extract_text(r: &CallToolResult) -> String {
r.content
.iter()
.filter_map(|c| c.as_text())
.map(|t| t.text.clone())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn unknown_section_envelope_inlines_every_declared_key() {
let declared = (0..6).map(|i| format!("sec{i}")).collect::<Vec<_>>();
let r = validation_envelope(ValidationError::UnknownSection {
key: "typo".to_string(),
entity_type: "spec".to_string(),
declared: declared.clone(),
suggestion: Some("sec0".to_string()),
});
let text = extract_text(&r);
assert!(text.starts_with("ERROR [UNKNOWN_SECTION]: "), "got: {text}");
for d in &declared {
assert!(
text.contains(d.as_str()),
"every declared key must appear inline; missing {d} in: {text}"
);
}
assert!(text.contains("Did you mean 'sec0'?"), "got: {text}");
assert!(
!text.contains("see details"),
"text channel must not point at the structured channel; got: {text}"
);
let sc = r.structured_content.expect("payload");
assert_eq!(
sc["details"]["declared"].as_array().unwrap().len(),
declared.len()
);
}
#[test]
fn unknown_section_envelope_lists_all_when_under_cap() {
let declared = vec!["sec0".to_string(), "sec1".to_string()];
let r = validation_envelope(ValidationError::UnknownSection {
key: "typo".to_string(),
entity_type: "spec".to_string(),
declared,
suggestion: None,
});
let text = extract_text(&r);
assert!(
text.contains("declared sections: sec0, sec1"),
"got: {text}"
);
assert!(!text.contains("see details"), "got: {text}");
}
#[test]
fn invalid_enum_value_envelope_inlines_allowed_values() {
let allowed = (0..10).map(|i| format!("v{i}")).collect::<Vec<_>>();
let r = validation_envelope(ValidationError::InvalidEnumValue {
field: "status".to_string(),
value: "bogus".to_string(),
allowed: allowed.clone(),
field_description: None,
suggestion: None,
type_write_rules: vec![],
entity_type: "decision".to_string(),
});
let text = extract_text(&r);
for a in &allowed {
assert!(
text.contains(a.as_str()),
"every allowed value must appear inline; missing {a} in: {text}"
);
}
assert!(!text.contains("see details"), "got: {text}");
}
#[test]
fn invalid_rel_type_envelope_inlines_rel_type_names() {
let allowed: Vec<RelationshipHint> = (0..5)
.map(|i| RelationshipHint {
name: format!("REL{i}"),
when_to_use: None,
})
.collect();
let r = validation_envelope(ValidationError::InvalidRelationshipType {
input: "BOGUS".to_string(),
allowed,
suggestion: None,
});
let text = extract_text(&r);
for n in ["REL0", "REL1", "REL2", "REL3", "REL4"] {
assert!(
text.contains(n),
"every rel-type name must appear inline; missing {n} in: {text}"
);
}
assert!(!text.contains("see details"), "got: {text}");
}
}