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}