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 // Connection / Closed / Timeout — surface as ConnectionLost
211 // so the engine recycles. is_connection_lost above already
212 // catches most of these via message; this is a fallback.
213 hyperdb_api::Error::Connection { .. }
214 | hyperdb_api::Error::Closed { .. }
215 | hyperdb_api::Error::Timeout(_)
216 | hyperdb_api::Error::Cancelled { .. } => McpError::new(ErrorCode::ConnectionLost, msg),
217
218 _ => McpError::new(ErrorCode::InternalError, msg),
219 }
220 }
221}
222
223/// Classify an error message as one where the underlying connection is no
224/// longer usable and the caller should recycle it. Used to decide whether
225/// the [`crate::engine::Engine`] should be torn down and reinitialized
226/// before the next call.
227///
228/// Covers two distinct failure modes:
229///
230/// 1. **Transport-level disappearance** — OS broken-pipe / reset / refused
231/// plus the generic end-of-file and "connection closed" responses the
232/// `PostgreSQL` client produces when `hyperd` crashes or is killed
233/// mid-transaction.
234///
235/// 2. **Wire-protocol desync** — the `hyper-client` layer marks a
236/// connection `desynchronized` when its bounded drain exhausts the
237/// `POST_ERROR_DRAIN_CAP` budget without reaching `ReadyForQuery` or
238/// hits an I/O error mid-drain. Subsequent operations on that
239/// connection fast-fail with an
240/// `ErrorKind::Connection` whose message contains `"desynchronized"`.
241/// The socket is technically still open but the wire state is corrupt
242/// and the only valid recovery is the same as #1: discard the
243/// connection and reconnect. Recognizing the signal here is what
244/// makes the mcp server's auto-reconnect path kick in for that case
245/// instead of returning the drain-poisoned error to callers forever.
246#[must_use]
247pub fn is_connection_lost(msg: &str) -> bool {
248 let lower = msg.to_lowercase();
249 // Transport-level disappearance.
250 lower.contains("broken pipe")
251 || lower.contains("connection reset")
252 || lower.contains("connection refused")
253 || lower.contains("connection closed")
254 || lower.contains("unexpected eof")
255 || lower.contains("end of file")
256 || lower.contains("unexpectedly closed")
257 || lower.contains("socket is not connected")
258 // Wire-protocol desync (see function-level comment).
259 || lower.contains("desynchronized")
260}
261
262/// Classify a hyperd error message as "the file is already opened by
263/// somebody else" so the registry can surface a clear
264/// [`ErrorCode::ResourceBusy`] instead of a generic internal error.
265/// Matches the wording hyperd uses when a `.hyper` file is locked or
266/// already attached by another process.
267fn is_resource_busy(msg: &str) -> bool {
268 let lower = msg.to_lowercase();
269 lower.contains("already attached")
270 || lower.contains("database is in use")
271 || lower.contains("could not lock")
272 || lower.contains("already in use")
273 || lower.contains("file is locked")
274}