fix(mcp): propagate internal serialization errors instead of silently swallowing them to prevent LLM hallucination

This commit is contained in:
Riz Ashraf committed 2026-09-27 22:19:43 +01:00
1 parent f8925050db
commit 8f32a09399
13 files changed
+235 -63

No files matched your search

+43 -12
View File
@@ -151,8 +151,14 @@ impl McpTool for CreateRelationsHandler {
Ok(r) => r,
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("missing field `from`") || err_msg.contains("missing field `to`") || err_msg.contains("missing field `relation_type`") {
return Err(format!("Schema error: {}. Note that the relation schema strictly uses 'from', 'to', and 'relation_type' (not 'source', 'target', or 'relationType'). Please correct your tool call arguments.", err_msg));
if err_msg.contains("missing field `from`")
|| err_msg.contains("missing field `to`")
|| err_msg.contains("missing field `relation_type`")
{
return Err(format!(
"Schema error: {}. Note that the relation schema strictly uses 'from', 'to', and 'relation_type' (not 'source', 'target', or 'relationType'). Please correct your tool call arguments.",
err_msg
));
}
return Err(err_msg);
}
@@ -166,15 +172,22 @@ impl McpTool for CreateRelationsHandler {
if from_exists && to_exists {
g.relations.push(relation);
} else {
if !from_exists { missing_nodes.insert(relation.from); }
if !to_exists { missing_nodes.insert(relation.to); }
if !from_exists {
missing_nodes.insert(relation.from);
}
if !to_exists {
missing_nodes.insert(relation.to);
}
}
}
}
});
if !missing_nodes.is_empty() {
let missing: Vec<_> = missing_nodes.into_iter().collect();
return Err(format!("Error: Relations dropped due to missing entities: {}", missing.join(", ")));
return Err(format!(
"Error: Relations dropped due to missing entities: {}",
missing.join(", ")
));
}
Ok("Relations created".to_string())
}
@@ -205,7 +218,10 @@ impl McpTool for AddObservationsHandler {
}
});
if !missing_entities.is_empty() {
return Err(format!("Error: Observations dropped for missing entities: {}", missing_entities.join(", ")));
return Err(format!(
"Error: Observations dropped for missing entities: {}",
missing_entities.join(", ")
));
}
Ok("Observations added".to_string())
}
@@ -239,7 +255,10 @@ impl McpTool for DeleteEntitiesHandler {
});
if !missing.is_empty() {
return Err(format!("Error: Entities not found: {}. Please use the search_nodes or read_graph tools to verify the exact entity names.", missing.join(", ")));
return Err(format!(
"Error: Entities not found: {}. Please use the search_nodes or read_graph tools to verify the exact entity names.",
missing.join(", ")
));
}
let idx = state.get_search_index();
@@ -280,7 +299,10 @@ impl McpTool for DeleteObservationsHandler {
}
});
if !missing.is_empty() {
return Err(format!("Error: Entities not found: {}. Please use the search_nodes or read_graph tools to verify the exact entity names.", missing.join(", ")));
return Err(format!(
"Error: Entities not found: {}. Please use the search_nodes or read_graph tools to verify the exact entity names.",
missing.join(", ")
));
}
Ok("Observations deleted".to_string())
}
@@ -308,7 +330,10 @@ impl McpTool for DeleteRelationsHandler {
missing_count = to_rem.len() - (initial_len - master.relations.len());
});
if missing_count > 0 {
return Err(format!("Error: {} relation(s) not found in graph. Please verify exact relation properties using read_graph.", missing_count));
return Err(format!(
"Error: {} relation(s) not found in graph. Please verify exact relation properties using read_graph.",
missing_count
));
}
Ok("Relations deleted".to_string())
}
@@ -536,7 +561,10 @@ impl McpTool for CondenseEntityHandler {
}
});
if missing {
return Err(format!("Error: Entity '{}' not found. Please verify the exact entity name using search_nodes.", req.entity_name));
return Err(format!(
"Error: Entity '{}' not found. Please verify the exact entity name using search_nodes.",
req.entity_name
));
}
Ok("Entity condensed".to_string())
}
@@ -588,7 +616,10 @@ impl McpTool for MergeEntitiesHandler {
});
});
if missing {
return Err(format!("Error: Source entity '{}' not found. Please verify the exact entity name using search_nodes.", req.source_entity));
return Err(format!(
"Error: Source entity '{}' not found. Please verify the exact entity name using search_nodes.",
req.source_entity
));
}
Ok("Entities merged".to_string())
}
@@ -691,7 +722,7 @@ mod tests {
});
let res = handler.execute(args, state.clone()).await.unwrap();
assert_eq!(res, "Relations created");
// Test semantic LLM schema feedback (User request)
let bad_args = json!({
"relations": [
+27 -12
View File
@@ -98,11 +98,15 @@ impl McpTool for DeleteDecisionHandler {
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<crate::tools::DeleteDecisionTool>("delete_decision", "Delete an architectural decision record")
crate::mcp::tool_def::<crate::tools::DeleteDecisionTool>(
"delete_decision",
"Delete an architectural decision record",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: crate::tools::DeleteDecisionTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: crate::tools::DeleteDecisionTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut found = false;
state.adrs.modify(|adrs| {
if let Some(pos) = adrs.iter().position(|a| a.id == req.id) {
@@ -110,7 +114,7 @@ impl McpTool for DeleteDecisionHandler {
found = true;
}
});
if found {
state.rebuild_index().await;
Ok("Decision deleted successfully".to_string())
@@ -337,7 +341,10 @@ impl McpTool for ResolveTechDebtHandler {
if found {
Ok("Tech debt resolved".to_string())
} else {
Err("Tech debt not found. Please verify the tech debt ID using list_tech_debt.".to_string())
Err(
"Tech debt not found. Please verify the tech debt ID using list_tech_debt."
.to_string(),
)
}
}
}
@@ -385,7 +392,10 @@ impl McpTool for OmniSearchHandler {
let req: OmniSearchTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let limit = req.limit.unwrap_or(5);
let include_body = req.include_body.unwrap_or(false);
let matches = match state.get_search_index().search(&req.query, req.namespace.as_deref()) {
let matches = match state
.get_search_index()
.search(&req.query, req.namespace.as_deref())
{
Ok(m) => m,
Err(e) => {
return Err(format!(
@@ -403,7 +413,9 @@ impl McpTool for OmniSearchHandler {
if doc_type == "entity"
&& let Some(e) = full.entities.get(id)
{
if count >= limit { continue; }
if count >= limit {
continue;
}
count += 1;
if !include_body {
let mut summary = e.clone();
@@ -792,13 +804,13 @@ mod tests {
dependencies: vec![],
acceptance_criteria: vec![],
};
{
state.tasks.modify(|t| {
t.push(task.clone());
});
}
state.rebuild_index().await;
state.get_search_index().reader.reload().unwrap();
@@ -808,21 +820,24 @@ mod tests {
.await
.unwrap();
println!("OMNI RES: {}", omni_res);
assert!(omni_res.contains("omni-1"), "omni search should return results containing the task id");
assert!(
omni_res.contains("omni-1"),
"omni search should return results containing the task id"
);
}
#[tokio::test]
async fn test_omni_search_malformed_query() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let omni = OmniSearchHandler;
// Pass a malformed Lucene query (unclosed parenthesis)
let omni_res = omni
.execute(json!({"query": "title: (unclosed"}), state.clone())
.await;
assert!(omni_res.is_err());
let err_msg = omni_res.unwrap_err();
assert!(err_msg.contains("malformed Lucene syntax"));
+4 -1
View File
@@ -444,7 +444,10 @@ impl McpTool for UpdateMilestoneHandler {
if found {
Ok("Milestone updated".to_string())
} else {
Err("Milestone not found. Please verify the milestone ID using list_milestones.".to_string())
Err(
"Milestone not found. Please verify the milestone ID using list_milestones."
.to_string(),
)
}
}
}
+13 -4
View File
@@ -185,7 +185,10 @@ impl McpTool for DeleteSnippetHandler {
drop(idx.delete_document(&req.name));
Ok("Snippet deleted.".to_string())
} else {
Err("Snippet not found. Please verify the snippet ID using search_snippets.".to_string())
Err(
"Snippet not found. Please verify the snippet ID using search_snippets."
.to_string(),
)
}
}
}
@@ -270,7 +273,10 @@ impl McpTool for ListContextWorkspacesHandler {
let req: ListContextWorkspacesTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let data = state.context_workspaces.read_with(|ws| {
let filtered: Vec<_> = ws.iter().filter(|w| req.namespace.as_ref().map_or(true, |ns| &w.namespace == ns)).collect();
let filtered: Vec<_> = ws
.iter()
.filter(|w| req.namespace.as_ref().map_or(true, |ns| &w.namespace == ns))
.collect();
serde_json::to_string(&filtered).map_err(|e| e.to_string())
})?;
Ok(data)
@@ -295,10 +301,13 @@ impl McpTool for DeleteContextWorkspaceHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: crate::tools::DeleteContextWorkspaceTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut found = false;
state.context_workspaces.modify(|ws| {
if let Some(pos) = ws.iter().position(|w| w.namespace == req.namespace && w.name == req.name) {
if let Some(pos) = ws
.iter()
.position(|w| w.namespace == req.namespace && w.name == req.name)
{
ws.remove(pos);
found = true;
}