hyperdb_mcp/engine.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Core database engine that owns the `HyperProcess` and its connection.
5//!
6//! The [`Engine`] is the single point of contact with the Hyper database. It
7//! manages process startup, connection lifecycle, table DDL, query execution,
8//! and workspace metadata. All higher-level modules (ingest, export, server)
9//! operate through an `&Engine` reference.
10//!
11//! # Lazy Initialization and Connection Recovery
12//!
13//! The engine is lazily initialized by [`crate::server::HyperMcpServer`] on the
14//! first tool call (not during MCP handshake). This keeps the `initialize`
15//! response fast and avoids starting `hyperd` if the client never calls a tool.
16//!
17//! If the connection to `hyperd` is lost (crash, broken pipe, wire-protocol
18//! desync), the server's `crate::server::HyperMcpServer::with_engine` wrapper
19//! detects the [`crate::error::ErrorCode::ConnectionLost`] error, drops the
20//! engine, and transparently re-creates it on the next call. This auto-reconnect
21//! path covers both transport-level failures and the `"desynchronized"` state
22//! surfaced by the `hyper-client` layer's bounded drain.
23//!
24//! # Workspace Modes
25//!
26//! - **Persistent** — caller supplies a path via `--workspace`; the `.hyper`
27//! file survives across sessions. Tables can be built up incrementally and
28//! exported to Tableau Desktop.
29//! - **Ephemeral** — a temp directory is created per process; everything is
30//! discarded when the server exits. No configuration needed.
31//!
32//! # Sync Calls in an Async Server
33//!
34//! All `Engine` methods are synchronous (blocking). The MCP server runs on a
35//! tokio runtime, but `hyperd` communication goes through the `hyperdb-api` crate's
36//! blocking `Connection` API. The `rmcp` framework spawns tool handlers on its
37//! own task pool, so blocking calls do not starve the async event loop. A future
38//! optimization could use `spawn_blocking` or an async connection API, but the
39//! current approach is correct and simple.
40
41use crate::error::{ErrorCode, McpError};
42use crate::schema::ColumnSchema;
43use hyperdb_api::{Catalog, Connection, CreateMode, HyperProcess, Parameters, SqlType};
44use serde_json::{json, Value};
45use std::path::{Path, PathBuf};
46
47/// Owns a running `HyperProcess` and the single `Connection` to its workspace
48/// `.hyper` file. All SQL execution flows through this struct.
49///
50/// Two workspace modes are supported:
51/// - **Persistent** — caller supplies a path; the `.hyper` file survives across
52/// sessions so tables can be built up incrementally.
53/// - **Ephemeral** — a temp directory is created per process; everything is
54/// discarded when the server exits.
55#[derive(Debug)]
56pub struct Engine {
57 hyper: HyperProcess,
58 connection: Connection,
59 workspace_path: PathBuf,
60 log_dir: PathBuf,
61 is_persistent: bool,
62}
63
64impl Engine {
65 #[expect(
66 clippy::needless_pass_by_value,
67 reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
68 )]
69 /// Create a new Engine. If `workspace_path` is Some, use that path (persistent mode).
70 /// If None, use a temp file (ephemeral mode).
71 ///
72 /// Logs from `hyperd` are written to the directory returned by
73 /// [`resolve_log_dir`]. The same directory should be used by the MCP
74 /// binary for its own client-side log so operators can find everything
75 /// in one place when debugging.
76 ///
77 /// # Errors
78 ///
79 /// - Returns [`ErrorCode::PermissionDenied`] if the workspace parent
80 /// directory or the log directory cannot be created.
81 /// - Returns [`ErrorCode::InternalError`] if the ephemeral temp
82 /// directory cannot be created, if the `public` schema bootstrap
83 /// fails, or if the initial connection to `hyperd` fails.
84 /// - Returns [`ErrorCode::HyperdNotFound`] when [`HyperProcess::new`]
85 /// reports the `hyperd` executable is missing or unreachable via
86 /// `HYPERD_PATH`.
87 pub fn new(workspace_path: Option<String>) -> Result<Self, McpError> {
88 let (path, is_persistent) = if let Some(ref p) = workspace_path {
89 let path = PathBuf::from(shellexpand_tilde(p));
90 if let Some(parent) = path.parent() {
91 std::fs::create_dir_all(parent).map_err(|e| {
92 McpError::new(
93 ErrorCode::PermissionDenied,
94 format!("Cannot create workspace directory: {e}"),
95 )
96 })?;
97 }
98 (path, true)
99 } else {
100 let dir = std::env::temp_dir().join(format!("hyperdb-mcp-{}", std::process::id()));
101 std::fs::create_dir_all(&dir).map_err(|e| {
102 McpError::new(
103 ErrorCode::InternalError,
104 format!("Cannot create temp directory: {e}"),
105 )
106 })?;
107 (dir.join("workspace.hyper"), false)
108 };
109
110 let log_dir = resolve_log_dir(workspace_path.as_deref());
111 std::fs::create_dir_all(&log_dir).map_err(|e| {
112 McpError::new(
113 ErrorCode::PermissionDenied,
114 format!("Cannot create log directory {}: {e}", log_dir.display()),
115 )
116 })?;
117
118 let mut params = Parameters::new();
119 params.set("log_file_max_count", "2");
120 params.set("log_file_size_limit", "100M");
121 params.set("log_dir", log_dir.to_string_lossy().as_ref());
122
123 let hyper = HyperProcess::new(None, Some(¶ms)).map_err(|e| {
124 let msg = e.to_string();
125 if msg.contains("hyperd") || msg.contains("HYPERD_PATH") || msg.contains("No such file")
126 {
127 McpError::new(ErrorCode::HyperdNotFound, msg)
128 } else {
129 McpError::new(ErrorCode::InternalError, msg)
130 }
131 })?;
132
133 let connection =
134 Connection::new(&hyper, &path, CreateMode::CreateIfNotExists).map_err(|e| {
135 McpError::new(ErrorCode::InternalError, format!("Failed to connect: {e}"))
136 })?;
137
138 // Ensure the `public` schema exists in the workspace database so that
139 // `load_file`, `load_data`, and unqualified `CREATE TABLE` statements
140 // resolve without a "could not resolve the schema (3F000)" error.
141 connection
142 .execute_command("CREATE SCHEMA IF NOT EXISTS public")
143 .map_err(|e| {
144 McpError::new(
145 ErrorCode::InternalError,
146 format!("Failed to bootstrap public schema: {e}"),
147 )
148 })?;
149
150 Ok(Self {
151 hyper,
152 connection,
153 workspace_path: path,
154 log_dir,
155 is_persistent,
156 })
157 }
158
159 /// Whether the `hyperd` child process is still alive.
160 pub fn is_running(&self) -> bool {
161 self.hyper.is_running()
162 }
163
164 /// `host:port` endpoint of the hyperd child process. Used by the
165 /// watcher to build additional async connections via `hyperdb_api::pool`
166 /// without touching the primary sync connection this engine holds.
167 ///
168 /// # Errors
169 ///
170 /// Returns [`ErrorCode::InternalError`] if the underlying
171 /// [`HyperProcess::require_endpoint`] call fails — typically when
172 /// `hyperd` has exited or never successfully reported an endpoint.
173 pub fn hyperd_endpoint(&self) -> Result<String, McpError> {
174 self.hyper
175 .require_endpoint()
176 .map(std::string::ToString::to_string)
177 .map_err(|e| McpError::new(ErrorCode::InternalError, e.to_string()))
178 }
179
180 /// Absolute path to the `.hyper` workspace file on disk.
181 pub fn workspace_path(&self) -> &Path {
182 &self.workspace_path
183 }
184
185 /// Unqualified database name Hyper uses for the primary workspace —
186 /// the stem of [`Self::workspace_path`]. Matches what
187 /// [`hyperdb_api::Connection::new`] registers when it issues its implicit
188 /// `ATTACH DATABASE`, so fully-qualified SQL built with this value
189 /// resolves to the primary workspace.
190 ///
191 /// Also the correct value for `SET schema_search_path = '…'` while
192 /// additional databases are attached: Hyper's default search path
193 /// (`"$single"`) only covers the implicit primary when no other
194 /// databases are attached, and starts resolving unqualified names to
195 /// nothing the moment an `ATTACH DATABASE` runs.
196 pub fn primary_db_name(&self) -> String {
197 self.workspace_path
198 .file_stem()
199 .and_then(|s| s.to_str())
200 .unwrap_or("workspace")
201 .to_string()
202 }
203
204 /// Directory where `hyperd` writes its log files. The MCP binary should
205 /// also drop its own client-side log here so debugging starts in one
206 /// place.
207 pub fn log_dir(&self) -> &Path {
208 &self.log_dir
209 }
210
211 /// Best-guess path to the most recent `hyperd` log file, useful when
212 /// something in the engine misbehaves and we want to surface the server
213 /// log to the caller. Picks the newest `hyperd*.log` file in [`log_dir`].
214 /// Returns `None` if no matching file exists yet.
215 ///
216 /// [`log_dir`]: Self::log_dir
217 pub fn hyperd_log_path(&self) -> Option<PathBuf> {
218 let entries = std::fs::read_dir(&self.log_dir).ok()?;
219 let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
220 .filter_map(std::result::Result::ok)
221 .filter_map(|e| {
222 let path = e.path();
223 let name = path.file_name()?.to_str()?;
224 if name.starts_with("hyperd")
225 && std::path::Path::new(name)
226 .extension()
227 .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
228 {
229 let mtime = e.metadata().ok().and_then(|m| m.modified().ok())?;
230 Some((mtime, path))
231 } else {
232 None
233 }
234 })
235 .collect();
236 candidates.sort_by_key(|b| std::cmp::Reverse(b.0));
237 candidates.into_iter().next().map(|(_, p)| p)
238 }
239
240 /// `true` if the workspace was created from a user-supplied path
241 /// (data survives across sessions).
242 pub fn is_persistent(&self) -> bool {
243 self.is_persistent
244 }
245
246 /// Direct access to the underlying connection for operations not
247 /// wrapped by `Engine` (e.g. `export_csv`, `execute_query_to_arrow`).
248 pub fn connection(&self) -> &Connection {
249 &self.connection
250 }
251
252 /// Execute a DDL/DML command. Returns affected row count.
253 ///
254 /// # Errors
255 ///
256 /// Converts any [`hyperdb_api::Error`] from the underlying connection
257 /// into an [`McpError`] — typical causes are SQL syntax errors,
258 /// constraint violations, permission failures, or
259 /// [`ErrorCode::ConnectionLost`] when the link to `hyperd` has
260 /// dropped.
261 pub fn execute_command(&self, sql: &str) -> Result<u64, McpError> {
262 self.connection.execute_command(sql).map_err(McpError::from)
263 }
264
265 /// Run the given closure inside a database transaction.
266 ///
267 /// Issues `BEGIN TRANSACTION` before calling `f`. If `f` returns `Ok`,
268 /// commits the transaction; if it returns `Err`, rolls back and returns
269 /// the original error. A failed rollback is logged via `tracing::warn!`
270 /// and the original error is still surfaced (rollback failure usually
271 /// means the transaction was already aborted by the server, which is
272 /// functionally equivalent to a successful rollback).
273 ///
274 /// This is the correctness primitive for ingest operations: it lets
275 /// per-row `INSERT` loops (Parquet, Arrow, JSON) leave zero partial data
276 /// on failure. The CSV `COPY FROM` path is already atomic at the
277 /// statement level, but wrapping it in a transaction costs nothing and
278 /// makes per-row INSERT loops atomic across the whole batch.
279 ///
280 /// # DDL is auto-committed
281 ///
282 /// Hyper treats `DROP TABLE` and `CREATE TABLE` as auto-committed even
283 /// when issued inside a transaction. This means `replace`-mode ingest
284 /// cannot roll back the original table once DDL has run. The guarantee
285 /// is weaker than it looks: on failure, the new (empty) table stays
286 /// in place rather than being replaced by partial data. Append-mode
287 /// ingest is fully atomic because it doesn't issue DDL on existing
288 /// tables.
289 ///
290 /// # Known wire protocol quirk
291 ///
292 /// After a mid-transaction Hyper-level error (e.g. a NOT NULL violation
293 /// on INSERT), the first SELECT after rollback may return an empty
294 /// result set due to residual bytes on the connection. Retrying the
295 /// query once restores normal behavior. The rollback itself is always
296 /// correct — this is a read-side symptom only. See the `query_resilient`
297 /// helper in `tests/transaction_tests.rs` for a robust pattern.
298 ///
299 /// # Errors
300 ///
301 /// - Returns any [`McpError`] raised by `BEGIN TRANSACTION` or by
302 /// `COMMIT` (typical causes: connection loss, serialization
303 /// conflict, DDL auto-commit contention).
304 /// - Returns whatever error `f` produces (rollback is performed
305 /// first; a rollback failure is only logged, never surfaced).
306 ///
307 /// # Panics
308 ///
309 /// Does not introduce new panic sites. If `f` panics, the transaction
310 /// is rolled back (best-effort) and the original panic is re-raised
311 /// via [`std::panic::resume_unwind`], preserving the panic payload.
312 pub fn execute_in_transaction<F, T>(&self, f: F) -> Result<T, McpError>
313 where
314 F: FnOnce(&Engine) -> Result<T, McpError>,
315 {
316 self.connection
317 .begin_transaction()
318 .map_err(McpError::from)?;
319 tracing::debug!("tx: BEGIN issued");
320 // `catch_unwind` wraps the closure so a panic (unwrap on None,
321 // indexing OOB, arithmetic overflow, …) doesn't leave an open
322 // transaction on the connection. Without this, the next tool
323 // call would hit "transaction already in progress" and the
324 // server's ConnectionLost auto-reconnect would *not* recover
325 // because the connection is live; the engine would stay wedged
326 // until restart. `AssertUnwindSafe` is correct here: we hold
327 // the transaction open for the closure's duration, and we
328 // always issue a rollback before resuming the panic, so no
329 // logical invariant survives into the panicking stack.
330 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self)));
331 match result {
332 Ok(Ok(val)) => {
333 tracing::debug!("tx: closure returned Ok, issuing COMMIT");
334 self.connection.commit().map_err(McpError::from)?;
335 Ok(val)
336 }
337 Ok(Err(e)) => {
338 tracing::debug!(err = %e, "tx: closure returned Err, issuing ROLLBACK");
339 if let Err(rb_err) = self.connection.rollback() {
340 // Rollback itself failed — log it but keep the original
341 // error as the primary cause. A failed rollback usually
342 // means the transaction was already aborted by the server,
343 // which is fine (nothing to unwind).
344 tracing::warn!(
345 "rollback after error failed (original error preserved): {}",
346 rb_err
347 );
348 } else {
349 tracing::debug!("tx: ROLLBACK succeeded");
350 }
351 Err(e)
352 }
353 Err(panic_payload) => {
354 tracing::error!("tx: closure panicked, issuing ROLLBACK before resuming unwind");
355 // Best-effort rollback. If it fails, the connection is
356 // unusable — but we're about to panic anyway, and
357 // `HyperMcpServer::with_engine` will drop the engine
358 // when the panic surfaces as a poisoned tokio task.
359 let _ = self.connection.rollback();
360 std::panic::resume_unwind(panic_payload)
361 }
362 }
363 }
364
365 /// Execute a SELECT query and materialize all result rows as a JSON array
366 /// of `{column_name: value}` objects.
367 ///
368 /// Results are consumed chunk-by-chunk to avoid holding the entire result
369 /// set in protocol buffers, though the final `Vec<Value>` does accumulate
370 /// in memory. For truly huge results, prefer `export` to a file instead.
371 ///
372 /// # Errors
373 ///
374 /// Returns any [`McpError`] produced by [`Connection::execute_query`]
375 /// or subsequent `next_chunk` calls — SQL errors, connection loss,
376 /// and decoding failures all surface through this path.
377 pub fn execute_query_to_json(&self, sql: &str) -> Result<Vec<Value>, McpError> {
378 let mut result = self.connection.execute_query(sql).map_err(McpError::from)?;
379
380 let mut rows_json = Vec::new();
381 let mut schema_opt = None;
382 while let Some(chunk) = result.next_chunk().map_err(McpError::from)? {
383 // Capture schema from first chunk
384 if schema_opt.is_none() {
385 schema_opt = result.schema();
386 }
387 if let Some(ref schema) = schema_opt {
388 let columns = schema.columns();
389 for row in &chunk {
390 let mut obj = serde_json::Map::new();
391 for col in columns {
392 let val = row_value_to_json(row, col.index(), &col.sql_type());
393 obj.insert(col.name().to_string(), val);
394 }
395 rows_json.push(Value::Object(obj));
396 }
397 }
398 }
399 Ok(rows_json)
400 }
401
402 /// Create a table from a schema definition.
403 ///
404 /// - `replace = true`: drops the existing table (if any) and recreates it.
405 /// Old rows are lost. Schema is defined by `columns`.
406 /// - `replace = false` (append mode): creates the table only if it doesn't
407 /// already exist. If it does exist, the schema defined here is ignored
408 /// and subsequent inserts must match the existing schema.
409 ///
410 /// Uses `CREATE TABLE IF NOT EXISTS` / `DROP TABLE IF EXISTS` so the
411 /// operation is idempotent without needing a separate `has_table` probe.
412 /// This is important for the watcher path, where a racy `has_table` check
413 /// (false negative due to protocol desync) would otherwise attempt a bare
414 /// `CREATE TABLE` that fails with "42P07 table already exists" and leaves
415 /// the connection in an aborted state.
416 ///
417 /// # Errors
418 ///
419 /// - Returns [`ErrorCode::EmptyData`] if `columns` is empty.
420 /// - Returns [`ErrorCode::SchemaMismatch`] if any column's
421 /// `hyper_type` cannot be resolved by [`crate::schema::map_hyper_type`].
422 /// - Propagates any Hyper error from `DROP TABLE` (when `replace`
423 /// is true) or `CREATE TABLE IF NOT EXISTS`.
424 pub fn create_table(
425 &self,
426 table_name: &str,
427 columns: &[ColumnSchema],
428 replace: bool,
429 ) -> Result<(), McpError> {
430 if columns.is_empty() {
431 return Err(McpError::new(
432 ErrorCode::EmptyData,
433 "No columns to create table from",
434 ));
435 }
436 // Validate every column's type name is known before issuing any DDL.
437 // This catches typos in schema overrides before we start mutating the
438 // database.
439 for col in columns {
440 if crate::schema::map_hyper_type(&col.hyper_type).is_none() {
441 return Err(McpError::new(
442 ErrorCode::SchemaMismatch,
443 format!(
444 "Unknown type '{}' for column '{}'",
445 col.hyper_type, col.name
446 ),
447 ));
448 }
449 }
450
451 let quoted_table = format!("\"{}\"", table_name.replace('"', "\"\""));
452 if replace {
453 self.connection
454 .execute_command(&format!("DROP TABLE IF EXISTS {quoted_table}"))
455 .map_err(McpError::from)?;
456 }
457
458 let col_defs: Vec<String> = columns
459 .iter()
460 .map(|c| {
461 let nullable = if c.nullable { "" } else { " NOT NULL" };
462 format!(
463 "\"{}\" {}{}",
464 c.name.replace('"', "\"\""),
465 c.hyper_type,
466 nullable
467 )
468 })
469 .collect();
470
471 let create_sql = format!(
472 "CREATE TABLE IF NOT EXISTS {} ({})",
473 quoted_table,
474 col_defs.join(", ")
475 );
476 self.connection
477 .execute_command(&create_sql)
478 .map_err(McpError::from)?;
479 Ok(())
480 }
481
482 /// Returns `(name, hyper_type, nullable)` for every column of `table`,
483 /// in declaration order, by reading the catalog (the same path
484 /// `describe_table` uses). Used by the `merge` ingest path to
485 /// compare incoming-file schema against the existing table.
486 ///
487 /// # Errors
488 ///
489 /// - Propagates [`Catalog::get_table_definition`] errors. Callers
490 /// that need a "table missing" sentinel should pre-check via
491 /// `Catalog::get_table_names("public")` (see `describe_table` for
492 /// the precedent) — `get_table_definition` errors with a
493 /// variable wording across Hyper versions.
494 pub fn column_metadata(&self, table: &str) -> Result<Vec<ColumnSchema>, McpError> {
495 let catalog = Catalog::new(&self.connection);
496 let def = catalog
497 .get_table_definition(table)
498 .map_err(McpError::from)?;
499 Ok(def
500 .columns()
501 .iter()
502 .map(|c| ColumnSchema {
503 name: c.name.clone(),
504 hyper_type: c.type_name().to_string(),
505 nullable: c.nullable,
506 })
507 .collect())
508 }
509
510 /// Returns true if `table` exists in the `public` schema. Avoids
511 /// the per-version error-string ambiguity of
512 /// [`Catalog::get_table_definition`] by listing names instead.
513 ///
514 /// # Errors
515 ///
516 /// Propagates errors from [`Catalog::get_table_names`] (typically
517 /// connection loss).
518 pub fn table_exists(&self, table: &str) -> Result<bool, McpError> {
519 let catalog = Catalog::new(&self.connection);
520 let names = catalog.get_table_names("public").map_err(McpError::from)?;
521 Ok(names.iter().any(|n| n.as_str() == table))
522 }
523
524 /// Issue a single `ALTER TABLE "<table>" ADD COLUMN "<n1>" <t1>,
525 /// ADD COLUMN "<n2>" <t2>, …` statement that adds all columns
526 /// atomically. Hyper supports the multi-column form (verified
527 /// 2026-05-07 against the pinned hyperd release), so partial-add
528 /// failures don't leave the schema half-widened.
529 ///
530 /// New columns are always added nullable — existing rows have no
531 /// value to satisfy NOT NULL. `nullable` on the input is ignored
532 /// for that reason.
533 ///
534 /// `cols` must be non-empty; an empty input is a no-op (returns
535 /// `Ok(())` without issuing SQL) so callers can pass the
536 /// "columns missing from target" set directly without a length
537 /// pre-check.
538 ///
539 /// # Errors
540 ///
541 /// - Returns [`ErrorCode::SchemaMismatch`] if any element's
542 /// `hyper_type` is not a known Hyper type (same validation as
543 /// `create_table`).
544 /// - Propagates the underlying SQL error from the single ALTER
545 /// statement. Because Hyper executes a multi-column ADD
546 /// atomically, a failure leaves the table schema unchanged —
547 /// no partial widening.
548 pub fn alter_table_add_columns(
549 &self,
550 table: &str,
551 cols: &[ColumnSchema],
552 ) -> Result<(), McpError> {
553 if cols.is_empty() {
554 return Ok(());
555 }
556 for col in cols {
557 if crate::schema::map_hyper_type(&col.hyper_type).is_none() {
558 return Err(McpError::new(
559 ErrorCode::SchemaMismatch,
560 format!(
561 "Unknown type '{}' for column '{}'",
562 col.hyper_type, col.name
563 ),
564 ));
565 }
566 }
567 let quoted_table = format!("\"{}\"", table.replace('"', "\"\""));
568 let add_clauses = cols
569 .iter()
570 .map(|c| {
571 format!(
572 "ADD COLUMN \"{}\" {}",
573 c.name.replace('"', "\"\""),
574 c.hyper_type
575 )
576 })
577 .collect::<Vec<_>>()
578 .join(", ");
579 let sql = format!("ALTER TABLE {quoted_table} {add_clauses}");
580 self.connection
581 .execute_command(&sql)
582 .map_err(McpError::from)?;
583 Ok(())
584 }
585
586 /// List all tables in the `public` schema with their column definitions
587 /// and row counts. Returned as a JSON-serializable `Vec` for direct use
588 /// in MCP tool responses.
589 ///
590 /// # Errors
591 ///
592 /// - Propagates any error from [`Catalog::get_table_names`] (typically
593 /// connection loss or SQL errors from the underlying catalog
594 /// probe).
595 /// - Propagates any error from `describe_table_with_catalog` for
596 /// individual tables — a single failing describe aborts the whole
597 /// listing.
598 pub fn describe_tables(&self) -> Result<Vec<Value>, McpError> {
599 let catalog = Catalog::new(&self.connection);
600 let table_names = catalog.get_table_names("public").map_err(McpError::from)?;
601 let mut tables = Vec::new();
602 for name in &table_names {
603 // Skip infrastructure tables (`_hyperdb_*`) so the public
604 // catalog only surfaces user-visible data. See
605 // [`is_internal_table`] for the convention and rationale.
606 if is_internal_table(name.as_str()) {
607 continue;
608 }
609 tables.push(describe_table_with_catalog(&catalog, name.as_str())?);
610 }
611 Ok(tables)
612 }
613
614 /// Describe a single table by name. Returns the same JSON shape as an
615 /// element of [`Self::describe_tables`] (`name`, `columns`, `row_count`).
616 ///
617 /// Errors with [`ErrorCode::TableNotFound`] when the table doesn't exist
618 /// or is an internal `_hyperdb_*` bookkeeping table (callers should not
619 /// be able to probe infrastructure via this path; it stays consistent
620 /// with the full-list variant that hides them).
621 ///
622 /// Uses `get_table_names("public")` as the authoritative existence check
623 /// rather than pattern-matching the error string from
624 /// `get_table_definition`, because the latter's wording varies across
625 /// Hyper versions and can slip past `translate_table_missing`.
626 ///
627 /// # Errors
628 ///
629 /// - Returns [`ErrorCode::TableNotFound`] if `table_name` is an
630 /// internal `_hyperdb_*` table or does not appear in `public`.
631 /// - Propagates any error from [`Catalog::get_table_names`] or from
632 /// `describe_table_with_catalog` (connection loss, catalog probe
633 /// failures).
634 pub fn describe_table(&self, table_name: &str) -> Result<Value, McpError> {
635 if is_internal_table(table_name) {
636 return Err(McpError::new(
637 ErrorCode::TableNotFound,
638 format!("Table '{table_name}' does not exist"),
639 ));
640 }
641 let catalog = Catalog::new(&self.connection);
642 let exists = catalog
643 .get_table_names("public")
644 .map_err(McpError::from)?
645 .iter()
646 .any(|n| n.as_str() == table_name);
647 if !exists {
648 return Err(McpError::new(
649 ErrorCode::TableNotFound,
650 format!("Table '{table_name}' does not exist"),
651 ));
652 }
653 describe_table_with_catalog(&catalog, table_name)
654 }
655
656 /// Sample rows from a table along with its schema and total row count.
657 ///
658 /// Returns a single JSON object with `table`, `row_count`, `sample_size`,
659 /// `schema`, and `rows`. `n` is clamped to the range `1..=100`.
660 /// Returns [`ErrorCode::TableNotFound`] if the table doesn't exist.
661 ///
662 /// Avoids the `Catalog::has_table` probe entirely — we just run the sample
663 /// SELECT first and translate a Hyper "table does not exist" error into
664 /// our own [`ErrorCode::TableNotFound`]. This sidesteps the old pattern
665 /// where a racy `has_table` silently returning `Err` would be rewritten
666 /// to `false` and surface as a spurious `TableNotFound` for tables that
667 /// actually exist.
668 ///
669 /// # Errors
670 ///
671 /// - Returns [`ErrorCode::TableNotFound`] (via `translate_table_missing`)
672 /// if the sample `SELECT` surfaces a Hyper "table does not exist" error.
673 /// - Propagates any other [`McpError`] from the sample query — SQL
674 /// errors, permission failures, or connection loss.
675 /// - The subsequent `COUNT(*)` and `get_table_definition` calls are
676 /// best-effort: their errors are swallowed so the sample payload
677 /// is still returned when available.
678 pub fn sample_table(&self, table_name: &str, n: u64) -> Result<Value, McpError> {
679 let n = n.clamp(1, 100);
680 let quoted = table_name.replace('"', "\"\"");
681
682 let select_sql = format!("SELECT * FROM \"{quoted}\" LIMIT {n}");
683 let rows = match self.execute_query_to_json(&select_sql) {
684 Ok(r) => r,
685 Err(e) => return Err(translate_table_missing(e, table_name)),
686 };
687
688 let count_sql = format!("SELECT COUNT(*) AS cnt FROM \"{quoted}\"");
689 let row_count = self
690 .execute_query_to_json(&count_sql)
691 .ok()
692 .and_then(|rs| {
693 rs.first()
694 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
695 })
696 .unwrap_or(0);
697
698 let catalog = Catalog::new(&self.connection);
699 let columns: Vec<Value> = match catalog.get_table_definition(table_name) {
700 Ok(def) => def
701 .columns()
702 .iter()
703 .map(|col| {
704 json!({
705 "name": col.name,
706 "type": col.type_name(),
707 "nullable": col.nullable,
708 })
709 })
710 .collect(),
711 Err(_) => {
712 // Catalog read may fail transiently during wire desync. We
713 // already have the sample rows; return what we have without
714 // the column metadata rather than failing the whole call.
715 Vec::new()
716 }
717 };
718
719 Ok(json!({
720 "table": table_name,
721 "row_count": row_count,
722 "sample_size": rows.len(),
723 "schema": columns,
724 "rows": rows,
725 }))
726 }
727
728 /// Collect workspace health and size metrics for the `status` MCP tool.
729 ///
730 /// Includes `logs` with paths to the `hyperd` log file (if one exists yet)
731 /// and the MCP client log. These are the first files to check when
732 /// something misbehaves.
733 ///
734 /// # Errors
735 ///
736 /// Propagates any error from [`Catalog::get_table_names`]. Per-table
737 /// row counts and disk usage fall back to `0` on read failure, so
738 /// these do not bubble up.
739 pub fn status(&self) -> Result<Value, McpError> {
740 let catalog = Catalog::new(&self.connection);
741 let all_names = catalog.get_table_names("public").map_err(McpError::from)?;
742 // Same filter as `describe_tables`: the saved-queries meta-table
743 // and any other `_hyperdb_*` internal tables shouldn't bump the
744 // user-visible `table_count` / `total_rows`.
745 let table_names: Vec<_> = all_names
746 .iter()
747 .filter(|n| !is_internal_table(n.as_str()))
748 .collect();
749 let table_count = table_names.len();
750
751 let total_rows: i64 = table_names
752 .iter()
753 .map(|name| catalog.get_row_count(name.as_str()).unwrap_or(0))
754 .sum();
755
756 let disk_bytes = std::fs::metadata(&self.workspace_path).map_or(0, |m| m.len());
757
758 let hyperd_log = self.hyperd_log_path().map_or(Value::Null, |p| {
759 Value::String(p.to_string_lossy().into_owned())
760 });
761 let client_log_path = self.log_dir.join(CLIENT_LOG_FILE_NAME);
762 let client_log = if client_log_path.exists() {
763 Value::String(client_log_path.to_string_lossy().into_owned())
764 } else {
765 Value::Null
766 };
767
768 Ok(json!({
769 "hyperd_running": self.hyper.is_running(),
770 "workspace_path": self.workspace_path.to_string_lossy(),
771 "workspace_mode": if self.is_persistent { "persistent" } else { "ephemeral" },
772 "table_count": table_count,
773 "total_rows": total_rows,
774 "disk_usage_bytes": disk_bytes,
775 // The MCP server and the `hyperdb-api` crate it's built on live in
776 // the same Cargo workspace and ship from the same commit, so a
777 // single version string identifies both. Label it by the
778 // underlying library since that's the more fundamental
779 // identifier — the MCP server is a thin layer over the Hyper
780 // Rust API.
781 "hyper_rust_api_version": crate::version::hyper_api_version_string(),
782 "logs": {
783 "log_dir": self.log_dir.to_string_lossy(),
784 "hyperd_log": hyperd_log,
785 "client_log": client_log,
786 },
787 }))
788 }
789}
790
791/// Convert a single cell from a Hyper result row into a JSON `Value`.
792///
793/// Dispatches on the column's SQL OID so each type is decoded through the
794/// right [`hyperdb_api::Row::get`] instantiation. When a type isn't explicitly
795/// handled, falls back to string decoding — safe for textual types but
796/// produces garbage for binary types, so every type we might actually see
797/// should have its own branch.
798///
799/// # Type mapping
800///
801/// | Hyper OID | JSON shape |
802/// |-----------|------------|
803/// | `BOOL` | `true`/`false` |
804/// | `SMALL_INT` / `INT` / `BIG_INT` | number |
805/// | `DOUBLE` / `FLOAT` | number |
806/// | `NUMERIC` | number when losslessly representable as `f64`, else string |
807/// | `DATE` | ISO 8601 date string (`YYYY-MM-DD`) |
808/// | `TIMESTAMP` / `TIMESTAMP_TZ` | ISO 8601 timestamp string |
809/// | `TEXT` / `VARCHAR` | string |
810/// | anything else | string (fallback; may be garbage for binary types) |
811fn row_value_to_json(row: &hyperdb_api::Row, idx: usize, sql_type: &SqlType) -> Value {
812 use hyperdb_api::oids;
813 use hyperdb_api::{Date, Numeric, OffsetTimestamp, Timestamp};
814
815 if row.is_null(idx) {
816 return Value::Null;
817 }
818 let oid_val = sql_type.internal_oid();
819 if oid_val == oids::BOOL.0 {
820 return row.get::<bool>(idx).map_or(Value::Null, Value::Bool);
821 }
822 if oid_val == oids::SMALL_INT.0 {
823 return row
824 .get::<i16>(idx)
825 .map_or(Value::Null, |v| Value::Number(v.into()));
826 }
827 if oid_val == oids::INT.0 {
828 return row
829 .get::<i32>(idx)
830 .map_or(Value::Null, |v| Value::Number(v.into()));
831 }
832 if oid_val == oids::BIG_INT.0 {
833 return row
834 .get::<i64>(idx)
835 .map_or(Value::Null, |v| Value::Number(v.into()));
836 }
837 if oid_val == oids::DOUBLE.0 || oid_val == oids::FLOAT.0 {
838 return row
839 .get::<f64>(idx)
840 .and_then(|v| serde_json::Number::from_f64(v).map(Value::Number))
841 .unwrap_or(Value::Null);
842 }
843 if oid_val == oids::NUMERIC.0 {
844 // `Row` is schema-aware as of the upstream NUMERIC fix — it
845 // carries an `Arc<ResultSchema>` and `row.get::<Numeric>()`
846 // reads the scale from the column's
847 // `SqlType::Numeric { precision, scale }` descriptor before
848 // dispatching on the buffer length. That covers all three
849 // NUMERIC wire shapes the server can send on a query result:
850 //
851 // * 8-byte `Numeric` (precision ≤ 18, e.g. `AVG(INT)`)
852 // * 16-byte `BigNumeric` (precision > 18)
853 // * Arrow `Decimal128`/`Decimal256` (gRPC transport)
854 //
855 // Prior to the upstream fix, `type_modifier` was being dropped
856 // during `RowDescription` parsing so the scale presented here
857 // was always `0`, the 8-byte form wasn't decodable at all, and
858 // `AVG` results fell through to `Null`. All of that is now
859 // handled inside `hyperdb-api`; this function only needs to pick
860 // the JSON shape.
861 //
862 // `Numeric::to_string()` uses the decoded scale, so round-trip
863 // through `f64` is only used for JSON compactness — if the
864 // value doesn't fit in `f64` losslessly (`serde_json::Number::
865 // from_f64` returns `None` for NaN/Infinity, and we can't
866 // always represent large i128 exactly as `f64`), fall back to
867 // the string form so the caller sees the exact value.
868 return row.get::<Numeric>(idx).map_or(Value::Null, |n| {
869 let s = n.to_string();
870 s.parse::<f64>()
871 .ok()
872 .and_then(serde_json::Number::from_f64)
873 .map(Value::Number)
874 .unwrap_or(Value::String(s))
875 });
876 }
877 if oid_val == oids::DATE.0 {
878 // `Date`'s `Display` impl already formats as ISO 8601 `YYYY-MM-DD`.
879 return row
880 .get::<Date>(idx)
881 .map_or(Value::Null, |d| Value::String(d.to_string()));
882 }
883 if oid_val == oids::TIMESTAMP.0 {
884 return row
885 .get::<Timestamp>(idx)
886 .map_or(Value::Null, |t| Value::String(t.to_string()));
887 }
888 if oid_val == oids::TIMESTAMP_TZ.0 {
889 return row
890 .get::<OffsetTimestamp>(idx)
891 .map_or(Value::Null, |t| Value::String(t.to_string()));
892 }
893 if oid_val == oids::TEXT.0 || oid_val == oids::VARCHAR.0 {
894 return row.get::<String>(idx).map_or(Value::Null, Value::String);
895 }
896 // Fallback: try as string. Safe for textual types we didn't list;
897 // produces garbage bytes for binary types (BYTEA, GEOGRAPHY, …)
898 // — add explicit branches above when those start appearing in
899 // real queries.
900 row.get::<String>(idx).map_or(Value::Null, Value::String)
901}
902
903/// Name of the client-side log file written in [`resolve_log_dir`].
904/// The MCP binary's `main` opens this file and sets it as a `tracing`
905/// subscriber target so both startup errors and runtime events land here.
906pub const CLIENT_LOG_FILE_NAME: &str = "hyperdb-mcp.log";
907
908/// Name-prefix convention for tables that belong to the `HyperDB` MCP's
909/// own infrastructure (currently the `_hyperdb_saved_queries` meta-table
910/// used by `WorkspaceStore`). Hidden from [`Engine::describe_tables`]
911/// and from [`Engine::status`]'s `table_count` / `total_rows`, so users
912/// never see `HyperDB`'s own bookkeeping in the public catalog.
913///
914/// Any future internal table (watcher state, audit log, etc.) just
915/// needs to follow this prefix and it disappears from the public view
916/// automatically — no per-table filter list to keep in sync.
917pub const HYPERDB_INTERNAL_PREFIX: &str = "_hyperdb_";
918
919/// Returns true when `name` is one of `HyperDB`'s own internal tables
920/// (matches [`HYPERDB_INTERNAL_PREFIX`]). Factored into a helper so
921/// every filter site calls the same predicate and a future move to a
922/// more nuanced scheme (e.g. per-table allowlist) is a single edit.
923#[must_use]
924pub fn is_internal_table(name: &str) -> bool {
925 name.starts_with(HYPERDB_INTERNAL_PREFIX)
926}
927
928/// Compute the log directory for both `hyperd` output and the client-side
929/// tracing log. Shared by [`Engine::new`] and `main` so both land in the
930/// same place.
931///
932/// - Persistent mode: same directory as the workspace file (with `~`
933/// expansion applied). This way a project workspace like
934/// `~/projects/foo.hyper` gets logs in `~/projects/`.
935/// - Ephemeral mode: same temp directory the Engine creates for the
936/// workspace (`$TMPDIR/hyperdb-mcp-<pid>/`).
937#[must_use]
938pub fn resolve_log_dir(workspace_path: Option<&str>) -> PathBuf {
939 match workspace_path {
940 Some(p) => {
941 let expanded = PathBuf::from(shellexpand_tilde(p));
942 expanded
943 .parent()
944 .map_or_else(|| PathBuf::from("."), std::path::Path::to_path_buf)
945 }
946 None => std::env::temp_dir().join(format!("hyperdb-mcp-{}", std::process::id())),
947 }
948}
949
950/// Build the `{name, columns, row_count}` JSON for a single table, shared
951/// between [`Engine::describe_tables`] (bulk) and [`Engine::describe_table`]
952/// (single) so both paths emit byte-identical shapes. A missing table
953/// surfaces as the underlying Hyper "relation does not exist" error; single-
954/// table callers should run it through `translate_table_missing`.
955fn describe_table_with_catalog(catalog: &Catalog<'_>, name: &str) -> Result<Value, McpError> {
956 let def = catalog.get_table_definition(name).map_err(McpError::from)?;
957 let row_count = catalog.get_row_count(name).unwrap_or(0);
958 let columns: Vec<Value> = def
959 .columns()
960 .iter()
961 .map(|col| {
962 json!({
963 "name": col.name,
964 "type": col.type_name(),
965 "nullable": col.nullable,
966 })
967 })
968 .collect();
969 Ok(json!({
970 "name": name,
971 "columns": columns,
972 "row_count": row_count,
973 }))
974}
975
976/// Translate an "undefined table / relation does not exist" error from Hyper
977/// into our own [`ErrorCode::TableNotFound`] with a consistent message.
978/// Any other error is passed through unchanged.
979fn translate_table_missing(err: McpError, table_name: &str) -> McpError {
980 let m = err.message.to_lowercase();
981 let looks_like_missing = m.contains("does not exist")
982 || m.contains("relation")
983 || m.contains("undefined table")
984 || err.message.contains("42P01");
985 if looks_like_missing {
986 McpError::new(
987 ErrorCode::TableNotFound,
988 format!("Table '{table_name}' does not exist"),
989 )
990 } else {
991 err
992 }
993}
994
995/// Returns `true` if a SQL statement is read-only: `SELECT`, `WITH`, `EXPLAIN`,
996/// `SHOW`, or `VALUES`. Anything else (`CREATE`, `INSERT`, `UPDATE`, `DELETE`,
997/// `DROP`, `ALTER`, `COPY`, ...) is considered mutating.
998///
999/// The check is a simple prefix match after trimming and upper-casing the first
1000/// Checks whether the first SQL keyword indicates a read-only statement.
1001///
1002/// Strips leading whitespace and SQL comments (line `--` and block `/* */`)
1003/// before inspecting the first alphabetic token. This prevents comment-based
1004/// bypass of the read-only guard (e.g. `/* harmless */ DROP TABLE ...`).
1005///
1006/// Note: data-modifying CTEs (`WITH x AS (DELETE ...) SELECT ...`) still slip
1007/// past this check. Hyper itself rejects such CTEs, so this is defense-in-depth
1008/// rather than the sole security boundary.
1009#[must_use]
1010pub fn is_read_only_sql(sql: &str) -> bool {
1011 let stripped = strip_leading_sql_comments(sql);
1012 let first_token: String = stripped
1013 .chars()
1014 .take_while(|c| c.is_alphabetic())
1015 .flat_map(char::to_uppercase)
1016 .collect();
1017 matches!(
1018 first_token.as_str(),
1019 "SELECT" | "WITH" | "EXPLAIN" | "SHOW" | "VALUES"
1020 )
1021}
1022
1023/// Strips leading whitespace, line comments (`--`), and block comments (`/* */`)
1024/// from SQL text. Handles nested block comments.
1025fn strip_leading_sql_comments(sql: &str) -> &str {
1026 let mut s = sql;
1027 loop {
1028 s = s.trim_start();
1029 if s.starts_with("--") {
1030 // Line comment — skip to end of line (handles LF, CRLF, and CR)
1031 match s.find(&['\n', '\r'][..]) {
1032 Some(pos) => {
1033 let mut next = pos + 1;
1034 // Handle CRLF: skip both characters
1035 if s.as_bytes().get(pos) == Some(&b'\r')
1036 && s.as_bytes().get(pos + 1) == Some(&b'\n')
1037 {
1038 next = pos + 2;
1039 }
1040 s = &s[next..];
1041 }
1042 None => return "",
1043 }
1044 } else if s.starts_with("/*") {
1045 // Block comment — find matching close, handling nesting
1046 let mut depth = 0u32;
1047 let mut chars = s.char_indices().peekable();
1048 let mut end = None;
1049 while let Some((i, c)) = chars.next() {
1050 if c == '/' && chars.peek().map(|(_, c2)| *c2) == Some('*') {
1051 chars.next();
1052 depth += 1;
1053 } else if c == '*' && chars.peek().map(|(_, c2)| *c2) == Some('/') {
1054 chars.next();
1055 depth -= 1;
1056 if depth == 0 {
1057 end = Some(i + 2);
1058 break;
1059 }
1060 }
1061 }
1062 match end {
1063 Some(pos) => s = &s[pos..],
1064 None => return "", // Unclosed comment — no valid SQL
1065 }
1066 } else {
1067 break;
1068 }
1069 }
1070 s
1071}
1072
1073/// Minimal `~/` (and `~\` on Windows) expansion. Resolves the home
1074/// directory via `$HOME` on Unix and `%USERPROFILE%` (falling back to
1075/// `%HOMEDRIVE%%HOMEPATH%`) on Windows. `~username/` is not supported —
1076/// callers who need that should expand their paths themselves.
1077fn shellexpand_tilde(path: &str) -> String {
1078 let rest = if let Some(r) = path.strip_prefix("~/") {
1079 Some(r)
1080 } else if cfg!(windows) {
1081 path.strip_prefix("~\\")
1082 } else {
1083 None
1084 };
1085 let Some(rest) = rest else {
1086 return path.to_string();
1087 };
1088 let Some(home) = home_dir() else {
1089 return path.to_string();
1090 };
1091 let sep = std::path::MAIN_SEPARATOR;
1092 format!("{}{sep}{rest}", home.to_string_lossy())
1093}
1094
1095/// Resolve the user's home directory across platforms. Unix uses `$HOME`;
1096/// Windows prefers `%USERPROFILE%` and falls back to `%HOMEDRIVE%%HOMEPATH%`.
1097fn home_dir() -> Option<PathBuf> {
1098 if cfg!(windows) {
1099 if let Some(profile) = std::env::var_os("USERPROFILE") {
1100 if !profile.is_empty() {
1101 return Some(PathBuf::from(profile));
1102 }
1103 }
1104 let drive = std::env::var_os("HOMEDRIVE")?;
1105 let rel = std::env::var_os("HOMEPATH")?;
1106 let mut combined = PathBuf::from(drive);
1107 combined.push(PathBuf::from(rel));
1108 Some(combined)
1109 } else {
1110 std::env::var_os("HOME").map(PathBuf::from)
1111 }
1112}