use crate::error::{ErrorCode, McpError};
use crate::schema::ColumnSchema;
use hyperdb_api::{Catalog, Connection, CreateMode, HyperProcess, Parameters, SqlType};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct Engine {
hyper: HyperProcess,
connection: Connection,
workspace_path: PathBuf,
log_dir: PathBuf,
is_persistent: bool,
}
impl Engine {
#[expect(
clippy::needless_pass_by_value,
reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
)]
pub fn new(workspace_path: Option<String>) -> Result<Self, McpError> {
let (path, is_persistent) = if let Some(ref p) = workspace_path {
let path = PathBuf::from(shellexpand_tilde(p));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
McpError::new(
ErrorCode::PermissionDenied,
format!("Cannot create workspace directory: {e}"),
)
})?;
}
(path, true)
} else {
let dir = std::env::temp_dir().join(format!("hyperdb-mcp-{}", std::process::id()));
std::fs::create_dir_all(&dir).map_err(|e| {
McpError::new(
ErrorCode::InternalError,
format!("Cannot create temp directory: {e}"),
)
})?;
(dir.join("workspace.hyper"), false)
};
let log_dir = resolve_log_dir(workspace_path.as_deref());
std::fs::create_dir_all(&log_dir).map_err(|e| {
McpError::new(
ErrorCode::PermissionDenied,
format!("Cannot create log directory {}: {e}", log_dir.display()),
)
})?;
let mut params = Parameters::new();
params.set("log_file_max_count", "2");
params.set("log_file_size_limit", "100M");
params.set("log_dir", log_dir.to_string_lossy().as_ref());
let hyper = HyperProcess::new(None, Some(¶ms)).map_err(|e| {
let msg = e.to_string();
if msg.contains("hyperd") || msg.contains("HYPERD_PATH") || msg.contains("No such file")
{
McpError::new(ErrorCode::HyperdNotFound, msg)
} else {
McpError::new(ErrorCode::InternalError, msg)
}
})?;
let connection =
Connection::new(&hyper, &path, CreateMode::CreateIfNotExists).map_err(|e| {
McpError::new(ErrorCode::InternalError, format!("Failed to connect: {e}"))
})?;
connection
.execute_command("CREATE SCHEMA IF NOT EXISTS public")
.map_err(|e| {
McpError::new(
ErrorCode::InternalError,
format!("Failed to bootstrap public schema: {e}"),
)
})?;
Ok(Self {
hyper,
connection,
workspace_path: path,
log_dir,
is_persistent,
})
}
pub fn is_running(&self) -> bool {
self.hyper.is_running()
}
pub fn hyperd_endpoint(&self) -> Result<String, McpError> {
self.hyper
.require_endpoint()
.map(std::string::ToString::to_string)
.map_err(|e| McpError::new(ErrorCode::InternalError, e.to_string()))
}
pub fn workspace_path(&self) -> &Path {
&self.workspace_path
}
pub fn primary_db_name(&self) -> String {
self.workspace_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("workspace")
.to_string()
}
pub fn log_dir(&self) -> &Path {
&self.log_dir
}
pub fn hyperd_log_path(&self) -> Option<PathBuf> {
let entries = std::fs::read_dir(&self.log_dir).ok()?;
let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
.filter_map(std::result::Result::ok)
.filter_map(|e| {
let path = e.path();
let name = path.file_name()?.to_str()?;
if name.starts_with("hyperd")
&& std::path::Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
{
let mtime = e.metadata().ok().and_then(|m| m.modified().ok())?;
Some((mtime, path))
} else {
None
}
})
.collect();
candidates.sort_by_key(|b| std::cmp::Reverse(b.0));
candidates.into_iter().next().map(|(_, p)| p)
}
pub fn is_persistent(&self) -> bool {
self.is_persistent
}
pub fn connection(&self) -> &Connection {
&self.connection
}
pub fn execute_command(&self, sql: &str) -> Result<u64, McpError> {
self.connection.execute_command(sql).map_err(McpError::from)
}
pub fn execute_in_transaction<F, T>(&self, f: F) -> Result<T, McpError>
where
F: FnOnce(&Engine) -> Result<T, McpError>,
{
self.connection
.begin_transaction()
.map_err(McpError::from)?;
tracing::debug!("tx: BEGIN issued");
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self)));
match result {
Ok(Ok(val)) => {
tracing::debug!("tx: closure returned Ok, issuing COMMIT");
self.connection.commit().map_err(McpError::from)?;
Ok(val)
}
Ok(Err(e)) => {
tracing::debug!(err = %e, "tx: closure returned Err, issuing ROLLBACK");
if let Err(rb_err) = self.connection.rollback() {
tracing::warn!(
"rollback after error failed (original error preserved): {}",
rb_err
);
} else {
tracing::debug!("tx: ROLLBACK succeeded");
}
Err(e)
}
Err(panic_payload) => {
tracing::error!("tx: closure panicked, issuing ROLLBACK before resuming unwind");
let _ = self.connection.rollback();
std::panic::resume_unwind(panic_payload)
}
}
}
pub fn execute_query_to_json(&self, sql: &str) -> Result<Vec<Value>, McpError> {
let mut result = self.connection.execute_query(sql).map_err(McpError::from)?;
let mut rows_json = Vec::new();
let mut schema_opt = None;
while let Some(chunk) = result.next_chunk().map_err(McpError::from)? {
if schema_opt.is_none() {
schema_opt = result.schema();
}
if let Some(ref schema) = schema_opt {
let columns = schema.columns();
for row in &chunk {
let mut obj = serde_json::Map::new();
for col in columns {
let val = row_value_to_json(row, col.index(), &col.sql_type());
obj.insert(col.name().to_string(), val);
}
rows_json.push(Value::Object(obj));
}
}
}
Ok(rows_json)
}
pub fn create_table(
&self,
table_name: &str,
columns: &[ColumnSchema],
replace: bool,
) -> Result<(), McpError> {
if columns.is_empty() {
return Err(McpError::new(
ErrorCode::EmptyData,
"No columns to create table from",
));
}
for col in columns {
if crate::schema::map_hyper_type(&col.hyper_type).is_none() {
return Err(McpError::new(
ErrorCode::SchemaMismatch,
format!(
"Unknown type '{}' for column '{}'",
col.hyper_type, col.name
),
));
}
}
let quoted_table = format!("\"{}\"", table_name.replace('"', "\"\""));
if replace {
self.connection
.execute_command(&format!("DROP TABLE IF EXISTS {quoted_table}"))
.map_err(McpError::from)?;
}
let col_defs: Vec<String> = columns
.iter()
.map(|c| {
let nullable = if c.nullable { "" } else { " NOT NULL" };
format!(
"\"{}\" {}{}",
c.name.replace('"', "\"\""),
c.hyper_type,
nullable
)
})
.collect();
let create_sql = format!(
"CREATE TABLE IF NOT EXISTS {} ({})",
quoted_table,
col_defs.join(", ")
);
self.connection
.execute_command(&create_sql)
.map_err(McpError::from)?;
Ok(())
}
pub fn column_metadata(&self, table: &str) -> Result<Vec<ColumnSchema>, McpError> {
let catalog = Catalog::new(&self.connection);
let def = catalog
.get_table_definition(table)
.map_err(McpError::from)?;
Ok(def
.columns()
.iter()
.map(|c| ColumnSchema {
name: c.name.clone(),
hyper_type: c.type_name().to_string(),
nullable: c.nullable,
})
.collect())
}
pub fn table_exists(&self, table: &str) -> Result<bool, McpError> {
let catalog = Catalog::new(&self.connection);
let names = catalog.get_table_names("public").map_err(McpError::from)?;
Ok(names.iter().any(|n| n.as_str() == table))
}
pub fn alter_table_add_columns(
&self,
table: &str,
cols: &[ColumnSchema],
) -> Result<(), McpError> {
if cols.is_empty() {
return Ok(());
}
for col in cols {
if crate::schema::map_hyper_type(&col.hyper_type).is_none() {
return Err(McpError::new(
ErrorCode::SchemaMismatch,
format!(
"Unknown type '{}' for column '{}'",
col.hyper_type, col.name
),
));
}
}
let quoted_table = format!("\"{}\"", table.replace('"', "\"\""));
let add_clauses = cols
.iter()
.map(|c| {
format!(
"ADD COLUMN \"{}\" {}",
c.name.replace('"', "\"\""),
c.hyper_type
)
})
.collect::<Vec<_>>()
.join(", ");
let sql = format!("ALTER TABLE {quoted_table} {add_clauses}");
self.connection
.execute_command(&sql)
.map_err(McpError::from)?;
Ok(())
}
pub fn describe_tables(&self) -> Result<Vec<Value>, McpError> {
let catalog = Catalog::new(&self.connection);
let table_names = catalog.get_table_names("public").map_err(McpError::from)?;
let mut tables = Vec::new();
for name in &table_names {
if is_internal_table(name.as_str()) {
continue;
}
tables.push(describe_table_with_catalog(&catalog, name.as_str())?);
}
Ok(tables)
}
pub fn describe_table(&self, table_name: &str) -> Result<Value, McpError> {
if is_internal_table(table_name) {
return Err(McpError::new(
ErrorCode::TableNotFound,
format!("Table '{table_name}' does not exist"),
));
}
let catalog = Catalog::new(&self.connection);
let exists = catalog
.get_table_names("public")
.map_err(McpError::from)?
.iter()
.any(|n| n.as_str() == table_name);
if !exists {
return Err(McpError::new(
ErrorCode::TableNotFound,
format!("Table '{table_name}' does not exist"),
));
}
describe_table_with_catalog(&catalog, table_name)
}
pub fn sample_table(&self, table_name: &str, n: u64) -> Result<Value, McpError> {
let n = n.clamp(1, 100);
let quoted = table_name.replace('"', "\"\"");
let select_sql = format!("SELECT * FROM \"{quoted}\" LIMIT {n}");
let rows = match self.execute_query_to_json(&select_sql) {
Ok(r) => r,
Err(e) => return Err(translate_table_missing(e, table_name)),
};
let count_sql = format!("SELECT COUNT(*) AS cnt FROM \"{quoted}\"");
let row_count = self
.execute_query_to_json(&count_sql)
.ok()
.and_then(|rs| {
rs.first()
.and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
})
.unwrap_or(0);
let catalog = Catalog::new(&self.connection);
let columns: Vec<Value> = match catalog.get_table_definition(table_name) {
Ok(def) => def
.columns()
.iter()
.map(|col| {
json!({
"name": col.name,
"type": col.type_name(),
"nullable": col.nullable,
})
})
.collect(),
Err(_) => {
Vec::new()
}
};
Ok(json!({
"table": table_name,
"row_count": row_count,
"sample_size": rows.len(),
"schema": columns,
"rows": rows,
}))
}
pub fn status(&self) -> Result<Value, McpError> {
let catalog = Catalog::new(&self.connection);
let all_names = catalog.get_table_names("public").map_err(McpError::from)?;
let table_names: Vec<_> = all_names
.iter()
.filter(|n| !is_internal_table(n.as_str()))
.collect();
let table_count = table_names.len();
let total_rows: i64 = table_names
.iter()
.map(|name| catalog.get_row_count(name.as_str()).unwrap_or(0))
.sum();
let disk_bytes = std::fs::metadata(&self.workspace_path).map_or(0, |m| m.len());
let hyperd_log = self.hyperd_log_path().map_or(Value::Null, |p| {
Value::String(p.to_string_lossy().into_owned())
});
let client_log_path = self.log_dir.join(CLIENT_LOG_FILE_NAME);
let client_log = if client_log_path.exists() {
Value::String(client_log_path.to_string_lossy().into_owned())
} else {
Value::Null
};
Ok(json!({
"hyperd_running": self.hyper.is_running(),
"workspace_path": self.workspace_path.to_string_lossy(),
"workspace_mode": if self.is_persistent { "persistent" } else { "ephemeral" },
"table_count": table_count,
"total_rows": total_rows,
"disk_usage_bytes": disk_bytes,
"hyper_rust_api_version": crate::version::hyper_api_version_string(),
"logs": {
"log_dir": self.log_dir.to_string_lossy(),
"hyperd_log": hyperd_log,
"client_log": client_log,
},
}))
}
}
fn row_value_to_json(row: &hyperdb_api::Row, idx: usize, sql_type: &SqlType) -> Value {
use hyperdb_api::oids;
use hyperdb_api::{Date, Numeric, OffsetTimestamp, Timestamp};
if row.is_null(idx) {
return Value::Null;
}
let oid_val = sql_type.internal_oid();
if oid_val == oids::BOOL.0 {
return row.get::<bool>(idx).map_or(Value::Null, Value::Bool);
}
if oid_val == oids::SMALL_INT.0 {
return row
.get::<i16>(idx)
.map_or(Value::Null, |v| Value::Number(v.into()));
}
if oid_val == oids::INT.0 {
return row
.get::<i32>(idx)
.map_or(Value::Null, |v| Value::Number(v.into()));
}
if oid_val == oids::BIG_INT.0 {
return row
.get::<i64>(idx)
.map_or(Value::Null, |v| Value::Number(v.into()));
}
if oid_val == oids::DOUBLE.0 || oid_val == oids::FLOAT.0 {
return row
.get::<f64>(idx)
.and_then(|v| serde_json::Number::from_f64(v).map(Value::Number))
.unwrap_or(Value::Null);
}
if oid_val == oids::NUMERIC.0 {
return row.get::<Numeric>(idx).map_or(Value::Null, |n| {
let s = n.to_string();
s.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.unwrap_or(Value::String(s))
});
}
if oid_val == oids::DATE.0 {
return row
.get::<Date>(idx)
.map_or(Value::Null, |d| Value::String(d.to_string()));
}
if oid_val == oids::TIMESTAMP.0 {
return row
.get::<Timestamp>(idx)
.map_or(Value::Null, |t| Value::String(t.to_string()));
}
if oid_val == oids::TIMESTAMP_TZ.0 {
return row
.get::<OffsetTimestamp>(idx)
.map_or(Value::Null, |t| Value::String(t.to_string()));
}
if oid_val == oids::TEXT.0 || oid_val == oids::VARCHAR.0 {
return row.get::<String>(idx).map_or(Value::Null, Value::String);
}
row.get::<String>(idx).map_or(Value::Null, Value::String)
}
pub const CLIENT_LOG_FILE_NAME: &str = "hyperdb-mcp.log";
pub const HYPERDB_INTERNAL_PREFIX: &str = "_hyperdb_";
#[must_use]
pub fn is_internal_table(name: &str) -> bool {
name.starts_with(HYPERDB_INTERNAL_PREFIX)
}
#[must_use]
pub fn resolve_log_dir(workspace_path: Option<&str>) -> PathBuf {
match workspace_path {
Some(p) => {
let expanded = PathBuf::from(shellexpand_tilde(p));
expanded
.parent()
.map_or_else(|| PathBuf::from("."), std::path::Path::to_path_buf)
}
None => std::env::temp_dir().join(format!("hyperdb-mcp-{}", std::process::id())),
}
}
fn describe_table_with_catalog(catalog: &Catalog<'_>, name: &str) -> Result<Value, McpError> {
let def = catalog.get_table_definition(name).map_err(McpError::from)?;
let row_count = catalog.get_row_count(name).unwrap_or(0);
let columns: Vec<Value> = def
.columns()
.iter()
.map(|col| {
json!({
"name": col.name,
"type": col.type_name(),
"nullable": col.nullable,
})
})
.collect();
Ok(json!({
"name": name,
"columns": columns,
"row_count": row_count,
}))
}
fn translate_table_missing(err: McpError, table_name: &str) -> McpError {
let m = err.message.to_lowercase();
let looks_like_missing = m.contains("does not exist")
|| m.contains("relation")
|| m.contains("undefined table")
|| err.message.contains("42P01");
if looks_like_missing {
McpError::new(
ErrorCode::TableNotFound,
format!("Table '{table_name}' does not exist"),
)
} else {
err
}
}
#[must_use]
pub fn is_read_only_sql(sql: &str) -> bool {
let stripped = strip_leading_sql_comments(sql);
let first_token: String = stripped
.chars()
.take_while(|c| c.is_alphabetic())
.flat_map(char::to_uppercase)
.collect();
matches!(
first_token.as_str(),
"SELECT" | "WITH" | "EXPLAIN" | "SHOW" | "VALUES"
)
}
fn strip_leading_sql_comments(sql: &str) -> &str {
let mut s = sql;
loop {
s = s.trim_start();
if s.starts_with("--") {
match s.find(&['\n', '\r'][..]) {
Some(pos) => {
let mut next = pos + 1;
if s.as_bytes().get(pos) == Some(&b'\r')
&& s.as_bytes().get(pos + 1) == Some(&b'\n')
{
next = pos + 2;
}
s = &s[next..];
}
None => return "",
}
} else if s.starts_with("/*") {
let mut depth = 0u32;
let mut chars = s.char_indices().peekable();
let mut end = None;
while let Some((i, c)) = chars.next() {
if c == '/' && chars.peek().map(|(_, c2)| *c2) == Some('*') {
chars.next();
depth += 1;
} else if c == '*' && chars.peek().map(|(_, c2)| *c2) == Some('/') {
chars.next();
depth -= 1;
if depth == 0 {
end = Some(i + 2);
break;
}
}
}
match end {
Some(pos) => s = &s[pos..],
None => return "", }
} else {
break;
}
}
s
}
fn shellexpand_tilde(path: &str) -> String {
let rest = if let Some(r) = path.strip_prefix("~/") {
Some(r)
} else if cfg!(windows) {
path.strip_prefix("~\\")
} else {
None
};
let Some(rest) = rest else {
return path.to_string();
};
let Some(home) = home_dir() else {
return path.to_string();
};
let sep = std::path::MAIN_SEPARATOR;
format!("{}{sep}{rest}", home.to_string_lossy())
}
fn home_dir() -> Option<PathBuf> {
if cfg!(windows) {
if let Some(profile) = std::env::var_os("USERPROFILE") {
if !profile.is_empty() {
return Some(PathBuf::from(profile));
}
}
let drive = std::env::var_os("HOMEDRIVE")?;
let rel = std::env::var_os("HOMEPATH")?;
let mut combined = PathBuf::from(drive);
combined.push(PathBuf::from(rel));
Some(combined)
} else {
std::env::var_os("HOME").map(PathBuf::from)
}
}