slave 0.13.1

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles, teams and SurrealDB migrations, add server-rendered web apps, and route them by domain or path
use rmcp::handler::server::tool::IntoCallToolResult;
use rmcp::model::{CallToolResponse, CallToolResult};
use serde_json::json;
use slave_framework::database;

/// Errors returned by MCP tools.
///
/// They become tool results with `isError: true` and structured content
/// `{"error": "<message>", "code": "<code>"}`, so clients and models can react
/// to them. Malformed arguments never reach a tool: the MCP layer rejects them
/// with a JSON-RPC `invalid params` error.
#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("{0} not found")]
    NotFound(String),

    #[error("{0}")]
    Validation(String),

    #[error("{0}")]
    Conflict(String),

    #[error("authentication required")]
    Unauthorized,

    #[error("you do not have permission to do this")]
    Forbidden,

    #[error("database error: {0}")]
    Database(#[from] surrealdb::Error),

    #[error("{0}")]
    Internal(String),
}

pub type AppResult<T> = Result<T, AppError>;

/// Lets `database::transaction` retry a transaction that lost a race with a
/// concurrent one.
impl database::MaybeConflict for AppError {
    fn is_transaction_conflict(&self) -> bool {
        matches!(self, Self::Database(err) if database::is_transaction_conflict(err))
    }
}

impl From<slave_framework::Error> for AppError {
    fn from(err: slave_framework::Error) -> Self {
        match err {
            slave_framework::Error::Auth(message) => Self::Validation(message),
            slave_framework::Error::Database(err) => Self::Database(err),
            err => Self::Internal(err.to_string()),
        }
    }
}

impl AppError {
    /// A write rejected by a `UNIQUE` index or an existing record ID.
    pub fn is_already_exists(&self) -> bool {
        matches!(self, Self::Database(err) if database::is_already_exists(err))
    }

    /// Stable, machine-readable error code.
    pub fn code(&self) -> &'static str {
        match self {
            Self::NotFound(_) => "not_found",
            Self::Validation(_) => "validation_failed",
            Self::Conflict(_) => "conflict",
            Self::Unauthorized => "unauthenticated",
            Self::Forbidden => "forbidden",
            Self::Database(_) | Self::Internal(_) => "internal_error",
        }
    }
}

impl IntoCallToolResult for AppError {
    fn into_call_tool_result(self) -> Result<CallToolResponse, rmcp::ErrorData> {
        let code = self.code();

        // Server-side failures are logged in full but never leaked to clients.
        let message = if code == "internal_error" {
            if self.to_string().contains("does not exist") || self.to_string().contains("not found")
            {
                tracing::error!("{self} (have you run `slave migrate`?)");
            } else {
                tracing::error!("{self}");
            }
            "internal server error".to_owned()
        } else {
            self.to_string()
        };

        Ok(CallToolResult::structured_error(json!({ "error": message, "code": code })).into())
    }
}