use std::path::Path;
use rmcp::ErrorData as McpError;
use serde_json::{Value, json};
use sqry_nl::NlError;
use crate::error::DaemonError;
const KIND_DEADLINE_EXCEEDED: &str = "deadline_exceeded";
const KIND_VALIDATION_ERROR: &str = "validation_error";
const KIND_WORKSPACE_NOT_READY: &str = "workspace_not_ready";
const KIND_WORKSPACE_STALE_EXPIRED: &str = "workspace_stale_expired";
const KIND_WORKSPACE_INCOMPATIBLE_GRAPH: &str = "workspace_incompatible_graph";
const KIND_INTERNAL: &str = "internal";
const KIND_QUERY_TOO_BROAD: &str = "query_too_broad";
pub(crate) const KIND_ONNX_RUNTIME_MISSING: &str = "ONNX_RUNTIME_MISSING";
#[must_use]
pub fn try_onnx_runtime_missing_to_mcp(err: &NlError) -> Option<McpError> {
match err {
NlError::OnnxRuntimeMissing { hint } => Some(onnx_runtime_missing_mcp(hint)),
_ => None,
}
}
#[must_use]
pub fn onnx_runtime_missing_mcp(hint: &str) -> McpError {
let data = json!({
"kind": KIND_ONNX_RUNTIME_MISSING,
"retryable": false,
"retry_after_ms": Value::Null,
"details": {
"code": KIND_ONNX_RUNTIME_MISSING,
"message": hint,
"retriable": false,
},
});
McpError::internal_error(format!("ONNX Runtime not found: {hint}"), Some(data))
}
fn mcp_timeout_error(root: &Path, secs: u64, tool_name: Option<&str>) -> McpError {
let deadline_ms = secs.saturating_mul(1000);
let tool_value = match tool_name {
Some(name) => Value::String(name.to_owned()),
None => Value::Null,
};
let data = json!({
"kind": KIND_DEADLINE_EXCEEDED,
"retryable": true,
"retry_after_ms": 500,
"details": {
"tool": tool_value,
"deadline_ms": deadline_ms,
}
});
McpError::internal_error(
format!(
"tool invocation exceeded deadline of {deadline_ms}ms for workspace {}",
root.display()
),
Some(data),
)
}
pub fn daemon_err_to_mcp(e: DaemonError) -> McpError {
match e {
DaemonError::ToolTimeout { root, secs, .. } => mcp_timeout_error(&root, secs, None),
DaemonError::InvalidArgument { reason } => {
let data = json!({
"kind": KIND_VALIDATION_ERROR,
"retryable": false,
"retry_after_ms": Value::Null,
"details": { "reason": reason.clone() },
});
McpError::invalid_params(format!("invalid argument: {reason}"), Some(data))
}
DaemonError::RpcErrorPreserved(rpc) => {
let data = json!({
"kind": rpc.kind,
"retryable": rpc.retryable,
"retry_after_ms": rpc.retry_after_ms,
"details": rpc.details,
});
match rpc.code {
-32602 => McpError::invalid_params(rpc.message, Some(data)),
_ => McpError::internal_error(rpc.message, Some(data)),
}
}
DaemonError::Internal(err) => {
let data = json!({
"kind": KIND_INTERNAL,
"retryable": false,
"retry_after_ms": Value::Null,
"details": Value::Null,
});
McpError::internal_error(format!("internal error: {err}"), Some(data))
}
DaemonError::WorkspaceBuildFailed { root, reason } => {
let data = json!({
"kind": KIND_WORKSPACE_NOT_READY,
"retryable": true,
"retry_after_ms": 2000,
"details": {
"root": root.display().to_string(),
"reason": reason.clone(),
},
});
McpError::internal_error(format!("workspace build failed: {reason}"), Some(data))
}
DaemonError::WorkspaceStaleExpired {
root,
age_hours,
cap_hours,
last_good_at,
last_error,
} => {
let last_good_at_str = last_good_at.map(|t| {
chrono::DateTime::<chrono::Utc>::from(t)
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
});
let data = json!({
"kind": KIND_WORKSPACE_STALE_EXPIRED,
"retryable": false,
"retry_after_ms": Value::Null,
"details": {
"root": root.display().to_string(),
"age_hours": age_hours,
"cap_hours": cap_hours,
"last_good_at": last_good_at_str,
"last_error": last_error,
},
});
McpError::internal_error(
format!(
"workspace {} stale ({age_hours}h > {cap_hours}h cap)",
root.display()
),
Some(data),
)
}
DaemonError::WorkspaceIncompatibleGraph { root, reason } => {
let data = json!({
"kind": KIND_WORKSPACE_INCOMPATIBLE_GRAPH,
"retryable": false,
"retry_after_ms": Value::Null,
"details": {
"root": root.display().to_string(),
"reason": reason.clone(),
},
});
McpError::internal_error(
format!(
"workspace {} graph is incompatible with this binary: {reason}",
root.display()
),
Some(data),
)
}
DaemonError::QueryTooBroad { reason, details } => {
let data = json!({
"kind": KIND_QUERY_TOO_BROAD,
"retryable": false,
"retry_after_ms": Value::Null,
"details": details,
});
McpError::invalid_params(format!("query rejected: {reason}"), Some(data))
}
other => {
let data = json!({
"kind": KIND_INTERNAL,
"retryable": false,
"retry_after_ms": Value::Null,
"details": Value::Null,
});
McpError::internal_error(format!("{other}"), Some(data))
}
}
}
pub fn daemon_err_to_mcp_with_tool(e: DaemonError, tool_name: &str) -> McpError {
match e {
DaemonError::ToolTimeout { root, secs, .. } => {
mcp_timeout_error(&root, secs, Some(tool_name))
}
other => daemon_err_to_mcp(other),
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn tool_timeout_envelope_has_canonical_shape() {
let err = DaemonError::ToolTimeout {
root: PathBuf::from("/tmp/ws"),
secs: 60,
deadline_ms: 60_000,
};
let mcp_err = daemon_err_to_mcp(err);
let data = mcp_err.data.as_ref().unwrap().as_object().unwrap();
assert_eq!(data["kind"], KIND_DEADLINE_EXCEEDED);
assert_eq!(data["retryable"], true);
assert_eq!(data["retry_after_ms"], 500);
let details = data["details"].as_object().unwrap();
assert!(details["tool"].is_null());
assert_eq!(details["deadline_ms"], 60_000);
assert!(
!details.contains_key("root"),
"details must not include `root`; the standalone envelope omits it"
);
}
#[test]
fn tool_timeout_with_tool_populates_details() {
let err = DaemonError::ToolTimeout {
root: PathBuf::from("/tmp/ws"),
secs: 60,
deadline_ms: 60_000,
};
let mcp_err = daemon_err_to_mcp_with_tool(err, "semantic_search");
let data = mcp_err.data.as_ref().unwrap().as_object().unwrap();
assert_eq!(data["details"]["tool"], "semantic_search");
assert_eq!(data["details"]["deadline_ms"], 60_000);
assert_eq!(data["kind"], KIND_DEADLINE_EXCEEDED);
assert_eq!(data["retryable"], true);
assert_eq!(data["retry_after_ms"], 500);
}
#[test]
fn invalid_argument_envelope_canonical() {
let err = DaemonError::InvalidArgument {
reason: "missing path".into(),
};
let mcp_err = daemon_err_to_mcp(err);
let data = mcp_err.data.as_ref().unwrap().as_object().unwrap();
assert_eq!(data["kind"], KIND_VALIDATION_ERROR);
assert_eq!(data["retryable"], false);
assert!(data["retry_after_ms"].is_null());
assert_eq!(data["details"]["reason"], "missing path");
assert_eq!(mcp_err.code.0, -32602);
}
#[test]
fn rpc_error_preserved_validation_emits_invalid_params() {
let rpc = sqry_mcp::error::RpcError::validation_with_data(
"budget_rows must be > 0".to_string(),
json!({
"kind": "validation",
"constraint": "range",
"field": "budget_rows",
"min": 1,
"actual": 0,
}),
);
let err = DaemonError::RpcErrorPreserved(rpc);
let mcp_err = daemon_err_to_mcp(err);
assert_eq!(mcp_err.code.0, -32602);
let data = mcp_err.data.as_ref().unwrap().as_object().unwrap();
assert_eq!(data["kind"], "validation_error");
assert_eq!(data["retryable"], false);
assert!(data["retry_after_ms"].is_null());
let details = data["details"].as_object().unwrap();
assert_eq!(details["field"], "budget_rows");
assert_eq!(details["constraint"], "range");
assert_eq!(details["min"], 1);
assert_eq!(details["actual"], 0);
assert_eq!(mcp_err.message, "budget_rows must be > 0");
}
#[test]
fn internal_envelope_has_null_details() {
let err = DaemonError::Internal(anyhow::anyhow!("boom"));
let mcp_err = daemon_err_to_mcp(err);
let data = mcp_err.data.as_ref().unwrap().as_object().unwrap();
assert_eq!(data["kind"], KIND_INTERNAL);
assert_eq!(data["retryable"], false);
assert!(data["retry_after_ms"].is_null());
assert!(data["details"].is_null());
assert!(mcp_err.message.contains("boom"));
}
#[test]
fn workspace_build_failed_envelope() {
let err = DaemonError::WorkspaceBuildFailed {
root: PathBuf::from("/repo"),
reason: "plugin panic".into(),
};
let mcp_err = daemon_err_to_mcp(err);
let data = mcp_err.data.as_ref().unwrap().as_object().unwrap();
assert_eq!(data["kind"], KIND_WORKSPACE_NOT_READY);
assert_eq!(data["retryable"], true);
assert_eq!(data["retry_after_ms"], 2000);
assert_eq!(data["details"]["root"], "/repo");
assert_eq!(data["details"]["reason"], "plugin panic");
}
#[test]
fn workspace_stale_expired_envelope_with_last_good_emits_rfc3339() {
use std::time::{Duration, UNIX_EPOCH};
let last_good = UNIX_EPOCH + Duration::from_secs(1_760_000_000);
let err = DaemonError::WorkspaceStaleExpired {
root: PathBuf::from("/repo"),
age_hours: 48,
cap_hours: 24,
last_good_at: Some(last_good),
last_error: Some("parse error".into()),
};
let mcp_err = daemon_err_to_mcp(err);
let data = mcp_err.data.as_ref().unwrap().as_object().unwrap();
assert_eq!(data["kind"], KIND_WORKSPACE_STALE_EXPIRED);
assert_eq!(data["retryable"], false);
assert!(data["retry_after_ms"].is_null());
assert_eq!(data["details"]["age_hours"], 48);
assert_eq!(data["details"]["cap_hours"], 24);
assert_eq!(data["details"]["last_error"], "parse error");
let last_good_str = data["details"]["last_good_at"].as_str().unwrap();
assert!(
last_good_str.ends_with('Z'),
"expected RFC3339 UTC-Zulu form, got: {last_good_str}"
);
}
#[test]
fn envelope_has_exactly_four_top_level_keys() {
use std::collections::BTreeSet;
let errs = vec![
DaemonError::ToolTimeout {
root: PathBuf::from("/"),
secs: 1,
deadline_ms: 1000,
},
DaemonError::InvalidArgument { reason: "x".into() },
DaemonError::Internal(anyhow::anyhow!("y")),
DaemonError::WorkspaceBuildFailed {
root: PathBuf::from("/repo"),
reason: "z".into(),
},
DaemonError::WorkspaceStaleExpired {
root: PathBuf::from("/repo"),
age_hours: 48,
cap_hours: 24,
last_good_at: None,
last_error: None,
},
];
let expected: BTreeSet<String> = ["kind", "retryable", "retry_after_ms", "details"]
.iter()
.map(|s| (*s).to_string())
.collect();
for err in errs {
let label = format!("{err:?}");
let mcp_err = daemon_err_to_mcp(err);
let data = mcp_err.data.as_ref().unwrap().as_object().unwrap();
let keys: BTreeSet<String> = data.keys().cloned().collect();
assert_eq!(
keys, expected,
"envelope for {label} must be exactly the 4 canonical keys"
);
}
}
#[test]
fn server_lifecycle_errors_map_to_internal_kind() {
let errs = [
DaemonError::MemoryBudgetExceeded {
limit_bytes: 1,
current_bytes: 0,
reserved_bytes: 0,
retained_bytes: 0,
requested_bytes: 2,
},
DaemonError::WorkspaceEvicted {
root: PathBuf::from("/repo"),
},
];
for err in errs {
let mcp_err = daemon_err_to_mcp(err);
let data = mcp_err.data.as_ref().unwrap().as_object().unwrap();
assert_eq!(data["kind"], KIND_INTERNAL);
assert!(data["details"].is_null());
}
}
#[test]
fn query_too_broad_envelope_has_canonical_4_key_shape() {
let details = serde_json::json!({
"source": "static_estimate",
"kind": "query_too_broad",
"estimated_visited_nodes": 312_487,
"limit": 312_487,
"predicate_shape": "name~=/.*_set$/",
"suggested_predicates": ["kind", "lang", "language", "path", "file"],
"doc_url": "https://docs.verivus.dev/sqry/query-cost-gate",
});
let err = DaemonError::QueryTooBroad {
reason: "rejected: predicate `name~=/.*_set$/` is unbounded".into(),
details: details.clone(),
};
let mcp_err = daemon_err_to_mcp(err);
let data = mcp_err
.data
.as_ref()
.expect("QueryTooBroad must carry data")
.as_object()
.expect("data must be a JSON object");
let keys: std::collections::BTreeSet<&str> = data.keys().map(String::as_str).collect();
let expected: std::collections::BTreeSet<&str> =
["kind", "retryable", "retry_after_ms", "details"]
.iter()
.copied()
.collect();
assert_eq!(
keys, expected,
"envelope must have exactly the 4 canonical keys, got: {keys:?}"
);
assert_eq!(data["kind"], KIND_QUERY_TOO_BROAD);
assert_eq!(data["kind"], "query_too_broad");
assert_eq!(data["retryable"], false);
assert!(data["retry_after_ms"].is_null());
assert_eq!(data["details"], details);
assert_eq!(data["details"]["source"], "static_estimate");
assert_eq!(data["details"]["kind"], "query_too_broad");
assert_eq!(data["details"]["limit"], 312_487);
assert!(data["details"]["suggested_predicates"].is_array());
assert_eq!(
data["details"]["doc_url"],
"https://docs.verivus.dev/sqry/query-cost-gate"
);
}
}