dynoxide-rs 3.0.0

An embeddable DynamoDB emulator backed by SQLite. Starts in under a millisecond, and is verified against real AWS by a public conformance suite.
Documentation
//! DynoxideError → MCP tool error mapping.
//!
//! Domain errors are returned as tool results with `isError: true` so the
//! agent conversation continues flowing. Protocol-level errors (McpError)
//! are reserved for infrastructure failures.

use crate::errors::DynoxideError;
use rmcp::model::{CallToolResult, ContentBlock};

/// Short error type name without the DynamoDB namespace prefix.
fn short_error_type(err: &DynoxideError) -> &'static str {
    match err {
        DynoxideError::ResourceNotFoundException(_) => "ResourceNotFoundException",
        DynoxideError::ResourceInUseException(_) => "ResourceInUseException",
        DynoxideError::ValidationException(_)
        | DynoxideError::KeyEmptyValueValidation(_)
        | DynoxideError::EnvelopedValidation(_) => "ValidationException",
        DynoxideError::ConditionalCheckFailedException(..)
        | DynoxideError::StatementConditionalCheckFailed(..) => "ConditionalCheckFailedException",
        DynoxideError::TransactionCanceledException(..) => "TransactionCanceledException",
        DynoxideError::ItemCollectionSizeLimitExceededException(_) => {
            "ItemCollectionSizeLimitExceededException"
        }
        DynoxideError::ProvisionedThroughputExceededException(_) => {
            "ProvisionedThroughputExceededException"
        }
        DynoxideError::InternalServerError(_) | DynoxideError::SqliteError(_) => {
            "InternalServerError"
        }
        DynoxideError::ConversionError(_) => "ValidationException",
        DynoxideError::DuplicateItemException(_) => "DuplicateItemException",
        DynoxideError::AccessDeniedException(_) => "AccessDeniedException",
        DynoxideError::SerializationException(_) => "SerializationException",
        DynoxideError::LimitExceededException(_) => "LimitExceededException",
        DynoxideError::IdempotentParameterMismatchException(_) => {
            "IdempotentParameterMismatchException"
        }
        DynoxideError::UnsupportedCapability(_) => "UnsupportedOperation",
    }
}

/// Whether this error type would be retryable against real DynamoDB.
///
/// This is informational: for a local emulator, the same request will produce
/// the same error. But agents building muscle memory for production should learn
/// which errors are transient vs permanent.
fn is_retryable(err: &DynoxideError) -> bool {
    matches!(
        err,
        DynoxideError::ProvisionedThroughputExceededException(_)
            | DynoxideError::InternalServerError(_)
            | DynoxideError::SqliteError(_)
    )
}

/// Convert a DynoxideError into an MCP tool result with `isError: true`.
pub fn to_tool_error(err: DynoxideError) -> CallToolResult {
    let mut error_json = serde_json::json!({
        "error_type": short_error_type(&err),
        "message": err.to_string(),
        "retryable": is_retryable(&err),
    });
    // A failed condition asked for ALL_OLD carries the item it was evaluated
    // against, as DynamoDB's ConditionalCheckFailedException does.
    let item = match &err {
        DynoxideError::ConditionalCheckFailedException(_, item) => item.as_ref(),
        DynoxideError::StatementConditionalCheckFailed(_, item) => Some(item),
        _ => None,
    };
    if let Some(item) = item
        && let Ok(item) = serde_json::to_value(item)
    {
        error_json["item"] = item;
    }

    CallToolResult::error(vec![ContentBlock::text(error_json.to_string())])
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_value_key_validation_maps_to_validation_exception() {
        // The MCP surface must treat KeyEmptyValueValidation exactly like ValidationException.
        assert_eq!(
            short_error_type(&DynoxideError::KeyEmptyValueValidation(String::new())),
            short_error_type(&DynoxideError::ValidationException(String::new())),
        );
        assert_eq!(
            short_error_type(&DynoxideError::KeyEmptyValueValidation(String::new())),
            "ValidationException",
        );
    }

    #[test]
    fn enveloped_validation_maps_to_validation_exception() {
        // The MCP surface must treat EnvelopedValidation exactly like ValidationException.
        assert_eq!(
            short_error_type(&DynoxideError::EnvelopedValidation(String::new())),
            short_error_type(&DynoxideError::ValidationException(String::new())),
        );
        assert_eq!(
            short_error_type(&DynoxideError::EnvelopedValidation(String::new())),
            "ValidationException",
        );
    }
}