slave 0.7.0

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles and Turso migrations, add server-rendered web apps, and route them by domain or path
//! A small client for the project's MCP backend (Streamable HTTP).
//!
//! The backend is stateless, so each tool call is one JSON-RPC `tools/call`
//! POST. The signed-in user's bearer token is passed per call.

use std::fmt;

use serde::de::DeserializeOwned;
use serde_json::{Value, json};

#[derive(Debug, Clone)]
pub struct McpClient {
    http: reqwest::Client,
    url: String,
}

/// A failed tool call.
#[derive(Debug, Clone)]
pub struct ToolError {
    /// The backend's error code (`unauthenticated`, `forbidden`, `not_found`,
    /// ...), or `unavailable` when the backend could not be reached.
    pub code: String,
    pub message: String,
}

impl fmt::Display for ToolError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.message)
    }
}

impl ToolError {
    fn new(code: &str, message: impl Into<String>) -> Self {
        Self {
            code: code.to_owned(),
            message: message.into(),
        }
    }
}

impl McpClient {
    pub fn new(url: String) -> Self {
        Self {
            http: reqwest::Client::new(),
            url,
        }
    }

    pub fn url(&self) -> &str {
        &self.url
    }

    /// Calls `tool` with `arguments`, as the user owning `token` if given, and
    /// decodes its structured result.
    pub async fn call<T: DeserializeOwned>(
        &self,
        tool: &str,
        arguments: Value,
        token: Option<&str>,
    ) -> Result<T, ToolError> {
        let message = json!({
            "jsonrpc": "2.0",
            "id": 1,
            "method": "tools/call",
            "params": {"name": tool, "arguments": arguments},
        });
        let mut request = self
            .http
            .post(&self.url)
            .header("accept", "application/json, text/event-stream")
            .json(&message);
        if let Some(token) = token {
            request = request.bearer_auth(token);
        }

        let unavailable = |err: reqwest::Error| {
            tracing::warn!("MCP backend at {}: {err}", self.url);
            ToolError::new("unavailable", "the backend is not reachable right now")
        };
        let response: Value = request
            .send()
            .await
            .and_then(reqwest::Response::error_for_status)
            .map_err(unavailable)?
            .json()
            .await
            .map_err(unavailable)?;

        if let Some(error) = response.get("error") {
            let message = error["message"].as_str().unwrap_or("request failed");
            return Err(ToolError::new("protocol_error", message));
        }
        let result = &response["result"];
        let content = result["structuredContent"].clone();
        if result["isError"] == true {
            let code = content["code"].as_str().unwrap_or("tool_error");
            let message = content["error"]
                .as_str()
                .or_else(|| result["content"][0]["text"].as_str())
                .unwrap_or("the request failed");
            return Err(ToolError::new(code, message));
        }
        serde_json::from_value(content)
            .map_err(|err| ToolError::new("protocol_error", format!("unexpected result: {err}")))
    }
}