Skip to main content

oraclemcp_error/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Structured, agent-facing error envelope for the `oraclemcp` Oracle MCP
4//! server (plan §8.2, bead P0-1).
5//!
6//! The contract: agent-facing failures are returned as an MCP tool result
7//! with `isError: true` and an actionable [`ErrorEnvelope`] — **never** as an
8//! opaque JSON-RPC numeric error code. Every envelope names a machine-stable
9//! [`ErrorClass`], a human/LLM-readable `message`, and a concrete next step
10//! (`suggested_tool`, `fuzzy_matches`, or `next_steps`). For example, an
11//! Oracle `ORA-00942` becomes
12//! `{ "isError": true, "error_class": "OBJECT_NOT_FOUND",
13//!    "suggested_tool": "oracle_schema_inspect", "fuzzy_matches": [...] }`.
14//!
15//! This crate is a leaf of the `oraclemcp-*` core (it imports no other
16//! workspace crate) so every layer can produce the same envelope shape
17//! without a dependency cycle.
18
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22pub mod fuzzy;
23pub use fuzzy::{enrich_oracle_error, fuzzy_suggest, levenshtein};
24
25/// Machine-stable classification of an agent-facing error.
26///
27/// Serialized as `SCREAMING_SNAKE_CASE` so the wire value is a stable string
28/// an agent can branch on (`"OBJECT_NOT_FOUND"`, `"CHALLENGE_REQUIRED"`, …).
29/// `#[non_exhaustive]` so new classes are additive, never breaking.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
32#[non_exhaustive]
33pub enum ErrorClass {
34    /// Referenced schema object does not exist / is not visible (ORA-00942,
35    /// ORA-04043). The agent should introspect, not retry verbatim.
36    ObjectNotFound,
37    /// The connected user lacks the required Oracle privilege (ORA-01031,
38    /// ORA-01017, ORA-00942 on a privileged dictionary view).
39    InsufficientPrivilege,
40    /// The statement failed to parse / is not valid SQL or PL/SQL.
41    SyntaxError,
42    /// The server could not connect to / lost its connection to Oracle.
43    ConnectionFailed,
44    /// A tool was dispatched but requires runtime state that is absent — most
45    /// often a live Oracle connection (the offline `RuntimeStateRequired`
46    /// degradation contract).
47    RuntimeStateRequired,
48    /// The operation requires a human step-up confirmation that has not yet
49    /// been granted; the agent should poll the issued task (§7.2).
50    ChallengeRequired,
51    /// A stateful operation (transaction, savepoint, DBMS_OUTPUT) was attempted
52    /// without an active session lease (§5.1).
53    LeaseRequired,
54    /// The fail-closed classifier refused the statement outright (§5.3) — e.g.
55    /// dynamic SQL via string concat, an unbalanced multi-statement batch.
56    ForbiddenStatement,
57    /// The required operating level exceeds the session's current level and the
58    /// profile's gate has not been satisfied (§6.6).
59    OperatingLevelTooLow,
60    /// Admission control rejected the call before it touched the pool (§5.6).
61    Busy,
62    /// The request arguments were malformed or failed validation.
63    InvalidArguments,
64    /// A configured per-schema / `protected`-profile policy denied the call
65    /// (§6.2).
66    PolicyDenied,
67    /// The call exceeded its deadline (call timeout / cancellation).
68    Timeout,
69    /// A transient, retryable Oracle/network condition (ORA-03113, ORA-12170…).
70    Transient,
71    /// An unexpected internal error; the agent cannot fix it by changing input.
72    Internal,
73}
74
75impl ErrorClass {
76    /// The default built-in tool an agent should reach for to recover from
77    /// this class, if any.
78    #[must_use]
79    pub fn default_suggested_tool(self) -> Option<&'static str> {
80        match self {
81            ErrorClass::ObjectNotFound => Some("oracle_schema_inspect"),
82            ErrorClass::OperatingLevelTooLow | ErrorClass::ChallengeRequired => {
83                Some("oracle_session")
84            }
85            ErrorClass::RuntimeStateRequired | ErrorClass::ConnectionFailed => {
86                Some("oracle_connect")
87            }
88            _ => None,
89        }
90    }
91
92    /// Whether a caller may safely retry the *same* request later. Note this is
93    /// about the error condition only; DML is never auto-retried regardless
94    /// (§5.7) — that decision lives at the dispatch layer.
95    #[must_use]
96    pub fn is_retryable(self) -> bool {
97        matches!(
98            self,
99            ErrorClass::Busy | ErrorClass::Transient | ErrorClass::Timeout
100        )
101    }
102}
103
104/// The actionable, agent-facing error payload (plan §8.2).
105///
106/// `is_error` is serialized as `isError` to match the MCP tool-result shape.
107/// Empty optional fields are omitted from the wire form so envelopes stay
108/// terse.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct ErrorEnvelope {
111    /// Always `true`; marks the MCP tool result as an error.
112    #[serde(rename = "isError")]
113    pub is_error: bool,
114    /// The machine-stable class.
115    pub error_class: ErrorClass,
116    /// Human/LLM-readable explanation. Never contains bind values or secrets.
117    pub message: String,
118    /// The originating Oracle `ORA-` code, when the error came from the DB.
119    #[serde(skip_serializing_if = "Option::is_none", default)]
120    pub ora_code: Option<i32>,
121    /// The single best tool to call next.
122    #[serde(skip_serializing_if = "Option::is_none", default)]
123    pub suggested_tool: Option<String>,
124    /// Near-miss candidates (e.g. similarly-named objects) for `ObjectNotFound`.
125    #[serde(skip_serializing_if = "Vec::is_empty", default)]
126    pub fuzzy_matches: Vec<String>,
127    /// Ordered, concrete remediation steps an agent can follow.
128    #[serde(skip_serializing_if = "Vec::is_empty", default)]
129    pub next_steps: Vec<String>,
130    /// For `Busy`/`Transient`: how long to wait before retrying.
131    #[serde(skip_serializing_if = "Option::is_none", default)]
132    pub retry_after_ms: Option<u64>,
133}
134
135impl ErrorEnvelope {
136    /// Construct a new envelope of the given class with a message, deriving the
137    /// default suggested tool for the class.
138    #[must_use]
139    pub fn new(error_class: ErrorClass, message: impl Into<String>) -> Self {
140        ErrorEnvelope {
141            is_error: true,
142            error_class,
143            message: message.into(),
144            ora_code: None,
145            suggested_tool: error_class.default_suggested_tool().map(str::to_owned),
146            fuzzy_matches: Vec::new(),
147            next_steps: Vec::new(),
148            retry_after_ms: None,
149        }
150    }
151
152    /// Attach the originating Oracle error code.
153    #[must_use]
154    pub fn with_ora_code(mut self, code: i32) -> Self {
155        self.ora_code = Some(code);
156        self
157    }
158
159    /// Override the suggested tool.
160    #[must_use]
161    pub fn with_suggested_tool(mut self, tool: impl Into<String>) -> Self {
162        self.suggested_tool = Some(tool.into());
163        self
164    }
165
166    /// Attach fuzzy near-miss candidates.
167    #[must_use]
168    pub fn with_fuzzy_matches(mut self, matches: Vec<String>) -> Self {
169        self.fuzzy_matches = matches;
170        self
171    }
172
173    /// Append a remediation step.
174    #[must_use]
175    pub fn with_next_step(mut self, step: impl Into<String>) -> Self {
176        self.next_steps.push(step.into());
177        self
178    }
179
180    /// Attach a retry-after hint (milliseconds).
181    #[must_use]
182    pub fn with_retry_after_ms(mut self, ms: u64) -> Self {
183        self.retry_after_ms = Some(ms);
184        self
185    }
186
187    /// Render as a `serde_json::Value` for embedding in an MCP tool result.
188    ///
189    /// # Panics
190    /// Never in practice — the envelope is composed of plain owned data that
191    /// always serializes; a failure would indicate a serde bug, which we
192    /// surface as a deterministic fallback object rather than unwrapping.
193    #[must_use]
194    pub fn to_json(&self) -> serde_json::Value {
195        serde_json::to_value(self).unwrap_or_else(|_| {
196            serde_json::json!({
197                "isError": true,
198                "error_class": "INTERNAL",
199                "message": "error envelope failed to serialize",
200            })
201        })
202    }
203}
204
205/// Parse the leading `ORA-NNNNN` code from an Oracle error message, if present.
206///
207/// `"ORA-00942: table or view does not exist"` → `Some(942)`.
208#[must_use]
209pub fn parse_ora_code(message: &str) -> Option<i32> {
210    let idx = message.find("ORA-")?;
211    let digits: String = message[idx + 4..]
212        .chars()
213        .take_while(char::is_ascii_digit)
214        .collect();
215    if digits.is_empty() {
216        None
217    } else {
218        digits.parse::<i32>().ok()
219    }
220}
221
222/// Map a numeric Oracle error code to its [`ErrorClass`].
223///
224/// Conservative by design: anything not explicitly recognised falls to
225/// [`ErrorClass::Internal`] (an honest "we don't classify this yet") rather
226/// than guessing a friendlier class.
227#[must_use]
228pub fn classify_ora_code(code: i32) -> ErrorClass {
229    match code {
230        // Object resolution (handled before the 900..=999 syntax range so
231        // ORA-00942 classifies as a missing object, not a syntax error).
232        942 | 4043 => ErrorClass::ObjectNotFound,
233        // Privilege / authentication.
234        1031 | 1017 | 1045 | 28009 => ErrorClass::InsufficientPrivilege,
235        // Read-only transaction violation (SET TRANSACTION READ ONLY, §6.3).
236        1456 | 16000 => ErrorClass::ForbiddenStatement,
237        // Connection / network — transient & retryable.
238        3113 | 3114 | 12170 | 12541 | 12514 | 12537 | 12543 => ErrorClass::Transient,
239        // Listener / session limits — admission backpressure.
240        12519 | 18 | 20 => ErrorClass::Busy,
241        // Syntax / parse family (942 already matched above).
242        900..=999 => ErrorClass::SyntaxError,
243        // Anything else from Oracle: not yet classified — honest Internal.
244        _ => ErrorClass::Internal,
245    }
246}
247
248/// Build an agent-facing envelope from a raw Oracle error message, classifying
249/// the `ORA-` code and seeding the default suggested tool.
250#[must_use]
251pub fn envelope_from_oracle_message(message: &str) -> ErrorEnvelope {
252    match parse_ora_code(message) {
253        Some(code) => {
254            let class = classify_ora_code(code);
255            ErrorEnvelope::new(class, message.to_owned()).with_ora_code(code)
256        }
257        None => ErrorEnvelope::new(ErrorClass::Internal, message.to_owned()),
258    }
259}
260
261/// Library-side error type for `?`-propagation across the `oraclemcp` core.
262///
263/// Distinct from [`ErrorEnvelope`]: this is the internal `Result` error;
264/// [`OracleMcpError::into_envelope`] renders the agent-facing shape at the
265/// tool boundary.
266#[derive(Debug, Error)]
267#[non_exhaustive]
268pub enum OracleMcpError {
269    /// A raw Oracle driver/DB error (its `ORA-` code is parsed on conversion).
270    #[error("oracle error: {0}")]
271    Oracle(String),
272    /// A referenced object was not found; carries near-miss candidates.
273    #[error("object not found: {name}")]
274    ObjectNotFound {
275        /// The object the caller asked for.
276        name: String,
277        /// Near-miss candidates for the agent to consider.
278        fuzzy_matches: Vec<String>,
279    },
280    /// The connected user lacks a required privilege.
281    #[error("insufficient privilege: {0}")]
282    InsufficientPrivilege(String),
283    /// The statement failed the fail-closed classifier.
284    #[error("statement refused by guard: {0}")]
285    ForbiddenStatement(String),
286    /// A stateful operation needs a lease.
287    #[error("session lease required: {0}")]
288    LeaseRequired(String),
289    /// Required operating level exceeds the current level.
290    #[error("operating level too low: {0}")]
291    OperatingLevelTooLow(String),
292    /// A human step-up confirmation is required.
293    #[error("challenge required: {0}")]
294    ChallengeRequired(String),
295    /// Live runtime state (e.g. an Oracle connection) is required.
296    #[error("runtime state required: {0}")]
297    RuntimeStateRequired(String),
298    /// Admission control rejected the call.
299    #[error("server busy")]
300    Busy {
301        /// Suggested wait before retrying.
302        retry_after_ms: u64,
303    },
304    /// Invalid request arguments.
305    #[error("invalid arguments: {0}")]
306    InvalidArguments(String),
307    /// A policy denied the call.
308    #[error("policy denied: {0}")]
309    PolicyDenied(String),
310    /// An internal error.
311    #[error("internal error: {0}")]
312    Internal(String),
313}
314
315impl OracleMcpError {
316    /// Render the agent-facing [`ErrorEnvelope`].
317    #[must_use]
318    pub fn into_envelope(self) -> ErrorEnvelope {
319        match self {
320            OracleMcpError::Oracle(msg) => envelope_from_oracle_message(&msg),
321            OracleMcpError::ObjectNotFound {
322                name,
323                fuzzy_matches,
324            } => ErrorEnvelope::new(
325                ErrorClass::ObjectNotFound,
326                format!("object not found: {name}"),
327            )
328            .with_fuzzy_matches(fuzzy_matches),
329            OracleMcpError::InsufficientPrivilege(msg) => {
330                ErrorEnvelope::new(ErrorClass::InsufficientPrivilege, msg)
331            }
332            OracleMcpError::ForbiddenStatement(msg) => {
333                ErrorEnvelope::new(ErrorClass::ForbiddenStatement, msg)
334            }
335            OracleMcpError::LeaseRequired(msg) => {
336                ErrorEnvelope::new(ErrorClass::LeaseRequired, msg)
337                    .with_next_step("call oracle_session(acquire_lease) and pass the lease_id")
338            }
339            OracleMcpError::OperatingLevelTooLow(msg) => {
340                ErrorEnvelope::new(ErrorClass::OperatingLevelTooLow, msg)
341                    .with_next_step("call oracle_session(escalate, target=<level>)")
342            }
343            OracleMcpError::ChallengeRequired(msg) => {
344                ErrorEnvelope::new(ErrorClass::ChallengeRequired, msg)
345            }
346            OracleMcpError::RuntimeStateRequired(msg) => {
347                ErrorEnvelope::new(ErrorClass::RuntimeStateRequired, msg)
348            }
349            OracleMcpError::Busy { retry_after_ms } => {
350                ErrorEnvelope::new(ErrorClass::Busy, "server busy")
351                    .with_retry_after_ms(retry_after_ms)
352            }
353            OracleMcpError::InvalidArguments(msg) => {
354                ErrorEnvelope::new(ErrorClass::InvalidArguments, msg)
355            }
356            OracleMcpError::PolicyDenied(msg) => ErrorEnvelope::new(ErrorClass::PolicyDenied, msg),
357            OracleMcpError::Internal(msg) => ErrorEnvelope::new(ErrorClass::Internal, msg),
358        }
359    }
360}
361
362/// Convenience alias for fallible `oraclemcp` core operations.
363pub type Result<T> = std::result::Result<T, OracleMcpError>;
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn parse_ora_code_extracts_leading_code() {
371        assert_eq!(
372            parse_ora_code("ORA-00942: table or view does not exist"),
373            Some(942)
374        );
375        assert_eq!(
376            parse_ora_code("foo ORA-1031: insufficient privileges"),
377            Some(1031)
378        );
379        assert_eq!(parse_ora_code("no oracle code here"), None);
380        assert_eq!(parse_ora_code("ORA-: malformed"), None);
381    }
382
383    #[test]
384    fn classify_known_codes() {
385        assert_eq!(classify_ora_code(942), ErrorClass::ObjectNotFound);
386        assert_eq!(classify_ora_code(4043), ErrorClass::ObjectNotFound);
387        assert_eq!(classify_ora_code(1031), ErrorClass::InsufficientPrivilege);
388        assert_eq!(classify_ora_code(1456), ErrorClass::ForbiddenStatement);
389        assert_eq!(classify_ora_code(3113), ErrorClass::Transient);
390        assert_eq!(classify_ora_code(12519), ErrorClass::Busy);
391        assert_eq!(classify_ora_code(923), ErrorClass::SyntaxError);
392        assert_eq!(classify_ora_code(7777), ErrorClass::Internal);
393    }
394
395    #[test]
396    fn object_not_found_envelope_golden() {
397        let env = ErrorEnvelope::new(ErrorClass::ObjectNotFound, "object not found: EMPLOYES")
398            .with_ora_code(942)
399            .with_fuzzy_matches(vec!["EMPLOYEES".to_owned(), "EMPLOYEE".to_owned()]);
400        let json = serde_json::to_value(&env).expect("serialize");
401        assert_eq!(json["isError"], serde_json::json!(true));
402        assert_eq!(json["error_class"], serde_json::json!("OBJECT_NOT_FOUND"));
403        assert_eq!(json["ora_code"], serde_json::json!(942));
404        assert_eq!(
405            json["suggested_tool"],
406            serde_json::json!("oracle_schema_inspect")
407        );
408        assert_eq!(
409            json["fuzzy_matches"],
410            serde_json::json!(["EMPLOYEES", "EMPLOYEE"])
411        );
412        // next_steps and retry_after_ms are omitted when empty.
413        assert!(json.get("next_steps").is_none());
414        assert!(json.get("retry_after_ms").is_none());
415    }
416
417    #[test]
418    fn busy_envelope_carries_retry_after() {
419        let env = OracleMcpError::Busy {
420            retry_after_ms: 250,
421        }
422        .into_envelope();
423        let json = serde_json::to_value(&env).expect("serialize");
424        assert_eq!(json["error_class"], serde_json::json!("BUSY"));
425        assert_eq!(json["retry_after_ms"], serde_json::json!(250));
426    }
427
428    #[test]
429    fn oracle_message_roundtrips_through_envelope() {
430        let env = OracleMcpError::Oracle("ORA-00942: table or view does not exist".to_owned())
431            .into_envelope();
432        assert_eq!(env.error_class, ErrorClass::ObjectNotFound);
433        assert_eq!(env.ora_code, Some(942));
434        assert_eq!(env.suggested_tool.as_deref(), Some("oracle_schema_inspect"));
435    }
436
437    #[test]
438    fn envelope_serde_roundtrip_is_stable() {
439        let env = ErrorEnvelope::new(ErrorClass::LeaseRequired, "needs a lease")
440            .with_next_step("call oracle_session(acquire_lease)");
441        let json = serde_json::to_string(&env).expect("serialize");
442        let back: ErrorEnvelope = serde_json::from_str(&json).expect("deserialize");
443        assert_eq!(env, back);
444    }
445
446    #[test]
447    fn retryability_matches_class() {
448        assert!(ErrorClass::Busy.is_retryable());
449        assert!(ErrorClass::Transient.is_retryable());
450        assert!(!ErrorClass::ObjectNotFound.is_retryable());
451        assert!(!ErrorClass::ForbiddenStatement.is_retryable());
452    }
453}