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 sniffing the message text
123/// to pick the most specific error code. Falls back to [`ErrorCode::InternalError`].
124impl From<hyperdb_api::Error> for McpError {
125 fn from(err: hyperdb_api::Error) -> Self {
126 let msg = err.to_string();
127 let lower = msg.to_lowercase();
128 if is_connection_lost(&msg) {
129 McpError::new(ErrorCode::ConnectionLost, msg)
130 } else if msg.contains("Multi-part queries") || msg.contains("0A000") {
131 // Hyper only allows one statement per call. Rewrite the error
132 // with an actionable suggestion instead of the cryptic code.
133 McpError::new(ErrorCode::SqlError, msg)
134 .with_suggestion("Hyper only accepts one SQL statement per call. Split your query into separate execute/query calls — one per statement.")
135 } else if msg.contains("22003")
136 || lower.contains("numeric overflow")
137 || lower.contains("out of range")
138 {
139 // SQLSTATE 22003: numeric_value_out_of_range. Happens at COPY/INSERT
140 // time when a value exceeds its column type. With the partial
141 // name-keyed schema override now in place, widening a single column
142 // is a one-line retry.
143 McpError::new(ErrorCode::SchemaMismatch, msg).with_suggestion(
144 "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.")
145 } else if msg.contains("22P02") || lower.contains("invalid input syntax") {
146 // SQLSTATE 22P02: invalid_text_representation. A value couldn't be
147 // parsed into its column type (e.g. non-date string in a DATE
148 // column). Usually the safe fix is to keep the column as TEXT and
149 // cast later in SQL.
150 McpError::new(ErrorCode::SchemaMismatch, msg).with_suggestion(
151 "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.")
152 } else if msg.contains("syntax error")
153 || (msg.contains("does not exist") && msg.contains("column"))
154 {
155 McpError::new(ErrorCode::SqlError, msg)
156 } else if msg.contains("No such file") || msg.contains("not found") {
157 McpError::new(ErrorCode::FileNotFound, msg)
158 } else if is_resource_busy(&msg) {
159 // ATTACH on a .hyper file already held by another hyperd
160 // surfaces as a "database is in use" / "already attached" /
161 // "could not lock" error. Route to ResourceBusy so the LLM
162 // sees an actionable recovery hint instead of InternalError.
163 McpError::new(ErrorCode::ResourceBusy, msg)
164 } else {
165 McpError::new(ErrorCode::InternalError, msg)
166 }
167 }
168}
169
170/// Classify an error message as one where the underlying connection is no
171/// longer usable and the caller should recycle it. Used to decide whether
172/// the [`crate::engine::Engine`] should be torn down and reinitialized
173/// before the next call.
174///
175/// Covers two distinct failure modes:
176///
177/// 1. **Transport-level disappearance** — OS broken-pipe / reset / refused
178/// plus the generic end-of-file and "connection closed" responses the
179/// `PostgreSQL` client produces when `hyperd` crashes or is killed
180/// mid-transaction.
181///
182/// 2. **Wire-protocol desync** — the `hyper-client` layer marks a
183/// connection `desynchronized` when its bounded drain exhausts the
184/// `POST_ERROR_DRAIN_CAP` budget without reaching `ReadyForQuery` or
185/// hits an I/O error mid-drain. Subsequent operations on that
186/// connection fast-fail with an
187/// `ErrorKind::Connection` whose message contains `"desynchronized"`.
188/// The socket is technically still open but the wire state is corrupt
189/// and the only valid recovery is the same as #1: discard the
190/// connection and reconnect. Recognizing the signal here is what
191/// makes the mcp server's auto-reconnect path kick in for that case
192/// instead of returning the drain-poisoned error to callers forever.
193#[must_use]
194pub fn is_connection_lost(msg: &str) -> bool {
195 let lower = msg.to_lowercase();
196 // Transport-level disappearance.
197 lower.contains("broken pipe")
198 || lower.contains("connection reset")
199 || lower.contains("connection refused")
200 || lower.contains("connection closed")
201 || lower.contains("unexpected eof")
202 || lower.contains("end of file")
203 || lower.contains("unexpectedly closed")
204 || lower.contains("socket is not connected")
205 // Wire-protocol desync (see function-level comment).
206 || lower.contains("desynchronized")
207}
208
209/// Classify a hyperd error message as "the file is already opened by
210/// somebody else" so the registry can surface a clear
211/// [`ErrorCode::ResourceBusy`] instead of a generic internal error.
212/// Matches the wording hyperd uses when a `.hyper` file is locked or
213/// already attached by another process.
214fn is_resource_busy(msg: &str) -> bool {
215 let lower = msg.to_lowercase();
216 lower.contains("already attached")
217 || lower.contains("database is in use")
218 || lower.contains("could not lock")
219 || lower.contains("already in use")
220 || lower.contains("file is locked")
221}