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
93impl std::fmt::Display for McpError {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        write!(f, "[{:?}] {}", self.code, self.message)
96    }
97}
98
99impl std::error::Error for McpError {}
100
101/// Returns a recovery hint for each error code. These are intentionally
102/// phrased as instructions so an LLM can act on them directly.
103fn default_suggestion(code: ErrorCode, _message: &str) -> Option<String> {
104    match code {
105        ErrorCode::HyperdNotFound => Some("Set HYPERD_PATH environment variable or ensure hyperd is on PATH".into()),
106        ErrorCode::FileNotFound => Some("Verify the file path exists and is accessible".into()),
107        ErrorCode::UnsupportedFormat => Some("Specify format explicitly: json, csv, parquet, or arrow_ipc".into()),
108        ErrorCode::SchemaMismatch => Some("Retry with an explicit schema override".into()),
109        ErrorCode::SqlError => Some("Check SQL syntax. Hyper uses the Data Cloud SQL dialect (PostgreSQL-compatible).".into()),
110        ErrorCode::TableNotFound => Some("Use the describe tool to list available tables".into()),
111        ErrorCode::EmptyData => None,
112        ErrorCode::DiskFull => Some("Check disk space. Use the status tool to see workspace size.".into()),
113        ErrorCode::PermissionDenied => Some("Check file permissions on the source or target path".into()),
114        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()),
115        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()),
116        ErrorCode::InvalidArgument => Some("Check the tool argument shape and allowed values. The message identifies the offending field.".into()),
117        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()),
118        ErrorCode::InternalError => None,
119    }
120}
121
122/// Converts a `hyperdb_api::Error` into an [`McpError`] by inspecting
123/// the structured variant first, falling back to message-substring
124/// classification for variants whose payload is just a `String`.
125impl From<hyperdb_api::Error> for McpError {
126    fn from(err: hyperdb_api::Error) -> Self {
127        // Structured variants get classified by their type, not their
128        // message. SQLSTATE-bearing server errors are routed by
129        // SQLSTATE code directly — no string sniffing.
130        if let hyperdb_api::Error::Server {
131            sqlstate: Some(ref code),
132            ..
133        } = err
134        {
135            match code.as_str() {
136                "22003" => {
137                    // numeric_value_out_of_range
138                    return McpError::new(ErrorCode::SchemaMismatch, err.to_string()).with_suggestion(
139                        "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.");
140                }
141                "22P02" => {
142                    // invalid_text_representation
143                    return McpError::new(ErrorCode::SchemaMismatch, err.to_string()).with_suggestion(
144                        "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.");
145                }
146                "0A000" => {
147                    // feature_not_supported — Hyper's "Multi-part queries"
148                    return McpError::new(ErrorCode::SqlError, err.to_string()).with_suggestion(
149                        "Hyper only accepts one SQL statement per call. Split your query into separate execute/query calls — one per statement.");
150                }
151                _ => {} // fall through to message-based classification
152            }
153        }
154
155        // Connection-lost / transport-desync detection — these may
156        // arrive as Connection, Closed, or Internal variants depending
157        // on where they originate; sniff the message string.
158        let msg = err.to_string();
159        let lower = msg.to_lowercase();
160        if is_connection_lost(&msg) {
161            return McpError::new(ErrorCode::ConnectionLost, msg);
162        }
163
164        // Resource-busy is a hyperd attach-time error; same multi-source
165        // problem as connection-lost.
166        if is_resource_busy(&msg) {
167            return McpError::new(ErrorCode::ResourceBusy, msg);
168        }
169
170        // Variant-driven classification for the remaining cases.
171        match err {
172            // File-not-found errors come back as NotFound or as a Server
173            // error containing the phrase; check both.
174            hyperdb_api::Error::NotFound(_) => McpError::new(ErrorCode::FileNotFound, msg),
175
176            // Server errors without a SQLSTATE we recognize fall through
177            // to the substring fallback below.
178            hyperdb_api::Error::Server { .. } => {
179                // Legacy substring fallback — covers messages whose
180                // SQLSTATE was carried in the text (older hyperd
181                // versions) rather than the structured field.
182                if msg.contains("22003")
183                    || lower.contains("numeric overflow")
184                    || lower.contains("out of range")
185                {
186                    McpError::new(ErrorCode::SchemaMismatch, msg).with_suggestion(
187                        "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.")
188                } else if msg.contains("22P02") || lower.contains("invalid input syntax") {
189                    McpError::new(ErrorCode::SchemaMismatch, msg).with_suggestion(
190                        "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.")
191                } else if msg.contains("syntax error")
192                    || (msg.contains("does not exist") && msg.contains("column"))
193                {
194                    McpError::new(ErrorCode::SqlError, msg)
195                } else if msg.contains("No such file") || msg.contains("not found") {
196                    McpError::new(ErrorCode::FileNotFound, msg)
197                } else {
198                    McpError::new(ErrorCode::SqlError, msg)
199                }
200            }
201
202            // Conversion errors are usually decode failures from
203            // result-row processing; map to InternalError until we
204            // surface them more specifically.
205            hyperdb_api::Error::Conversion(_) => McpError::new(ErrorCode::InternalError, msg),
206
207            // Configuration errors are caller-visible setup mistakes.
208            hyperdb_api::Error::Config(_) => McpError::new(ErrorCode::InvalidArgument, msg),
209
210            // Caller-fixable argument errors: an invalid identifier (e.g. a
211            // KV store/key with a disallowed byte or over the length limit)
212            // or a malformed table definition (zero columns, conflicting
213            // attributes). These are triggered by the tool arguments an LLM
214            // supplies, and the message names what's wrong, so they are
215            // InvalidArgument, not an opaque InternalError.
216            //
217            // NOTE: `InvalidOperation` is deliberately NOT included — it is
218            // hyperdb-api "caller-API misuse" where the *caller* is this
219            // MCP's own Rust code (e.g. mixing inserter modes), not the LLM.
220            // If it ever fired it would signal an MCP bug the model can't fix
221            // by changing arguments, so it correctly stays InternalError.
222            hyperdb_api::Error::InvalidName(_) | hyperdb_api::Error::InvalidTableDefinition(_) => {
223                McpError::new(ErrorCode::InvalidArgument, msg)
224            }
225
226            // Connection / Closed / Timeout — surface as ConnectionLost
227            // so the engine recycles. is_connection_lost above already
228            // catches most of these via message; this is a fallback.
229            hyperdb_api::Error::Connection { .. }
230            | hyperdb_api::Error::Closed { .. }
231            | hyperdb_api::Error::Timeout(_)
232            | hyperdb_api::Error::Cancelled { .. } => McpError::new(ErrorCode::ConnectionLost, msg),
233
234            _ => McpError::new(ErrorCode::InternalError, msg),
235        }
236    }
237}
238
239/// Classify an error message as one where the underlying connection is no
240/// longer usable and the caller should recycle it. Used to decide whether
241/// the [`crate::engine::Engine`] should be torn down and reinitialized
242/// before the next call.
243///
244/// Covers two distinct failure modes:
245///
246/// 1. **Transport-level disappearance** — OS broken-pipe / reset / refused
247///    plus the generic end-of-file and "connection closed" responses the
248///    `PostgreSQL` client produces when `hyperd` crashes or is killed
249///    mid-transaction.
250///
251/// 2. **Wire-protocol desync** — the `hyper-client` layer marks a
252///    connection `desynchronized` when its bounded drain exhausts the
253///    `POST_ERROR_DRAIN_CAP` budget without reaching `ReadyForQuery` or
254///    hits an I/O error mid-drain. Subsequent operations on that
255///    connection fast-fail with an
256///    `ErrorKind::Connection` whose message contains `"desynchronized"`.
257///    The socket is technically still open but the wire state is corrupt
258///    and the only valid recovery is the same as #1: discard the
259///    connection and reconnect. Recognizing the signal here is what
260///    makes the mcp server's auto-reconnect path kick in for that case
261///    instead of returning the drain-poisoned error to callers forever.
262#[must_use]
263pub fn is_connection_lost(msg: &str) -> bool {
264    let lower = msg.to_lowercase();
265    // Transport-level disappearance.
266    lower.contains("broken pipe")
267        || lower.contains("connection reset")
268        || lower.contains("connection refused")
269        || lower.contains("connection closed")
270        || lower.contains("unexpected eof")
271        || lower.contains("end of file")
272        || lower.contains("unexpectedly closed")
273        || lower.contains("socket is not connected")
274        // Wire-protocol desync (see function-level comment).
275        || lower.contains("desynchronized")
276}
277
278/// Classify a hyperd error message as "the file is already opened by
279/// somebody else" so the registry can surface a clear
280/// [`ErrorCode::ResourceBusy`] instead of a generic internal error.
281/// Matches the wording hyperd uses when a `.hyper` file is locked or
282/// already attached by another process.
283fn is_resource_busy(msg: &str) -> bool {
284    let lower = msg.to_lowercase();
285    lower.contains("already attached")
286        || lower.contains("database is in use")
287        || lower.contains("could not lock")
288        || lower.contains("already in use")
289        || lower.contains("file is locked")
290}