bevy_fairy 0.2.2

An agentic debugger for Bevy games: a Bevy plugin that exposes the game's state over BRP, and an MCP server that lets a coding agent read and drive it
Documentation
/*
 * bevy-fairy: the MCP server half of an agentic debugger for Bevy games
 * Copyright (C) 2025 ladvien
 *
 * Licensed under either of MIT (LICENSE-MIT) or Apache-2.0 (LICENSE-APACHE), at your option.
 *
 * Relicensed from GPL-3.0 when this crate was adopted into Ladvien/foundation_vs_slop: a GPL
 * crate in the Bevy ecosystem cannot be adopted, and being adoptable is why it is published.
 */

use std::sync::Arc;
use tokio::signal;
use tokio::sync::RwLock;
use tracing::{error, info, warn};
use is_terminal::IsTerminal;

// Modules are defined in lib.rs, no need to redeclare them here

use bevy_fairy::server::brp_client::BrpClient;
use bevy_fairy::server::config::Config;
use bevy_fairy::server::error::Result;
use bevy_fairy::server::{mcp_server, mcp_server_v2};

#[cfg(feature = "observability")]
use bevy_fairy::server::observability::ObservabilityService;

#[tokio::main]
async fn main() -> Result<()> {
    // Set up global panic handler that logs before exit
    std::panic::set_hook(Box::new(|panic_info| {
        let payload = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
            s.to_string()
        } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
            s.clone()
        } else {
            "Unknown panic payload".to_string()
        };
        
        let location = if let Some(location) = panic_info.location() {
            format!("{}:{}:{}", location.file(), location.line(), location.column())
        } else {
            "Unknown location".to_string()
        };
        
        eprintln!("FATAL PANIC in bevy-fairy: {} at {}", payload, location);
        eprintln!("This indicates a critical bug that should be reported.");
        eprintln!("Stack trace should appear above this message.");
        
        // Ensure logs are flushed
        std::io::Write::flush(&mut std::io::stderr()).unwrap_or(());
    }));
    let args: Vec<String> = std::env::args().collect();

    // Every argument this binary understands. Anything else is refused by name: it used to
    // fall through to "start the MCP server", so `--version` or a typo looked like a server
    // crash ("connection closed: initialized request") rather than a usage error.
    //
    // `stdio` and `serve` are legacy spellings of `--stdio`. Earlier installers wrote them into
    // MCP client configs (`"args": ["stdio"]`, `"args": ["serve"]`), and those configs only
    // worked because unknown words fell through to stdio mode. They are accepted explicitly so
    // a config already on someone's machine keeps working; the installers now write `--stdio`.
    const LEGACY_STDIO_ALIASES: &[&str] = &["stdio", "serve"];
    const KNOWN_ARGS: &[&str] = &["--stdio", "--tcp", "--server", "--help", "-h", "--version", "-V"];
    if let Some(unknown) = args.iter().skip(1).find(|arg| {
        !KNOWN_ARGS.contains(&arg.as_str()) && !LEGACY_STDIO_ALIASES.contains(&arg.as_str())
    }) {
        eprintln!("bevy-fairy: unknown argument `{unknown}`");
        eprintln!("valid arguments: {}", KNOWN_ARGS.join(", "));
        eprintln!("try `bevy-fairy --help`");
        std::process::exit(2);
    }

    if args.iter().any(|arg| arg == "--version" || arg == "-V") {
        println!("bevy-fairy {}", env!("CARGO_PKG_VERSION"));
        return Ok(());
    }

    // Check for help flag
    if args.iter().any(|arg| arg == "--help" || arg == "-h") {
        println!("bevy-fairy {}", env!("CARGO_PKG_VERSION"));
        println!("\nUsage: {} [OPTIONS]", args[0]);
        println!("\nOptions:");
        println!("  --stdio              Run in stdio mode (default for Claude Code)");
        println!("  --tcp, --server      Run as TCP server on port {}", Config::from_env().unwrap_or_default().mcp_port);
        println!("  --help, -h           Show this help message");
        println!("  --version, -V        Print the version and exit");
        println!("\nEnvironment variables:");
        println!("  BEVY_BRP_HOST        Bevy Remote Protocol host (default: localhost)");
        println!("  BEVY_BRP_PORT        Bevy Remote Protocol port (default: 15702)");
        println!("  MCP_PORT             MCP server port for TCP mode (default: 3001)");
        println!("  RUST_LOG             Logging level (default: info)");
        return Ok(());
    }
    
    // Determine if we're in stdio mode (for MCP protocol)
    let is_stdio_mode = args
        .iter()
        .any(|arg| arg == "--stdio" || LEGACY_STDIO_ALIASES.contains(&arg.as_str())) ||
                        (!args.iter().any(|arg| arg == "--tcp" || arg == "--server") && !std::io::stdout().is_terminal());
    
    // Initialize tracing to stderr when in stdio mode (stdout is reserved for MCP protocol)
    // This prevents log output from contaminating the JSON-RPC stream
    if is_stdio_mode {
        tracing_subscriber::fmt()
            .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
            .with_writer(std::io::stderr)
            .with_ansi(false)  // Disable ANSI color codes in stdio mode
            .init();
    } else {
        tracing_subscriber::fmt()
            .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
            .init();
    }

    let config = Config::from_env()?;

    // Check if we should run in stdio mode (for Claude Code) or TCP mode
    let use_tcp = args.iter().any(|arg| arg == "--tcp" || arg == "--server");
    let use_stdio = !use_tcp && (
        args.iter().any(|arg| arg == "--stdio")
        || !is_terminal::IsTerminal::is_terminal(&std::io::stdin())
        || std::env::var("MCP_TRANSPORT")
            .map(|t| t == "stdio")
            .unwrap_or(false)
    );

    if use_stdio {
        info!("Starting bevy-fairy in stdio mode for Claude Code");
        run_stdio_mode(config).await
    } else {
        info!(
            "Starting bevy-fairy in TCP mode on port {}",
            config.mcp_port
        );
        run_tcp_mode(config).await
    }
}

