use serde::Deserialize;
use serde_json::Value;
pub use crate::daemon::validate::VALID_INTERACTION_TYPES;
pub fn coerce_and_validate<T: for<'de> Deserialize<'de>>(value: Value) -> Result<T, CoercionError> {
if let Ok(result) = T::deserialize(&value) {
return Ok(result);
}
let coerced = apply_coercions(value)?;
T::deserialize(coerced)
.map_err(|e| CoercionError::new("Failed to deserialize parameter(s)", e, None))
}
fn apply_coercions(mut value: Value) -> Result<Value, CoercionError> {
let Some(obj) = value.as_object_mut() else {
return Ok(value);
};
for (key, val) in obj.iter_mut() {
match key.as_str() {
"content" | "parameters" => coerce_string_map(val)?,
"updates" => {
if let Value::Array(items) = val {
for item in items {
if let Some(content) = item.get_mut("content") {
coerce_string_map(content)?;
}
}
}
}
k if k.ends_with("_id") => coerce_scalar_to_string(val),
_ => {}
}
}
Ok(value)
}
fn coerce_string_map(val: &mut Value) -> Result<(), CoercionError> {
if let Value::Object(map) = val {
for v in map.values_mut() {
coerce_to_string(v)?;
}
}
Ok(())
}
fn coerce_scalar_to_string(val: &mut Value) {
match val {
Value::Number(n) => *val = Value::String(n.to_string()),
Value::Bool(b) => *val = Value::String(b.to_string()),
_ => {}
}
}
fn coerce_to_string(val: &mut Value) -> Result<(), CoercionError> {
match val {
Value::Number(_) | Value::Bool(_) => coerce_scalar_to_string(val),
Value::Object(_) | Value::Array(_) => {
let json = serde_json::to_string(val).map_err(|e| {
CoercionError::new(
"Failed to serialize nested value to JSON string",
e,
Some(val.clone()),
)
})?;
*val = Value::String(json);
}
Value::String(_) | Value::Null => {}
}
Ok(())
}
pub fn generate_recovery_suggestions(
error_message: &str,
parameter_path: Option<&str>,
received_value: Option<&Value>,
) -> Vec<String> {
let mut suggestions = Vec::new();
if error_message.contains("UUID") || error_message.contains("36-character") {
suggestions.push("Ensure the session_id is a valid 36-character UUID (e.g., '60c598e2-d602-4e07-a328-c458006d48c7')".to_string());
suggestions.push("Create a new session using the 'session' tool with action='create' to get a valid UUID".to_string());
if let Some(Value::String(s)) = received_value
&& s.len() != 36
{
suggestions.push(format!("Your session_id '{}' has {} characters, but UUIDs require exactly 36 characters with hyphens.", s, s.len()));
}
}
if error_message.contains("interaction_type")
|| error_message.contains("Unknown interaction type")
{
suggestions.push(format!(
"Valid interaction_type values are: {}",
VALID_INTERACTION_TYPES.join(", ")
));
suggestions.push("Use lowercase with underscores, not CamelCase or spaces".to_string());
suggestions
.push("Examples: ✅ 'decision_made' ❌ 'DecisionMade' ❌ 'made decision'".to_string());
}
if error_message.contains("content") && error_message.contains("required") {
suggestions.push(
"For single update mode, provide both 'interaction_type' and 'content' parameters"
.to_string(),
);
suggestions.push("For bulk updates, use 'updates' array instead".to_string());
suggestions.push("Content must be an object with key-value pairs".to_string());
}
if error_message.contains("invalid type") || error_message.contains("expected") {
if let Some(path) = parameter_path {
suggestions.push(format!("Parameter '{}' has an incorrect type", path));
}
if let Some(Value::Number(n)) = received_value {
suggestions.push(format!("Convert the number {} to a string", n));
}
if let Some(Value::Bool(b)) = received_value {
suggestions.push(format!("Convert the boolean {} to a string ('{}')", b, b));
}
}
if error_message.contains("required") || error_message.contains("missing") {
suggestions
.push("Check that all required parameters are included in your request".to_string());
suggestions.push(
"Review the tool schema to see which parameters are required vs optional".to_string(),
);
}
if error_message.contains("Session not found")
|| error_message.contains("session does not exist")
{
suggestions
.push("Create a new session using the 'session' tool with action='create'".to_string());
suggestions.push("Or use semantic_search to find existing sessions".to_string());
}
if error_message.contains("updates")
&& (error_message.contains("array") || error_message.contains("expected length"))
{
suggestions
.push("When using bulk mode, 'updates' must be an array of update objects".to_string());
suggestions.push(
"Each update in the array must have 'interaction_type' and 'content' fields"
.to_string(),
);
}
if suggestions.is_empty() {
suggestions
.push("Review the error message and check your parameter types and values".to_string());
suggestions
.push("Use dry_run=true to validate your request without making changes".to_string());
suggestions
.push("Check the tool documentation for the correct parameter format".to_string());
}
suggestions
}
#[derive(Debug, Clone)]
pub struct CoercionError {
pub message: String,
pub parameter_path: Option<String>,
pub expected_type: Option<String>,
pub received_value: Option<Value>,
pub hint: Option<String>,
}
impl CoercionError {
pub fn new(
message: &str,
source_error: impl std::error::Error,
received_value: Option<Value>,
) -> Self {
Self {
message: format!("{}: {}", message, source_error),
parameter_path: None,
expected_type: None,
received_value,
hint: None,
}
}
pub fn with_parameter_path(mut self, path: String) -> Self {
self.parameter_path = Some(path);
self
}
pub fn with_expected_type(mut self, type_desc: &str) -> Self {
self.expected_type = Some(type_desc.to_string());
self
}
pub fn with_hint(mut self, hint: &str) -> Self {
self.hint = Some(hint.to_string());
self
}
pub fn to_mcp_error(&self) -> rmcp::model::ErrorData {
let mut details = serde_json::json!({
"message": self.message,
});
if let Some(path) = &self.parameter_path {
details["parameter"] = serde_json::json!(path);
}
if let Some(expected) = &self.expected_type {
details["expectedType"] = serde_json::json!(expected);
}
if let Some(received) = &self.received_value {
details["receivedValue"] = received.clone();
}
if let Some(hint) = &self.hint {
details["hint"] = serde_json::json!(hint);
}
let suggestions = generate_recovery_suggestions(
&self.message,
self.parameter_path.as_deref(),
self.received_value.as_ref(),
);
if !suggestions.is_empty() {
details["suggestions"] = serde_json::json!(suggestions);
}
rmcp::model::ErrorData::invalid_params(
serde_json::to_string(&details).unwrap_or_default(),
None,
)
}
}
impl std::fmt::Display for CoercionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)?;
if let Some(path) = &self.parameter_path {
write!(f, " (parameter: {})", path)?;
}
if let Some(hint) = &self.hint {
write!(f, "\nHint: {}", hint)?;
}
Ok(())
}
}
impl std::error::Error for CoercionError {}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
#[test]
fn test_coerce_number_to_string() {
let value = json!({"session_id": 123});
let result: HashMap<String, String> = coerce_and_validate(value).unwrap();
assert_eq!(result.get("session_id"), Some(&"123".to_string()));
}
#[derive(Debug, Deserialize)]
struct UpdateReq {
session_id: String,
content: Option<HashMap<String, String>>,
updates: Option<Vec<UpdateItem>>,
dry_run: Option<bool>,
code_reference: Option<Value>,
}
#[derive(Debug, Deserialize)]
struct UpdateItem {
interaction_type: String,
content: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct SearchReq {
query: String,
scope_id: Option<String>,
limit: Option<usize>,
recency_bias: Option<f32>,
interaction_type: Option<Vec<String>>,
}
#[test]
fn test_coerce_content_values_to_strings() {
let value = json!({
"session_id": "s",
"content": {
"score": 2.71,
"count": 42,
"enabled": true,
"metadata": {"key": "value"},
"tags": ["tag1", "tag2"],
"text": "plain"
}
});
let req: UpdateReq = coerce_and_validate(value).unwrap();
let content = req.content.unwrap();
assert_eq!(content["score"], "2.71");
assert_eq!(content["count"], "42");
assert_eq!(content["enabled"], "true");
assert_eq!(content["metadata"], "{\"key\":\"value\"}");
assert_eq!(content["tags"], "[\"tag1\",\"tag2\"]");
assert_eq!(content["text"], "plain");
}
#[test]
fn test_coerce_keeps_typed_fields_intact() {
let value = json!({
"session_id": 7,
"dry_run": true,
"code_reference": {"file": "main.rs", "line": 3},
"updates": [
{"interaction_type": "qa", "content": {"question": "q", "n": 1}},
{"interaction_type": "code_change", "content": {"ok": false}}
]
});
let req: UpdateReq = coerce_and_validate(value).unwrap();
assert_eq!(req.session_id, "7");
assert_eq!(req.dry_run, Some(true));
assert_eq!(
req.code_reference,
Some(json!({"file": "main.rs", "line": 3}))
);
let updates = req.updates.unwrap();
assert_eq!(updates.len(), 2);
assert_eq!(updates[0].interaction_type, "qa");
assert_eq!(updates[0].content["n"], "1");
assert_eq!(updates[1].content["ok"], "false");
}
#[test]
fn test_coerce_does_not_stringify_numeric_and_array_params() {
let value = json!({
"query": "auth",
"scope_id": 42,
"limit": 5,
"recency_bias": 0.5,
"interaction_type": ["qa", "decision_made"]
});
let req: SearchReq = coerce_and_validate(value).unwrap();
assert_eq!(req.query, "auth");
assert_eq!(req.scope_id.as_deref(), Some("42"));
assert_eq!(req.limit, Some(5));
assert_eq!(req.recency_bias, Some(0.5));
assert_eq!(
req.interaction_type,
Some(vec!["qa".to_string(), "decision_made".to_string()])
);
}
#[test]
fn test_coerce_query_parameters_map() {
let value = json!({"parameters": {"limit": 10, "topic": "x"}});
#[derive(Deserialize)]
struct QueryReq {
parameters: HashMap<String, String>,
}
let req: QueryReq = coerce_and_validate(value).unwrap();
assert_eq!(req.parameters["limit"], "10");
assert_eq!(req.parameters["topic"], "x");
}
#[test]
fn test_coerce_unrelated_field_still_errors() {
let value = json!({"query": 123});
let result: Result<SearchReq, _> = coerce_and_validate(value);
assert!(result.is_err());
}
#[test]
fn test_fast_path_string_passes_through() {
let value = json!({"name": "test"});
let result: HashMap<String, String> = coerce_and_validate(value).unwrap();
assert_eq!(result.get("name"), Some(&"test".to_string()));
}
#[test]
fn test_coercion_error_with_path() {
let error = CoercionError::new(
"Test error",
std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid"),
Some(json!(123)),
)
.with_parameter_path("session_id".to_string())
.with_expected_type("UUID string")
.with_hint("Create a session first");
assert_eq!(error.parameter_path, Some("session_id".to_string()));
assert_eq!(error.expected_type, Some("UUID string".to_string()));
assert_eq!(error.hint, Some("Create a session first".to_string()));
}
#[test]
fn test_to_mcp_error_format() {
let error = CoercionError::new(
"Invalid parameter",
std::io::Error::new(std::io::ErrorKind::InvalidInput, "test"),
Some(json!(123)),
)
.with_parameter_path("session_id".to_string())
.with_expected_type("UUID string")
.with_hint("Use session tool to create");
let mcp_error = error.to_mcp_error();
let error_message = mcp_error.message;
assert!(error_message.contains("session_id"));
let error_data: serde_json::Value = serde_json::from_str(&error_message).unwrap();
assert_eq!(error_data["parameter"], "session_id");
assert_eq!(error_data["expectedType"], "UUID string");
assert_eq!(error_data["receivedValue"], 123);
assert_eq!(error_data["hint"], "Use session tool to create");
}
#[test]
fn test_recovery_suggestions_uuid_error() {
let suggestions = generate_recovery_suggestions(
"Invalid UUID format",
Some("session_id"),
Some(&json!("abc")),
);
assert!(suggestions.iter().any(|s| s.contains("36-character UUID")));
assert!(suggestions.iter().any(|s| s.contains("'session' tool")));
}
#[test]
fn test_recovery_suggestions_interaction_type_error() {
let suggestions = generate_recovery_suggestions(
"Unknown interaction type",
Some("interaction_type"),
Some(&json!("made_decision")),
);
assert!(suggestions.iter().any(|s| s.contains("decision_made")));
assert!(
suggestions
.iter()
.any(|s| s.contains("lowercase with underscores"))
);
}
#[test]
fn test_recovery_suggestions_type_error() {
let suggestions = generate_recovery_suggestions(
"invalid type: integer `123`, expected a string",
Some("session_id"),
Some(&json!(123)),
);
assert!(
suggestions
.iter()
.any(|s| s.contains("Convert the number 123"))
);
}
#[test]
fn test_recovery_suggestions_content_required() {
let suggestions =
generate_recovery_suggestions("content is required", Some("content"), None);
assert!(suggestions.iter().any(|s| s.contains("interaction_type")));
assert!(suggestions.iter().any(|s| s.contains("bulk updates")));
}
#[test]
fn test_recovery_suggestions_session_not_found() {
let suggestions = generate_recovery_suggestions("Session not found", None, None);
assert!(suggestions.iter().any(|s| s.contains("'session' tool")));
assert!(suggestions.iter().any(|s| s.contains("semantic_search")));
}
#[test]
fn test_recovery_suggestions_includes_suggestions_in_mcp_error() {
let error = CoercionError::new(
"Invalid UUID format",
std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid"),
Some(json!("short-id")),
)
.with_parameter_path("session_id".to_string());
let mcp_error = error.to_mcp_error();
let error_data: serde_json::Value = serde_json::from_str(&mcp_error.message).unwrap();
assert!(error_data["suggestions"].is_array());
let suggestions = error_data["suggestions"].as_array().unwrap();
assert!(!suggestions.is_empty());
}
#[test]
fn test_recovery_suggestions_general_fallback() {
let suggestions = generate_recovery_suggestions("Some unknown error", None, None);
assert!(suggestions.iter().any(|s| s.contains("dry_run")));
assert!(suggestions.iter().any(|s| s.contains("parameter types")));
}
}