use rmcp::model::ErrorData;
const CONNECTION_KIND: &str = "connection";
#[derive(Debug, thiserror::Error)]
pub enum SqlError {
#[error("Query blocked: only SELECT, SHOW, DESC, DESCRIBE, USE queries are allowed in read-only mode")]
ReadOnlyViolation,
#[error("Operation forbidden: LOAD_FILE() is not allowed for security reasons")]
LoadFileBlocked,
#[error("Operation forbidden: {0}() is not allowed for security reasons")]
DangerousFunctionBlocked(String),
#[error("Operation forbidden: SELECT INTO OUTFILE/DUMPFILE is not allowed for security reasons")]
IntoOutfileBlocked,
#[error("Query blocked: only single statements are allowed")]
MultiStatement,
#[error("Invalid identifier '{0}': must not be empty, whitespace-only, or contain control characters")]
InvalidIdentifier(String),
#[error("Query timed out after {elapsed_secs:.1}s: {sql}")]
QueryTimeout {
elapsed_secs: f64,
sql: String,
},
#[error("Database error: {0}")]
Query(String),
#[error("Database error: {0}")]
Connection(String),
#[error("Table not found: {0}")]
TableNotFound(String),
}
impl From<SqlError> for ErrorData {
fn from(e: SqlError) -> Self {
let data = matches!(e, SqlError::Connection(_)).then(|| serde_json::json!({ "kind": CONNECTION_KIND }));
Self::internal_error(e.to_string(), data)
}
}
#[must_use]
pub fn is_connection_error(error: &ErrorData) -> bool {
error
.data
.as_ref()
.and_then(|data| data.get("kind"))
.is_some_and(|kind| kind == CONNECTION_KIND)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_connection_errors_are_marked() {
let connection = ErrorData::from(SqlError::Connection("reset".into()));
assert_eq!(connection.message, "Database error: reset");
assert!(is_connection_error(&connection));
for error in [SqlError::Query("syntax".into()), SqlError::ReadOnlyViolation] {
assert!(!is_connection_error(&ErrorData::from(error)));
}
assert!(!is_connection_error(&ErrorData::internal_error("other", None)));
}
}