Skip to main content

hyperdb_mcp/
error.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Structured error types for MCP tool responses.
5//!
6//! Every error carries a machine-readable [`ErrorCode`] and a human-readable message.
7//! Most codes also get a default `suggestion` string that helps LLMs self-correct
8//! without a round-trip to the user.
9
10use serde::Serialize;
11
12/// Machine-readable error codes returned in MCP tool error responses.
13///
14/// Serialized as `SCREAMING_SNAKE_CASE` (e.g. `HYPERD_NOT_FOUND`) so LLM clients
15/// can pattern-match without parsing prose.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
18pub enum ErrorCode {
19    /// The `hyperd` binary was not found at `HYPERD_PATH` or on `PATH`.
20    HyperdNotFound,
21    /// A file path argument points to a nonexistent or unreadable file.
22    FileNotFound,
23    /// The file extension or explicit format string is not one we handle.
24    UnsupportedFormat,
25    /// Data doesn't match the inferred or user-provided schema.
26    SchemaMismatch,
27    /// SQL syntax error or reference to a nonexistent column.
28    SqlError,
29    /// A referenced table does not exist in the workspace.
30    TableNotFound,
31    /// The input data is empty (zero rows or zero columns).
32    EmptyData,
33    /// Workspace disk is full or write quota exceeded.
34    DiskFull,
35    /// Filesystem permission denied on a source or target path.
36    PermissionDenied,
37    /// Server is running in read-only mode and the requested operation would mutate state.
38    ReadOnlyViolation,
39    /// The connection to `hyperd` was lost (crash, broken pipe, EOF) or
40    /// the wire protocol fell out of sync (a bounded drain exhausted
41    /// without reaching `ReadyForQuery`, surfacing as a
42    /// `"desynchronized"` error message from the `hyper-client` layer).
43    /// Either way, the connection is unusable and the MCP server will
44    /// automatically tear down the [`crate::engine::Engine`] and
45    /// reconnect on the next call.
46    ConnectionLost,
47    /// A tool argument is malformed or violates a precondition that the
48    /// caller can fix (bad alias shape, wrong mode string, reserved name,
49    /// etc.). Distinct from [`Self::SchemaMismatch`] in that the argument
50    /// itself is wrong, not the data it refers to.
51    InvalidArgument,
52    /// A resource (typically a `.hyper` file) is held by another process
53    /// and cannot be opened exclusively. Surfaced when `ATTACH DATABASE`
54    /// fails because another MCP server or `hyperd` owns the file.
55    ResourceBusy,
56    /// Catch-all for unexpected failures (panics, I/O, lock poisoning).
57    InternalError,
58}
59
60/// An error type designed for MCP tool responses.
61///
62/// Serializes to JSON with `code`, `message`, and an optional `suggestion`.
63/// The suggestion is aimed at the LLM caller — it tells the model how to retry
64/// or what parameter to fix, reducing round-trips.
65#[derive(Debug, Clone, Serialize)]
66pub struct McpError {
67    pub code: ErrorCode,
68    pub message: String,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub suggestion: Option<String>,
71}
72
73impl McpError {
74    /// Create an error with an auto-generated suggestion based on the error code.
75    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
76        let message = message.into();
77        let suggestion = default_suggestion(code, &message);
78        Self {
79            code,
80            message,
81            suggestion,
82        }
83    }
84
85    #[must_use]
86    /// Override the default suggestion with a context-specific one.
87    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
88        self.suggestion = Some(suggestion.into());
89        self
90    }
91
92    /// Maps a filesystem [`std::io::Error`] to an [`McpError`], preserving the
93    /// distinction between a missing file and a permission problem instead of
94    /// collapsing both to [`ErrorCode::FileNotFound`].
95    #[must_use]
96    pub fn from_io_error(err: &std::io::Error, context: &str) -> Self {
97        let code = match err.kind() {
98            std::io::ErrorKind::PermissionDenied => ErrorCode::PermissionDenied,
99            std::io::ErrorKind::NotFound => ErrorCode::FileNotFound,
100            _ => ErrorCode::InternalError,
101        };
102        McpError::new(code, format!("{context}: {err}"))
103    }
104}
105
106impl std::fmt::Display for McpError {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        write!(f, "[{:?}] {}", self.code, self.message)
109    }
110}
111
112impl std::error::Error for McpError {}
113
114/// Returns a recovery hint for each error code. These are intentionally
115/// phrased as instructions so an LLM can act on them directly.
116fn default_suggestion(code: ErrorCode, _message: &str) -> Option<String> {
117    match code {
118        ErrorCode::HyperdNotFound => Some("Set HYPERD_PATH environment variable or ensure hyperd is on PATH".into()),
119        ErrorCode::FileNotFound => Some("Verify the file path exists and is accessible".into()),
120        ErrorCode::UnsupportedFormat => Some("Specify format explicitly: json, csv, parquet, or arrow_ipc".into()),
121        ErrorCode::SchemaMismatch => Some("Retry with an explicit schema override".into()),
122        ErrorCode::SqlError => Some("Check SQL syntax. Hyper uses the Data Cloud SQL dialect (PostgreSQL-compatible).".into()),
123        ErrorCode::TableNotFound => Some("Use the describe tool to list available tables".into()),
124        ErrorCode::EmptyData => None,
125        ErrorCode::DiskFull => Some("Check disk space. Use the status tool to see workspace size.".into()),
126        ErrorCode::PermissionDenied => Some("Check file permissions on the source or target path".into()),
127        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()),
128        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()),
129        ErrorCode::InvalidArgument => Some("Check the tool argument shape and allowed values. The message identifies the offending field.".into()),
130        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()),
131        ErrorCode::InternalError => None,
132    }
133}
134
135/// Converts a `hyperdb_api::Error` into an [`McpError`] by inspecting
136/// the structured variant first, falling back to message-substring
137/// classification for variants whose payload is just a `String`.
138impl From<hyperdb_api::Error> for McpError {
139    fn from(err: hyperdb_api::Error) -> Self {
140        // Structured variants get classified by their type, not their
141        // message. SQLSTATE-bearing server errors are routed by
142        // SQLSTATE code directly — no string sniffing.
143        if let hyperdb_api::Error::Server {
144            sqlstate: Some(ref code),
145            ..
146        } = err
147        {
148            match code.as_str() {
149                "22003" => {
150                    // numeric_value_out_of_range
151                    return McpError::new(ErrorCode::SchemaMismatch, err.to_string()).with_suggestion(
152                        "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.");
153                }
154                "22P02" => {
155                    // invalid_text_representation
156                    return McpError::new(ErrorCode::SchemaMismatch, err.to_string()).with_suggestion(
157                        "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.");
158                }
159                "0A000" => {
160                    // feature_not_supported — could be Hyper's "multi-part
161                    // queries" OR an unimplemented function (e.g. JSON_VALUE).
162                    let lower = err.to_string().to_lowercase();
163                    if lower.contains("json") {
164                        return McpError::new(ErrorCode::SqlError, err.to_string()).with_suggestion(
165                            "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 = '...'`.");
166                    }
167                    return McpError::new(ErrorCode::SqlError, err.to_string()).with_suggestion(
168                        "Hyper only accepts one SQL statement per call. Split your query into separate execute/query calls — one per statement.");
169                }
170                "42601" => {
171                    // syntax_error — includes "requires a structured data type"
172                    // when ->/->>' is applied to raw TEXT. Only steer toward the
173                    // JSON cast when the message actually points at that case;
174                    // otherwise leave a generic SQL error (no misleading hint).
175                    let lower = err.to_string().to_lowercase();
176                    if lower.contains("structured data type") || lower.contains("json") {
177                        return McpError::new(ErrorCode::SqlError, err.to_string()).with_suggestion(
178                            "The -> / ->> operators need a structured type. Cast the TEXT value to json first, e.g. `value::json ->> 'field'`.");
179                    }
180                    return McpError::new(ErrorCode::SqlError, err.to_string());
181                }
182                _ => {} // fall through to message-based classification
183            }
184        }
185
186        // Connection-lost / transport-desync detection — these may
187        // arrive as Connection, Closed, or Internal variants depending
188        // on where they originate; sniff the message string.
189        let msg = err.to_string();
190        let lower = msg.to_lowercase();
191        if is_connection_lost(&msg) {
192            return McpError::new(ErrorCode::ConnectionLost, msg);
193        }
194
195        // Resource-busy is a hyperd attach-time error; same multi-source
196        // problem as connection-lost.
197        if is_resource_busy(&msg) {
198            return McpError::new(ErrorCode::ResourceBusy, msg);
199        }
200
201        // Variant-driven classification for the remaining cases.
202        match err {
203            // File-not-found errors come back as NotFound or as a Server
204            // error containing the phrase; check both.
205            hyperdb_api::Error::NotFound(_) => McpError::new(ErrorCode::FileNotFound, msg),
206
207            // Server errors without a SQLSTATE we recognize fall through
208            // to the substring fallback below.
209            hyperdb_api::Error::Server { .. } => {
210                // Legacy substring fallback — covers messages whose
211                // SQLSTATE was carried in the text (older hyperd
212                // versions) rather than the structured field.
213                if msg.contains("22003")
214                    || lower.contains("numeric overflow")
215                    || lower.contains("out of range")
216                {
217                    McpError::new(ErrorCode::SchemaMismatch, msg).with_suggestion(
218                        "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.")
219                } else if msg.contains("22P02") || lower.contains("invalid input syntax") {
220                    McpError::new(ErrorCode::SchemaMismatch, msg).with_suggestion(
221                        "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.")
222                } else if msg.contains("syntax error")
223                    || (msg.contains("does not exist") && msg.contains("column"))
224                {
225                    McpError::new(ErrorCode::SqlError, msg)
226                } else if msg.contains("No such file") || msg.contains("not found") {
227                    McpError::new(ErrorCode::FileNotFound, msg)
228                } else {
229                    McpError::new(ErrorCode::SqlError, msg)
230                }
231            }
232
233            // Conversion errors are usually decode failures from
234            // result-row processing; map to InternalError until we
235            // surface them more specifically.
236            hyperdb_api::Error::Conversion(_) => McpError::new(ErrorCode::InternalError, msg),
237
238            // Configuration errors are caller-visible setup mistakes.
239            hyperdb_api::Error::Config(_) => McpError::new(ErrorCode::InvalidArgument, msg),
240
241            // Caller-fixable argument errors: an invalid identifier (e.g. a
242            // KV store/key with a disallowed byte or over the length limit)
243            // or a malformed table definition (zero columns, conflicting
244            // attributes). These are triggered by the tool arguments an LLM
245            // supplies, and the message names what's wrong, so they are
246            // InvalidArgument, not an opaque InternalError.
247            //
248            // NOTE: `InvalidOperation` is deliberately NOT included — it is
249            // hyperdb-api "caller-API misuse" where the *caller* is this
250            // MCP's own Rust code (e.g. mixing inserter modes), not the LLM.
251            // If it ever fired it would signal an MCP bug the model can't fix
252            // by changing arguments, so it correctly stays InternalError.
253            hyperdb_api::Error::InvalidName(_) | hyperdb_api::Error::InvalidTableDefinition(_) => {
254                McpError::new(ErrorCode::InvalidArgument, msg)
255            }
256
257            // Connection / Closed / Timeout — surface as ConnectionLost
258            // so the engine recycles. is_connection_lost above already
259            // catches most of these via message; this is a fallback.
260            hyperdb_api::Error::Connection { .. }
261            | hyperdb_api::Error::Closed { .. }
262            | hyperdb_api::Error::Timeout(_)
263            | hyperdb_api::Error::Cancelled { .. } => McpError::new(ErrorCode::ConnectionLost, msg),
264
265            _ => McpError::new(ErrorCode::InternalError, msg),
266        }
267    }
268}
269
270/// Classify an error message as one where the underlying connection is no
271/// longer usable and the caller should recycle it. Used to decide whether
272/// the [`crate::engine::Engine`] should be torn down and reinitialized
273/// before the next call.
274///
275/// Covers two distinct failure modes:
276///
277/// 1. **Transport-level disappearance** — OS broken-pipe / reset / refused
278///    plus the generic end-of-file and "connection closed" responses the
279///    `PostgreSQL` client produces when `hyperd` crashes or is killed
280///    mid-transaction.
281///
282/// 2. **Wire-protocol desync** — the `hyper-client` layer marks a
283///    connection `desynchronized` when its bounded drain exhausts the
284///    `POST_ERROR_DRAIN_CAP` budget without reaching `ReadyForQuery` or
285///    hits an I/O error mid-drain. Subsequent operations on that
286///    connection fast-fail with an
287///    `ErrorKind::Connection` whose message contains `"desynchronized"`.
288///    The socket is technically still open but the wire state is corrupt
289///    and the only valid recovery is the same as #1: discard the
290///    connection and reconnect. Recognizing the signal here is what
291///    makes the mcp server's auto-reconnect path kick in for that case
292///    instead of returning the drain-poisoned error to callers forever.
293#[must_use]
294pub fn is_connection_lost(msg: &str) -> bool {
295    let lower = msg.to_lowercase();
296    // Transport-level disappearance.
297    lower.contains("broken pipe")
298        || lower.contains("connection reset")
299        || lower.contains("connection refused")
300        || lower.contains("connection closed")
301        || lower.contains("unexpected eof")
302        || lower.contains("end of file")
303        || lower.contains("unexpectedly closed")
304        || lower.contains("socket is not connected")
305        // Wire-protocol desync (see function-level comment).
306        || lower.contains("desynchronized")
307}
308
309/// Classify a hyperd error message as "the file is already opened by
310/// somebody else" so the registry can surface a clear
311/// [`ErrorCode::ResourceBusy`] instead of a generic internal error.
312/// Matches the wording hyperd uses when a `.hyper` file is locked or
313/// already attached by another process.
314fn is_resource_busy(msg: &str) -> bool {
315    let lower = msg.to_lowercase();
316    lower.contains("already attached")
317        || lower.contains("database is in use")
318        || lower.contains("could not lock")
319        || lower.contains("already in use")
320        || lower.contains("file is locked")
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn io_error_preserves_permission_denied() {
329        let e = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
330        assert_eq!(
331            McpError::from_io_error(&e, "value_path").code,
332            ErrorCode::PermissionDenied
333        );
334    }
335
336    #[test]
337    fn io_error_maps_not_found() {
338        let e = std::io::Error::from(std::io::ErrorKind::NotFound);
339        assert_eq!(
340            McpError::from_io_error(&e, "value_path").code,
341            ErrorCode::FileNotFound
342        );
343    }
344
345    #[test]
346    fn json_value_error_suggests_cast_not_split() {
347        let err = hyperdb_api::Error::server(
348            Some("0A000".to_string()),
349            "function JSON_VALUE is not implemented yet",
350            None,
351            None,
352        );
353        let mapped = McpError::from(err);
354        let s = mapped.suggestion.unwrap_or_default();
355        assert!(
356            s.contains("::json"),
357            "expected a ::json cast hint, got: {s}"
358        );
359        assert!(
360            !s.to_lowercase().contains("split"),
361            "must not suggest splitting: {s}"
362        );
363    }
364
365    #[test]
366    fn structured_type_error_suggests_cast() {
367        let err = hyperdb_api::Error::server(
368            Some("42601".to_string()),
369            "operator ->> requires a structured data type",
370            None,
371            None,
372        );
373        let s = McpError::from(err).suggestion.unwrap_or_default();
374        assert!(
375            s.contains("::json"),
376            "expected a ::json cast hint, got: {s}"
377        );
378    }
379
380    #[test]
381    fn multi_statement_error_still_suggests_split() {
382        let err = hyperdb_api::Error::server(
383            Some("0A000".to_string()),
384            "multi-statement queries are not supported",
385            None,
386            None,
387        );
388        let s = McpError::from(err).suggestion.unwrap_or_default();
389        assert!(s.to_lowercase().contains("one sql statement"), "got: {s}");
390    }
391}