async fn run_stdio_mode(config: Config) -> Result<()> {
    let brp_client = Arc::new(RwLock::new(BrpClient::new(&config)));
    {
        let client = brp_client.read().await;
        client.init().await?;
    }
    
    // Initialize observability if enabled
    #[cfg(feature = "observability")]
    let _observability = if config.observability.metrics_enabled || config.observability.tracing_enabled {
        let obs_service = ObservabilityService::new(config.clone(), brp_client.clone()).await?;
        obs_service.start().await?;
        Some(obs_service)
    } else {
        None
    };
    
    let server = mcp_server_v2::McpServerV2::new(config, brp_client)?;
    server.run_stdio().await
}

async fn run_tcp_mode(config: Config) -> Result<()> {
    let brp_client = Arc::new(RwLock::new(BrpClient::new(&config)));
    {
        let client = brp_client.read().await;
        client.init().await?;
    }
    
    // Initialize observability if enabled
    #[cfg(feature = "observability")]
    let observability = if config.observability.metrics_enabled || config.observability.tracing_enabled {
        let obs_service = ObservabilityService::new(config.clone(), brp_client.clone()).await?;
        obs_service.start().await?;
        
        // Start health endpoints if enabled
        if config.observability.health_check_enabled {
            let health_service = obs_service.health();
            let health_router = health_service.create_router();
            
            tokio::spawn(async move {
                let app = health_router.with_state(health_service);
                let health_listener = tokio::net::TcpListener::bind(
                    format!("127.0.0.1:{}", config.observability.health_check_port)
                ).await.expect("Failed to bind health check port");
                
                info!("Health endpoints available at http://127.0.0.1:{}", config.observability.health_check_port);
                axum::serve(health_listener, app).await.expect("Health server failed");
            });
        }
        
        Some(obs_service)
    } else {
        None
    };
    
    let mcp_server = mcp_server::McpServer::new(config.clone(), brp_client);
    
    // Start TCP server
    let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", config.mcp_port))
        .await
        .map_err(|e| bevy_fairy::server::error::Error::Connection(format!("Failed to bind TCP: {}", e)))?;
    
    info!("MCP server listening on 127.0.0.1:{}", config.mcp_port);
    
    let server_handle = tokio::spawn(async move {
        if let Err(e) = mcp_server.run(listener).await {
            error!("MCP Server error: {}", e);
        }
    });

    tokio::select! {
        _ = server_handle => {
            warn!("MCP Server task completed");
        }
        _ = signal::ctrl_c() => {
            info!("Received SIGINT, shutting down gracefully");
            
            #[cfg(feature = "observability")]
            if let Some(obs) = observability {
                if let Err(e) = obs.shutdown().await {
                    warn!("Error shutting down observability: {}", e);
                }
            }
        }
    }

    Ok(())
}