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
}
}
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" => {
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.");
}
_ => {} }
}
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::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")
}