slave 0.4.0

Laravel-inspired CLI for Rust MCP servers: scaffold apps whose business operations are MCP tools over Streamable HTTP, with built-in auth, users, roles and Turso migrations
use rmcp::handler::server::tool::IntoCallToolResult;
use rmcp::model::{CallToolResponse, CallToolResult};
use serde_json::json;
use slave_framework::turso;

/// 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("invalid email or password")]
    InvalidCredentials,

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

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

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

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

impl From<slave_framework::Error> for AppError {
    fn from(err: slave_framework::Error) -> Self {
        Self::Internal(err.to_string())
    }
}

impl AppError {
    /// 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::InvalidCredentials => "invalid_credentials",
            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("no such table") {
                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())
    }
}