bevy_brp_mcp 0.22.2

MCP server for Bevy Remote Protocol (BRP) integration
//! HTTP client for BRP (Bevy Remote Protocol) communication
//!
//! This module provides a dedicated HTTP client for making BRP-specific HTTP requests.
//! It encapsulates all HTTP-related operations including URL building, request sending,
//! status checking, and response parsing.

use std::time::UNIX_EPOCH;

use reqwest::Response;
use serde_json::Value;
use tracing::debug;
use tracing::warn;

use super::constants::BRP_DEFAULT_HOST;
use super::constants::BRP_HTTP_PROTOCOL;
use super::constants::BRP_JSONRPC_PATH;
use super::constants::ERROR_BODY_PREVIEW_CHARS;
use super::constants::HTTP_CONTENT_TYPE_JSON;
use super::constants::HTTP_HEADER_CONTENT_TYPE;
use super::constants::HTTP_REQUEST_TIMEOUT;
use super::json_rpc_builder::BrpJsonRpcBuilder;
use crate::brp_tools::Port;
use crate::error::Error;
use crate::error::Result;
use crate::support::JsonObjectAccess;
use crate::tool::ParameterName;

/// HTTP client for BRP communication
pub(super) struct BrpHttpClient<'method> {
    brp_method: &'method str,
    port:       Port,
    params:     Option<Value>,
}

enum ReqwestErrorKind {
    Timeout,
    Connection,
    Request,
    Body,
    Decode,
    Other,
}

impl ReqwestErrorKind {
    fn classify(error: &reqwest::Error) -> Self {
        if error.is_timeout() {
            Self::Timeout
        } else if error.is_connect() {
            Self::Connection
        } else if error.is_request() {
            Self::Request
        } else if error.is_body() {
            Self::Body
        } else if error.is_decode() {
            Self::Decode
        } else {
            Self::Other
        }
    }

    const fn label(self) -> &'static str {
        match self {
            Self::Timeout => "Timeout",
            Self::Connection => "Connection failed",
            Self::Request => "Request error",
            Self::Body => "Body error",
            Self::Decode => "Decode error",
            Self::Other => "Unknown error type",
        }
    }
}

impl<'method> BrpHttpClient<'method> {
    /// Create a new BRP HTTP client
    pub(super) const fn new(brp_method: &'method str, port: Port, params: Option<Value>) -> Self {
        Self {
            brp_method,
            port,
            params,
        }
    }

    /// Build the BRP URL for this client's port
    fn build_url(&self) -> String {
        format!(
            "{BRP_HTTP_PROTOCOL}://{BRP_DEFAULT_HOST}:{}{BRP_JSONRPC_PATH}",
            self.port
        )
    }

    /// Build the JSON-RPC request body for this client
    fn build_request_body(&self) -> String {
        let mut brp_json_rpc_builder = BrpJsonRpcBuilder::new(self.brp_method);
        if let Some(ref params) = self.params {
            debug!(
                "BRP execute_brp_method: Added params - {}",
                serde_json::to_string(params)
                    .unwrap_or_else(|_| "Failed to serialize params".to_string())
            );
            brp_json_rpc_builder = brp_json_rpc_builder.params(params.clone());
        }
        brp_json_rpc_builder.build().to_string()
    }

    /// Send an HTTP request with timeout
    pub(super) async fn send_request(&self) -> Result<Response> {
        let url = self.build_url();
        let body = self.build_request_body();
        let client = reqwest::Client::new();

        let response = client
            .post(&url)
            .header(HTTP_HEADER_CONTENT_TYPE, HTTP_CONTENT_TYPE_JSON)
            .body(body.clone())
            .timeout(HTTP_REQUEST_TIMEOUT)
            .send()
            .await;

        let response = match response {
            Ok(resp) => resp,
            Err(e) => self.handle_error(e, &url, &body)?,
        };

        // Check HTTP status before returning
        self.check_status(&response)?;
        Ok(response)
    }

    /// Send an HTTP request for streaming (no timeout)
    pub(super) async fn send_streaming_request(&self) -> Result<Response> {
        let url = self.build_url();
        let body = self.build_request_body();
        // Create client with no timeout for streaming
        let client = reqwest::Client::new();

        let response = client
            .post(&url)
            .header(HTTP_HEADER_CONTENT_TYPE, HTTP_CONTENT_TYPE_JSON)
            .body(body.clone())
            .send()
            .await;

        let response = match response {
            Ok(resp) => resp,
            Err(e) => self.handle_error(e, &url, &body)?,
        };

        // Check HTTP status before returning
        self.check_status(&response)?;
        Ok(response)
    }

    /// Check if the HTTP response status is successful
    fn check_status(&self, response: &Response) -> Result<()> {
        if !response.status().is_success() {
            warn!(
                "BRP execute_brp_method: HTTP status error - status={}",
                response.status()
            );
            return Err(
                error_stack::Report::new(Error::JsonRpc("HTTP error".to_string()))
                    .attach(format!(
                        "BRP server returned HTTP error {}: {}",
                        response.status(),
                        response
                            .status()
                            .canonical_reason()
                            .unwrap_or("Unknown error")
                    ))
                    .attach(format!("Method: {}, Port: {}", self.brp_method, self.port)),
            );
        }

        Ok(())
    }

    /// Handle HTTP errors with detailed context
    fn handle_error(&self, e: reqwest::Error, url: &str, request_body: &str) -> Result<Response> {
        // Always log HTTP errors to help debug intermittent failures
        warn!("BRP execute_brp_method: HTTP request failed - error={e}");

        let error_details = format!(
            "HTTP Error at {}\nMethod: {}\nPort: {}\nURL: {}\nError: {:?}\n",
            std::time::SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .map_or(0, |d| d.as_secs()),
            self.brp_method,
            self.port,
            url,
            e
        );
        if let Some(temp_dir) = std::env::temp_dir().to_str() {
            let error_file = format!(
                "{}/bevy_brp_http_error_{}.log",
                temp_dir,
                std::process::id()
            );
            let _ = std::fs::write(&error_file, &error_details);
            debug!("HTTP error details written to: {error_file}");
        }

        // Extract additional context from the request body for better error reporting
        let mut context_info = vec![
            format!("Method: {}", self.brp_method),
            format!("Port: {}", self.port),
            format!("URL: {url}"),
        ];

        // Try to parse request body to extract component/entity info for mutations
        if let Ok(body_json) = serde_json::from_str::<Value>(request_body)
            && let Some(params) = body_json.get_field(ParameterName::Params)
        {
            if let Some(entity) = params.get_field(ParameterName::Entity) {
                context_info.push(format!("Entity: {entity}"));
            }
            if let Some(component) = params.get_field_str(ParameterName::Component) {
                context_info.push(format!("Component: {component}"));
            }
            if let Some(path) = params.get_field_str(ParameterName::Path) {
                context_info.push(format!("Path: {path}"));
            }
        }

        // Determine error type and details
        let error_type = ReqwestErrorKind::classify(&e).label();

        context_info.push(format!("Error type: {error_type}"));

        // Add port info
        context_info.push(format!("Port: ({})", self.port));

        let error_message = format!(
            "HTTP request failed for {} operation - {error_type}: {e}",
            self.brp_method
        );

        Err(error_stack::Report::new(Error::JsonRpc(error_message))
            .attach(context_info.join(", "))
            .attach(format!("Full error: {e:?}"))
            .attach(format!(
                "Request body (first {ERROR_BODY_PREVIEW_CHARS} chars): {}",
                request_body
                    .chars()
                    .take(ERROR_BODY_PREVIEW_CHARS)
                    .collect::<String>()
            )))
    }
}