use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ErrorCode {
HyperdNotFound,
FileNotFound,
UnsupportedFormat,
SchemaMismatch,
SqlError,
TableNotFound,
EmptyData,
DiskFull,
PermissionDenied,
ReadOnlyViolation,
ConnectionLost,
InvalidArgument,
ResourceBusy,
InternalError,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpError {
pub code: ErrorCode,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>,
}
impl McpError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
let message = message.into();
let suggestion = default_suggestion(code, &message);
Self {
code,
message,
suggestion,
}
}
#[must_use]
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestion = Some(suggestion.into());
self
}
#[must_use]
pub fn from_io_error(err: &std::io::Error, context: &str) -> Self {
let code = match err.kind() {
std::io::ErrorKind::PermissionDenied => ErrorCode::PermissionDenied,
std::io::ErrorKind::NotFound => ErrorCode::FileNotFound,
_ => ErrorCode::InternalError,
};
McpError::new(code, format!("{context}: {err}"))
}
}
impl std::fmt::Display for McpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{:?}] {}", self.code, self.message)
}
}
impl std::error::Error for McpError {}
fn default_suggestion(code: ErrorCode, _message: &str) -> Option<String> {
match code {
ErrorCode::HyperdNotFound => Some("Set HYPERD_PATH environment variable or ensure hyperd is on PATH".into()),
ErrorCode::FileNotFound => Some("Verify the file path exists and is accessible".into()),
ErrorCode::UnsupportedFormat => Some("Specify format explicitly: json, csv, parquet, or arrow_ipc".into()),
ErrorCode::SchemaMismatch => Some("Retry with an explicit schema override".into()),
ErrorCode::SqlError => Some("Check SQL syntax. Hyper uses the Data Cloud SQL dialect (PostgreSQL-compatible).".into()),
ErrorCode::TableNotFound => Some("Use the describe tool to list available tables".into()),
ErrorCode::EmptyData => None,
ErrorCode::DiskFull => Some("Check disk space. Use the status tool to see workspace size.".into()),
ErrorCode::PermissionDenied => Some("Check file permissions on the source or target path".into()),
ErrorCode::ReadOnlyViolation => Some("Server is in read-only mode. Use query_data or query_file for one-shot analysis, or restart without --read-only.".into()),
ErrorCode::ConnectionLost => Some("The hyperd connection was lost or fell out of wire-protocol sync. Retry the request — the server will tear down the engine and reconnect automatically.".into()),
ErrorCode::InvalidArgument => Some("Check the tool argument shape and allowed values. The message identifies the offending field.".into()),
ErrorCode::ResourceBusy => Some("The .hyper file is held by another process. Close the other MCP server (or hyperd instance) that owns it, or copy the file first and attach the copy.".into()),
ErrorCode::InternalError => None,
}
}
impl From<hyperdb_api::Error> for McpError {
fn from(err: hyperdb_api::Error) -> Self {
if let hyperdb_api::Error::Server {
sqlstate: Some(ref code),
..
} = err
{
match code.as_str() {
"22003" => {
return McpError::new(ErrorCode::SchemaMismatch, err.to_string()).with_suggestion(
"A numeric value exceeded its column's range. Retry with a partial schema override that widens the offending column, e.g. schema: {\"Population\": \"BIGINT\"} or {\"Amount\": \"NUMERIC(38,0)\"}. The override is a partial dictionary keyed by column name — unlisted columns keep their inferred type. Call inspect_file first if you don't know which column is too narrow.");
}
"22P02" => {
return McpError::new(ErrorCode::SchemaMismatch, err.to_string()).with_suggestion(
"A value could not be parsed into its column type. Retry with a partial schema override forcing TEXT for the offending column, e.g. schema: {\"Id\": \"TEXT\"}, and cast in SQL as needed.");
}
"0A000" => {
let lower = err.to_string().to_lowercase();
if lower.contains("json") {
return McpError::new(ErrorCode::SqlError, err.to_string()).with_suggestion(
"JSON_VALUE is not implemented in this engine. Cast the TEXT value to json first, then use -> / ->> / JSON_EACH, e.g. `SELECT value::json ->> 'field' FROM _hyperdb_kv_store WHERE store_name = '...'`.");
}
return McpError::new(ErrorCode::SqlError, err.to_string()).with_suggestion(
"Hyper only accepts one SQL statement per call. Split your query into separate execute/query calls — one per statement.");
}
"42601" => {
let lower = err.to_string().to_lowercase();
if lower.contains("structured data type") || lower.contains("json") {
return McpError::new(ErrorCode::SqlError, err.to_string()).with_suggestion(
"The -> / ->> operators need a structured type. Cast the TEXT value to json first, e.g. `value::json ->> 'field'`.");
}
return McpError::new(ErrorCode::SqlError, err.to_string());
}
_ => {} }
}
let msg = err.to_string();
let lower = msg.to_lowercase();
if is_connection_lost(&msg) {
return McpError::new(ErrorCode::ConnectionLost, msg);
}
if is_resource_busy(&msg) {
return McpError::new(ErrorCode::ResourceBusy, msg);
}
match err {
hyperdb_api::Error::NotFound(_) => McpError::new(ErrorCode::FileNotFound, msg),
hyperdb_api::Error::Server { .. } => {
if msg.contains("22003")
|| lower.contains("numeric overflow")
|| lower.contains("out of range")
{
McpError::new(ErrorCode::SchemaMismatch, msg).with_suggestion(
"A numeric value exceeded its column's range. Retry with a partial schema override that widens the offending column, e.g. schema: {\"Population\": \"BIGINT\"} or {\"Amount\": \"NUMERIC(38,0)\"}. The override is a partial dictionary keyed by column name — unlisted columns keep their inferred type. Call inspect_file first if you don't know which column is too narrow.")
} else if msg.contains("22P02") || lower.contains("invalid input syntax") {
McpError::new(ErrorCode::SchemaMismatch, msg).with_suggestion(
"A value could not be parsed into its column type. Retry with a partial schema override forcing TEXT for the offending column, e.g. schema: {\"Id\": \"TEXT\"}, and cast in SQL as needed.")
} else if msg.contains("syntax error")
|| (msg.contains("does not exist") && msg.contains("column"))
{
McpError::new(ErrorCode::SqlError, msg)
} else if msg.contains("No such file") || msg.contains("not found") {
McpError::new(ErrorCode::FileNotFound, msg)
} else {
McpError::new(ErrorCode::SqlError, msg)
}
}
hyperdb_api::Error::Conversion(_) => McpError::new(ErrorCode::InternalError, msg),
hyperdb_api::Error::Config(_) => McpError::new(ErrorCode::InvalidArgument, msg),
hyperdb_api::Error::InvalidName(_) | hyperdb_api::Error::InvalidTableDefinition(_) => {
McpError::new(ErrorCode::InvalidArgument, msg)
}
hyperdb_api::Error::Connection { .. }
| hyperdb_api::Error::Closed { .. }
| hyperdb_api::Error::Timeout(_)
| hyperdb_api::Error::Cancelled { .. } => McpError::new(ErrorCode::ConnectionLost, msg),
_ => McpError::new(ErrorCode::InternalError, msg),
}
}
}
#[must_use]
pub fn is_connection_lost(msg: &str) -> bool {
let lower = msg.to_lowercase();
lower.contains("broken pipe")
|| lower.contains("connection reset")
|| lower.contains("connection refused")
|| lower.contains("connection closed")
|| lower.contains("unexpected eof")
|| lower.contains("end of file")
|| lower.contains("unexpectedly closed")
|| lower.contains("socket is not connected")
|| lower.contains("desynchronized")
}
fn is_resource_busy(msg: &str) -> bool {
let lower = msg.to_lowercase();
lower.contains("already attached")
|| lower.contains("database is in use")
|| lower.contains("could not lock")
|| lower.contains("already in use")
|| lower.contains("file is locked")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn io_error_preserves_permission_denied() {
let e = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
assert_eq!(
McpError::from_io_error(&e, "value_path").code,
ErrorCode::PermissionDenied
);
}
#[test]
fn io_error_maps_not_found() {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
assert_eq!(
McpError::from_io_error(&e, "value_path").code,
ErrorCode::FileNotFound
);
}
#[test]
fn json_value_error_suggests_cast_not_split() {
let err = hyperdb_api::Error::server(
Some("0A000".to_string()),
"function JSON_VALUE is not implemented yet",
None,
None,
);
let mapped = McpError::from(err);
let s = mapped.suggestion.unwrap_or_default();
assert!(
s.contains("::json"),
"expected a ::json cast hint, got: {s}"
);
assert!(
!s.to_lowercase().contains("split"),
"must not suggest splitting: {s}"
);
}
#[test]
fn structured_type_error_suggests_cast() {
let err = hyperdb_api::Error::server(
Some("42601".to_string()),
"operator ->> requires a structured data type",
None,
None,
);
let s = McpError::from(err).suggestion.unwrap_or_default();
assert!(
s.contains("::json"),
"expected a ::json cast hint, got: {s}"
);
}
#[test]
fn multi_statement_error_still_suggests_split() {
let err = hyperdb_api::Error::server(
Some("0A000".to_string()),
"multi-statement queries are not supported",
None,
None,
);
let s = McpError::from(err).suggestion.unwrap_or_default();
assert!(s.to_lowercase().contains("one sql statement"), "got: {s}");
}
}