#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub mod fuzzy;
pub use fuzzy::{enrich_oracle_error, fuzzy_suggest, levenshtein};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum ErrorClass {
ObjectNotFound,
InsufficientPrivilege,
SyntaxError,
ConnectionFailed,
RuntimeStateRequired,
ChallengeRequired,
LeaseRequired,
ForbiddenStatement,
OperatingLevelTooLow,
Busy,
InvalidArguments,
PolicyDenied,
Timeout,
Transient,
Internal,
}
impl ErrorClass {
#[must_use]
pub fn default_suggested_tool(self) -> Option<&'static str> {
match self {
ErrorClass::ObjectNotFound => Some("oracle_schema_inspect"),
ErrorClass::OperatingLevelTooLow | ErrorClass::ChallengeRequired => {
Some("oracle_session")
}
ErrorClass::RuntimeStateRequired | ErrorClass::ConnectionFailed => {
Some("oracle_connect")
}
_ => None,
}
}
#[must_use]
pub fn is_retryable(self) -> bool {
matches!(
self,
ErrorClass::Busy | ErrorClass::Transient | ErrorClass::Timeout
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorEnvelope {
#[serde(rename = "isError")]
pub is_error: bool,
pub error_class: ErrorClass,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub ora_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub suggested_tool: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub fuzzy_matches: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub next_steps: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub retry_after_ms: Option<u64>,
}
impl ErrorEnvelope {
#[must_use]
pub fn new(error_class: ErrorClass, message: impl Into<String>) -> Self {
ErrorEnvelope {
is_error: true,
error_class,
message: message.into(),
ora_code: None,
suggested_tool: error_class.default_suggested_tool().map(str::to_owned),
fuzzy_matches: Vec::new(),
next_steps: Vec::new(),
retry_after_ms: None,
}
}
#[must_use]
pub fn with_ora_code(mut self, code: i32) -> Self {
self.ora_code = Some(code);
self
}
#[must_use]
pub fn with_suggested_tool(mut self, tool: impl Into<String>) -> Self {
self.suggested_tool = Some(tool.into());
self
}
#[must_use]
pub fn with_fuzzy_matches(mut self, matches: Vec<String>) -> Self {
self.fuzzy_matches = matches;
self
}
#[must_use]
pub fn with_next_step(mut self, step: impl Into<String>) -> Self {
self.next_steps.push(step.into());
self
}
#[must_use]
pub fn with_retry_after_ms(mut self, ms: u64) -> Self {
self.retry_after_ms = Some(ms);
self
}
#[must_use]
pub fn to_json(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or_else(|_| {
serde_json::json!({
"isError": true,
"error_class": "INTERNAL",
"message": "error envelope failed to serialize",
})
})
}
}
#[must_use]
pub fn parse_ora_code(message: &str) -> Option<i32> {
let idx = message.find("ORA-")?;
let digits: String = message[idx + 4..]
.chars()
.take_while(char::is_ascii_digit)
.collect();
if digits.is_empty() {
None
} else {
digits.parse::<i32>().ok()
}
}
#[must_use]
pub fn classify_ora_code(code: i32) -> ErrorClass {
match code {
942 | 4043 => ErrorClass::ObjectNotFound,
1031 | 1017 | 1045 | 28009 => ErrorClass::InsufficientPrivilege,
1456 | 16000 => ErrorClass::ForbiddenStatement,
3113 | 3114 | 12170 | 12541 | 12514 | 12537 | 12543 => ErrorClass::Transient,
12519 | 18 | 20 => ErrorClass::Busy,
900..=999 => ErrorClass::SyntaxError,
_ => ErrorClass::Internal,
}
}
#[must_use]
pub fn envelope_from_oracle_message(message: &str) -> ErrorEnvelope {
match parse_ora_code(message) {
Some(code) => {
let class = classify_ora_code(code);
ErrorEnvelope::new(class, message.to_owned()).with_ora_code(code)
}
None => ErrorEnvelope::new(ErrorClass::Internal, message.to_owned()),
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum OracleMcpError {
#[error("oracle error: {0}")]
Oracle(String),
#[error("object not found: {name}")]
ObjectNotFound {
name: String,
fuzzy_matches: Vec<String>,
},
#[error("insufficient privilege: {0}")]
InsufficientPrivilege(String),
#[error("statement refused by guard: {0}")]
ForbiddenStatement(String),
#[error("session lease required: {0}")]
LeaseRequired(String),
#[error("operating level too low: {0}")]
OperatingLevelTooLow(String),
#[error("challenge required: {0}")]
ChallengeRequired(String),
#[error("runtime state required: {0}")]
RuntimeStateRequired(String),
#[error("server busy")]
Busy {
retry_after_ms: u64,
},
#[error("invalid arguments: {0}")]
InvalidArguments(String),
#[error("policy denied: {0}")]
PolicyDenied(String),
#[error("internal error: {0}")]
Internal(String),
}
impl OracleMcpError {
#[must_use]
pub fn into_envelope(self) -> ErrorEnvelope {
match self {
OracleMcpError::Oracle(msg) => envelope_from_oracle_message(&msg),
OracleMcpError::ObjectNotFound {
name,
fuzzy_matches,
} => ErrorEnvelope::new(
ErrorClass::ObjectNotFound,
format!("object not found: {name}"),
)
.with_fuzzy_matches(fuzzy_matches),
OracleMcpError::InsufficientPrivilege(msg) => {
ErrorEnvelope::new(ErrorClass::InsufficientPrivilege, msg)
}
OracleMcpError::ForbiddenStatement(msg) => {
ErrorEnvelope::new(ErrorClass::ForbiddenStatement, msg)
}
OracleMcpError::LeaseRequired(msg) => {
ErrorEnvelope::new(ErrorClass::LeaseRequired, msg)
.with_next_step("call oracle_session(acquire_lease) and pass the lease_id")
}
OracleMcpError::OperatingLevelTooLow(msg) => {
ErrorEnvelope::new(ErrorClass::OperatingLevelTooLow, msg)
.with_next_step("call oracle_session(escalate, target=<level>)")
}
OracleMcpError::ChallengeRequired(msg) => {
ErrorEnvelope::new(ErrorClass::ChallengeRequired, msg)
}
OracleMcpError::RuntimeStateRequired(msg) => {
ErrorEnvelope::new(ErrorClass::RuntimeStateRequired, msg)
}
OracleMcpError::Busy { retry_after_ms } => {
ErrorEnvelope::new(ErrorClass::Busy, "server busy")
.with_retry_after_ms(retry_after_ms)
}
OracleMcpError::InvalidArguments(msg) => {
ErrorEnvelope::new(ErrorClass::InvalidArguments, msg)
}
OracleMcpError::PolicyDenied(msg) => ErrorEnvelope::new(ErrorClass::PolicyDenied, msg),
OracleMcpError::Internal(msg) => ErrorEnvelope::new(ErrorClass::Internal, msg),
}
}
}
pub type Result<T> = std::result::Result<T, OracleMcpError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ora_code_extracts_leading_code() {
assert_eq!(
parse_ora_code("ORA-00942: table or view does not exist"),
Some(942)
);
assert_eq!(
parse_ora_code("foo ORA-1031: insufficient privileges"),
Some(1031)
);
assert_eq!(parse_ora_code("no oracle code here"), None);
assert_eq!(parse_ora_code("ORA-: malformed"), None);
}
#[test]
fn classify_known_codes() {
assert_eq!(classify_ora_code(942), ErrorClass::ObjectNotFound);
assert_eq!(classify_ora_code(4043), ErrorClass::ObjectNotFound);
assert_eq!(classify_ora_code(1031), ErrorClass::InsufficientPrivilege);
assert_eq!(classify_ora_code(1456), ErrorClass::ForbiddenStatement);
assert_eq!(classify_ora_code(3113), ErrorClass::Transient);
assert_eq!(classify_ora_code(12519), ErrorClass::Busy);
assert_eq!(classify_ora_code(923), ErrorClass::SyntaxError);
assert_eq!(classify_ora_code(7777), ErrorClass::Internal);
}
#[test]
fn object_not_found_envelope_golden() {
let env = ErrorEnvelope::new(ErrorClass::ObjectNotFound, "object not found: EMPLOYES")
.with_ora_code(942)
.with_fuzzy_matches(vec!["EMPLOYEES".to_owned(), "EMPLOYEE".to_owned()]);
let json = serde_json::to_value(&env).expect("serialize");
assert_eq!(json["isError"], serde_json::json!(true));
assert_eq!(json["error_class"], serde_json::json!("OBJECT_NOT_FOUND"));
assert_eq!(json["ora_code"], serde_json::json!(942));
assert_eq!(
json["suggested_tool"],
serde_json::json!("oracle_schema_inspect")
);
assert_eq!(
json["fuzzy_matches"],
serde_json::json!(["EMPLOYEES", "EMPLOYEE"])
);
assert!(json.get("next_steps").is_none());
assert!(json.get("retry_after_ms").is_none());
}
#[test]
fn busy_envelope_carries_retry_after() {
let env = OracleMcpError::Busy {
retry_after_ms: 250,
}
.into_envelope();
let json = serde_json::to_value(&env).expect("serialize");
assert_eq!(json["error_class"], serde_json::json!("BUSY"));
assert_eq!(json["retry_after_ms"], serde_json::json!(250));
}
#[test]
fn oracle_message_roundtrips_through_envelope() {
let env = OracleMcpError::Oracle("ORA-00942: table or view does not exist".to_owned())
.into_envelope();
assert_eq!(env.error_class, ErrorClass::ObjectNotFound);
assert_eq!(env.ora_code, Some(942));
assert_eq!(env.suggested_tool.as_deref(), Some("oracle_schema_inspect"));
}
#[test]
fn envelope_serde_roundtrip_is_stable() {
let env = ErrorEnvelope::new(ErrorClass::LeaseRequired, "needs a lease")
.with_next_step("call oracle_session(acquire_lease)");
let json = serde_json::to_string(&env).expect("serialize");
let back: ErrorEnvelope = serde_json::from_str(&json).expect("deserialize");
assert_eq!(env, back);
}
#[test]
fn retryability_matches_class() {
assert!(ErrorClass::Busy.is_retryable());
assert!(ErrorClass::Transient.is_retryable());
assert!(!ErrorClass::ObjectNotFound.is_retryable());
assert!(!ErrorClass::ForbiddenStatement.is_retryable());
}
}