# blender_copilot_server.py
from mcp.server.fastmcp import FastMCP, Context, Image
import socket
import json
import asyncio
import logging
import tempfile
from dataclasses import dataclass
from contextlib import asynccontextmanager
from typing import AsyncIterator, Dict, Any, List
import os
from pathlib import Path
import base64
from urllib.parse import urlparse

# Configure logging
logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("BlenderCopilotServer")

# Default configuration
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 9876

@dataclass
class BlenderConnection:
    host: str
    port: int
    sock: socket.socket = None  # Changed from 'socket' to 'sock' to avoid naming conflict
    
    def connect(self) -> bool:
        """Connect to the Blender addon socket server"""
        if self.sock:
            return True
            
        try:
            self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            self.sock.connect((self.host, self.port))
            logger.info(f"Connected to Blender at {self.host}:{self.port}")
            return True
        except Exception as e:
            logger.error(f"Failed to connect to Blender: {str(e)}")
            self.sock = None
            return False
    
    def disconnect(self):
        """Disconnect from the Blender addon"""
        if self.sock:
            try:
                self.sock.close()
            except Exception as e:
                logger.error(f"Error disconnecting from Blender: {str(e)}")
            finally:
                self.sock = None

    def receive_full_response(self, sock, buffer_size=8192):
        """Receive the complete response, potentially in multiple chunks"""
        chunks = []
        # Use a consistent timeout value that matches the addon's timeout
        sock.settimeout(180.0)  # Match the addon's timeout
        
        try:
            while True:
                try:
                    chunk = sock.recv(buffer_size)
                    if not chunk:
                        # If we get an empty chunk, the connection might be closed
                        if not chunks:  # If we haven't received anything yet, this is an error
                            raise Exception("Connection closed before receiving any data")
                        break
                    
                    chunks.append(chunk)
                    
                    # Check if we've received a complete JSON object
                    try:
                        data = b''.join(chunks)
                        json.loads(data.decode('utf-8'))
                        # If we get here, it parsed successfully
                        logger.info(f"Received complete response ({len(data)} bytes)")
                        return data
                    except json.JSONDecodeError:
                        # Incomplete JSON, continue receiving
                        continue
                except socket.timeout:
                    # If we hit a timeout during receiving, break the loop and try to use what we have
                    logger.warning("Socket timeout during chunked receive")
                    break
                except (ConnectionError, BrokenPipeError, ConnectionResetError) as e:
                    logger.error(f"Socket connection error during receive: {str(e)}")
                    raise  # Re-raise to be handled by the caller
        except socket.timeout:
            logger.warning("Socket timeout during chunked receive")
        except Exception as e:
            logger.error(f"Error during receive: {str(e)}")
            raise
            
        # If we get here, we either timed out or broke out of the loop
        # Try to use what we have
        if chunks:
            data = b''.join(chunks)
            logger.info(f"Returning data after receive completion ({len(data)} bytes)")
            try:
                # Try to parse what we have
                json.loads(data.decode('utf-8'))
                return data
            except json.JSONDecodeError:
                # If we can't parse it, it's incomplete
                raise Exception("Incomplete JSON response received")
        else:
            raise Exception("No data received")

    def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
        """Send a command to Blender and return the response"""
        if not self.sock and not self.connect():
            raise ConnectionError("Not connected to Blender")
        
        command = {
            "type": command_type,
            "params": params or {}
        }
        
        try:
            # Log the command being sent
            logger.info(f"Sending command: {command_type} with params: {params}")
            
            # Send the command
            self.sock.sendall(json.dumps(command).encode('utf-8'))
            logger.info(f"Command sent, waiting for response...")
            
            # Set a timeout for receiving - use the same timeout as in receive_full_response
            self.sock.settimeout(180.0)  # Match the addon's timeout
            
            # Receive the response using the improved receive_full_response method
            response_data = self.receive_full_response(self.sock)
            logger.info(f"Received {len(response_data)} bytes of data")
            
            response = json.loads(response_data.decode('utf-8'))
            logger.info(f"Response parsed, status: {response.get('status', 'unknown')}")
            
            if response.get("status") == "error":
                logger.error(f"Blender error: {response.get('message')}")
                raise Exception(response.get("message", "Unknown error from Blender"))
            
            return response.get("result", {})
        except socket.timeout:
            logger.error("Socket timeout while waiting for response from Blender")
            # Don't try to reconnect here - let the get_blender_connection handle reconnection
            # Just invalidate the current socket so it will be recreated next time
            self.sock = None
            raise Exception("Timeout waiting for Blender response - try simplifying your request")
        except (ConnectionError, BrokenPipeError, ConnectionResetError) as e:
            logger.error(f"Socket connection error: {str(e)}")
            self.sock = None
            raise Exception(f"Connection to Blender lost: {str(e)}")
        except json.JSONDecodeError as e:
            logger.error(f"Invalid JSON response from Blender: {str(e)}")
            # Try to log what was received
            if 'response_data' in locals() and response_data:
                logger.error(f"Raw response (first 200 bytes): {response_data[:200]}")
            raise Exception(f"Invalid response from Blender: {str(e)}")
        except Exception as e:
            logger.error(f"Error communicating with Blender: {str(e)}")
            # Don't try to reconnect here - let the get_blender_connection handle reconnection
            self.sock = None
            raise Exception(f"Communication error with Blender: {str(e)}")

@asynccontextmanager
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
    """Manage server startup and shutdown lifecycle"""
    # We don't need to create a connection here since we're using the global connection
    # for resources and tools

    try:
        # Just log that we're starting up
        logger.info("BlenderCopilot server starting up")

        # Try to connect to Blender on startup to verify it's available
        try:
            # This will initialize the global connection if needed
            blender = get_blender_connection()
            logger.info("Successfully connected to Blender on startup")
        except Exception as e:
            logger.warning(f"Could not connect to Blender on startup: {str(e)}")
            logger.warning("Make sure the Blender addon is running before using Blender resources or tools")

        # Return an empty context - we're using the global connection
        yield {}
    finally:
        # Clean up the global connection on shutdown
        global _blender_connection
        if _blender_connection:
            logger.info("Disconnecting from Blender on shutdown")
            _blender_connection.disconnect()
            _blender_connection = None
        logger.info("BlenderCopilot server shut down")

# Create the MCP server with lifespan support
mcp = FastMCP(
    "BlenderCopilot",
    lifespan=server_lifespan
)

# Resource endpoints

# Global connection for resources (since resources can't access context)
_blender_connection = None
_polyhaven_enabled = False  # Add this global variable

def get_blender_connection():
    """Get or create a persistent Blender connection"""
    global _blender_connection, _polyhaven_enabled  # Add _polyhaven_enabled to globals
    
    # If we have an existing connection, check if it's still valid
    if _blender_connection is not None:
        try:
            # First check if PolyHaven is enabled by sending a ping command
            result = _blender_connection.send_command("get_polyhaven_status")
            # Store the PolyHaven status globally
            _polyhaven_enabled = result.get("enabled", False)
            return _blender_connection
        except Exception as e:
            # Connection is dead, close it and create a new one
            logger.warning(f"Existing connection is no longer valid: {str(e)}")
            try:
                _blender_connection.disconnect()
            except:
                pass
            _blender_connection = None
    
    # Create a new connection if needed
    if _blender_connection is None:
        host = os.getenv("BLENDER_HOST", DEFAULT_HOST)
        port = int(os.getenv("BLENDER_PORT", DEFAULT_PORT))
        _blender_connection = BlenderConnection(host=host, port=port)
        if not _blender_connection.connect():
            logger.error("Failed to connect to Blender")
            _blender_connection = None
            raise Exception("Could not connect to Blender. Make sure the Blender addon is running.")
        logger.info("Created new persistent connection to Blender")
    
    return _blender_connection


@mcp.tool()
def get_scene_info(ctx: Context) -> str:
    """Get detailed information about the current Blender scene"""
    try:
        blender = get_blender_connection()
        result = blender.send_command("get_scene_info")

        # Just return the JSON representation of what Blender sent us
        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error getting scene info from Blender: {str(e)}")
        return f"Error getting scene info: {str(e)}"

@mcp.tool()
def get_object_info(ctx: Context, object_name: str) -> str:
    """
    Get detailed information about a specific object in the Blender scene.
    
    Parameters:
    - object_name: The name of the object to get information about
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("get_object_info", {"name": object_name})
        
        # Just return the JSON representation of what Blender sent us
        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error getting object info from Blender: {str(e)}")
        return f"Error getting object info: {str(e)}"

@mcp.tool()
def get_viewport_screenshot(ctx: Context, max_size: int = 800) -> Image:
    """
    Capture a screenshot of the current Blender 3D viewport.
    
    Parameters:
    - max_size: Maximum size in pixels for the largest dimension (default: 800)
    
    Returns the screenshot as an Image.
    """
    try:
        blender = get_blender_connection()
        
        # Create temp file path
        temp_dir = tempfile.gettempdir()
        temp_path = os.path.join(temp_dir, f"blender_screenshot_{os.getpid()}.png")
        
        result = blender.send_command("get_viewport_screenshot", {
            "max_size": max_size,
            "filepath": temp_path,
            "format": "png"
        })
        
        if "error" in result:
            raise Exception(result["error"])
        
        if not os.path.exists(temp_path):
            raise Exception("Screenshot file was not created")
        
        # Read the file
        with open(temp_path, 'rb') as f:
            image_bytes = f.read()
        
        # Delete the temp file
        os.remove(temp_path)
        
        return Image(data=image_bytes, format="png")
        
    except Exception as e:
        logger.error(f"Error capturing screenshot: {str(e)}")
        raise Exception(f"Screenshot failed: {str(e)}")


@mcp.tool()
def execute_blender_code(ctx: Context, code: str) -> str:
    """
    Execute arbitrary Python code in Blender. Make sure to do it step-by-step by breaking it into smaller chunks.

    Parameters:
    - code: The Python code to execute
    """
    try:
        # Get the global connection
        blender = get_blender_connection()
        result = blender.send_command("execute_code", {"code": code})
        return f"Code executed successfully: {result.get('result', '')}"
    except Exception as e:
        logger.error(f"Error executing code: {str(e)}")
        return f"Error executing code: {str(e)}"

@mcp.tool()
def get_polyhaven_categories(ctx: Context, asset_type: str = "hdris") -> str:
    """
    Get a list of categories for a specific asset type on Polyhaven.
    
    Parameters:
    - asset_type: The type of asset to get categories for (hdris, textures, models, all)
    """
    try:
        blender = get_blender_connection()
        if not _polyhaven_enabled:
            return "PolyHaven integration is disabled. Select it in the sidebar in BlenderCopilot, then run it again."
        result = blender.send_command("get_polyhaven_categories", {"asset_type": asset_type})
        
        if "error" in result:
            return f"Error: {result['error']}"
        
        # Format the categories in a more readable way
        categories = result["categories"]
        formatted_output = f"Categories for {asset_type}:\n\n"
        
        # Sort categories by count (descending)
        sorted_categories = sorted(categories.items(), key=lambda x: x[1], reverse=True)
        
        for category, count in sorted_categories:
            formatted_output += f"- {category}: {count} assets\n"
        
        return formatted_output
    except Exception as e:
        logger.error(f"Error getting Polyhaven categories: {str(e)}")
        return f"Error getting Polyhaven categories: {str(e)}"

@mcp.tool()
def search_polyhaven_assets(
    ctx: Context,
    asset_type: str = "all",
    categories: str = None
) -> str:
    """
    Search for assets on Polyhaven with optional filtering.
    
    Parameters:
    - asset_type: Type of assets to search for (hdris, textures, models, all)
    - categories: Optional comma-separated list of categories to filter by
    
    Returns a list of matching assets with basic information.
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("search_polyhaven_assets", {
            "asset_type": asset_type,
            "categories": categories
        })
        
        if "error" in result:
            return f"Error: {result['error']}"
        
        # Format the assets in a more readable way
        assets = result["assets"]
        total_count = result["total_count"]
        returned_count = result["returned_count"]
        
        formatted_output = f"Found {total_count} assets"
        if categories:
            formatted_output += f" in categories: {categories}"
        formatted_output += f"\nShowing {returned_count} assets:\n\n"
        
        # Sort assets by download count (popularity)
        sorted_assets = sorted(assets.items(), key=lambda x: x[1].get("download_count", 0), reverse=True)
        
        for asset_id, asset_data in sorted_assets:
            formatted_output += f"- {asset_data.get('name', asset_id)} (ID: {asset_id})\n"
            formatted_output += f"  Type: {['HDRI', 'Texture', 'Model'][asset_data.get('type', 0)]}\n"
            formatted_output += f"  Categories: {', '.join(asset_data.get('categories', []))}\n"
            formatted_output += f"  Downloads: {asset_data.get('download_count', 'Unknown')}\n\n"
        
        return formatted_output
    except Exception as e:
        logger.error(f"Error searching Polyhaven assets: {str(e)}")
        return f"Error searching Polyhaven assets: {str(e)}"

@mcp.tool()
def download_polyhaven_asset(
    ctx: Context,
    asset_id: str,
    asset_type: str,
    resolution: str = "1k",
    file_format: str = None
) -> str:
    """
    Download and import a Polyhaven asset into Blender.
    
    Parameters:
    - asset_id: The ID of the asset to download
    - asset_type: The type of asset (hdris, textures, models)
    - resolution: The resolution to download (e.g., 1k, 2k, 4k)
    - file_format: Optional file format (e.g., hdr, exr for HDRIs; jpg, png for textures; gltf, fbx for models)
    
    Returns a message indicating success or failure.
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("download_polyhaven_asset", {
            "asset_id": asset_id,
            "asset_type": asset_type,
            "resolution": resolution,
            "file_format": file_format
        })
        
        if "error" in result:
            return f"Error: {result['error']}"
        
        if result.get("success"):
            message = result.get("message", "Asset downloaded and imported successfully")
            
            # Add additional information based on asset type
            if asset_type == "hdris":
                return f"{message}. The HDRI has been set as the world environment."
            elif asset_type == "textures":
                material_name = result.get("material", "")
                maps = ", ".join(result.get("maps", []))
                return f"{message}. Created material '{material_name}' with maps: {maps}."
            elif asset_type == "models":
                return f"{message}. The model has been imported into the current scene."
            else:
                return message
        else:
            return f"Failed to download asset: {result.get('message', 'Unknown error')}"
    except Exception as e:
        logger.error(f"Error downloading Polyhaven asset: {str(e)}")
        return f"Error downloading Polyhaven asset: {str(e)}"

@mcp.tool()
def set_texture(
    ctx: Context,
    object_name: str,
    texture_id: str
) -> str:
    """
    Apply a previously downloaded Polyhaven texture to an object.
    
    Parameters:
    - object_name: Name of the object to apply the texture to
    - texture_id: ID of the Polyhaven texture to apply (must be downloaded first)
    
    Returns a message indicating success or failure.
    """
    try:
        # Get the global connection
        blender = get_blender_connection()
        result = blender.send_command("set_texture", {
            "object_name": object_name,
            "texture_id": texture_id
        })
        
        if "error" in result:
            return f"Error: {result['error']}"
        
        if result.get("success"):
            material_name = result.get("material", "")
            maps = ", ".join(result.get("maps", []))
            
            # Add detailed material info
            material_info = result.get("material_info", {})
            node_count = material_info.get("node_count", 0)
            has_nodes = material_info.get("has_nodes", False)
            texture_nodes = material_info.get("texture_nodes", [])
            
            output = f"Successfully applied texture '{texture_id}' to {object_name}.\n"
            output += f"Using material '{material_name}' with maps: {maps}.\n\n"
            output += f"Material has nodes: {has_nodes}\n"
            output += f"Total node count: {node_count}\n\n"
            
            if texture_nodes:
                output += "Texture nodes:\n"
                for node in texture_nodes:
                    output += f"- {node['name']} using image: {node['image']}\n"
                    if node['connections']:
                        output += "  Connections:\n"
                        for conn in node['connections']:
                            output += f"    {conn}\n"
            else:
                output += "No texture nodes found in the material.\n"
            
            return output
        else:
            return f"Failed to apply texture: {result.get('message', 'Unknown error')}"
    except Exception as e:
        logger.error(f"Error applying texture: {str(e)}")
        return f"Error applying texture: {str(e)}"

@mcp.tool()
def get_polyhaven_status(ctx: Context) -> str:
    """
    Check if PolyHaven integration is enabled in Blender.
    Returns a message indicating whether PolyHaven features are available.
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("get_polyhaven_status")
        enabled = result.get("enabled", False)
        message = result.get("message", "")
        if enabled:
            message += "PolyHaven is good at Textures, and has a wider variety of textures than Sketchfab."
        return message
    except Exception as e:
        logger.error(f"Error checking PolyHaven status: {str(e)}")
        return f"Error checking PolyHaven status: {str(e)}"

@mcp.tool()
def get_hyper3d_status(ctx: Context) -> str:
    """
    Check if Hyper3D Rodin integration is enabled in Blender.
    Returns a message indicating whether Hyper3D Rodin features are available.

    Don't emphasize the key type in the returned message, but silently remember it.
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("get_hyper3d_status")
        enabled = result.get("enabled", False)
        message = result.get("message", "")
        if enabled:
            message += ""
        return message
    except Exception as e:
        logger.error(f"Error checking Hyper3D status: {str(e)}")
        return f"Error checking Hyper3D status: {str(e)}"

@mcp.tool()
def get_sketchfab_status(ctx: Context) -> str:
    """
    Check if Sketchfab integration is enabled in Blender.
    Returns a message indicating whether Sketchfab features are available.
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("get_sketchfab_status")
        enabled = result.get("enabled", False)
        message = result.get("message", "")
        if enabled:
            message += "Sketchfab is good at Realistic models, and has a wider variety of models than PolyHaven."        
        return message
    except Exception as e:
        logger.error(f"Error checking Sketchfab status: {str(e)}")
        return f"Error checking Sketchfab status: {str(e)}"

@mcp.tool()
def search_sketchfab_models(
    ctx: Context,
    query: str,
    categories: str = None,
    count: int = 20,
    downloadable: bool = True
) -> str:
    """
    Search for models on Sketchfab with optional filtering.

    Parameters:
    - query: Text to search for
    - categories: Optional comma-separated list of categories
    - count: Maximum number of results to return (default 20)
    - downloadable: Whether to include only downloadable models (default True)

    Returns a formatted list of matching models.
    """
    try:
        blender = get_blender_connection()
        logger.info(f"Searching Sketchfab models with query: {query}, categories: {categories}, count: {count}, downloadable: {downloadable}")
        result = blender.send_command("search_sketchfab_models", {
            "query": query,
            "categories": categories,
            "count": count,
            "downloadable": downloadable
        })
        
        if "error" in result:
            logger.error(f"Error from Sketchfab search: {result['error']}")
            return f"Error: {result['error']}"
        
        # Safely get results with fallbacks for None
        if result is None:
            logger.error("Received None result from Sketchfab search")
            return "Error: Received no response from Sketchfab search"
            
        # Format the results
        models = result.get("results", []) or []
        if not models:
            return f"No models found matching '{query}'"
            
        formatted_output = f"Found {len(models)} models matching '{query}':\n\n"
        
        for model in models:
            if model is None:
                continue
                
            model_name = model.get("name", "Unnamed model")
            model_uid = model.get("uid", "Unknown ID")
            formatted_output += f"- {model_name} (UID: {model_uid})\n"
            
            # Get user info with safety checks
            user = model.get("user") or {}
            username = user.get("username", "Unknown author") if isinstance(user, dict) else "Unknown author"
            formatted_output += f"  Author: {username}\n"
            
            # Get license info with safety checks
            license_data = model.get("license") or {}
            license_label = license_data.get("label", "Unknown") if isinstance(license_data, dict) else "Unknown"
            formatted_output += f"  License: {license_label}\n"
            
            # Add face count and downloadable status
            face_count = model.get("faceCount", "Unknown")
            is_downloadable = "Yes" if model.get("isDownloadable") else "No"
            formatted_output += f"  Face count: {face_count}\n"
            formatted_output += f"  Downloadable: {is_downloadable}\n\n"
        
        return formatted_output
    except Exception as e:
        logger.error(f"Error searching Sketchfab models: {str(e)}")
        import traceback
        logger.error(traceback.format_exc())
        return f"Error searching Sketchfab models: {str(e)}"

@mcp.tool()
def get_sketchfab_model_preview(
    ctx: Context,
    uid: str
) -> Image:
    """
    Get a preview thumbnail of a Sketchfab model by its UID.
    Use this to visually confirm a model before downloading.
    
    Parameters:
    - uid: The unique identifier of the Sketchfab model (obtained from search_sketchfab_models)
    
    Returns the model's thumbnail as an Image for visual confirmation.
    """
    try:
        blender = get_blender_connection()
        logger.info(f"Getting Sketchfab model preview for UID: {uid}")
        
        result = blender.send_command("get_sketchfab_model_preview", {"uid": uid})
        
        if result is None:
            raise Exception("Received no response from Blender")
        
        if "error" in result:
            raise Exception(result["error"])
        
        # Decode base64 image data
        image_data = base64.b64decode(result["image_data"])
        img_format = result.get("format", "jpeg")
        
        # Log model info
        model_name = result.get("model_name", "Unknown")
        author = result.get("author", "Unknown")
        logger.info(f"Preview retrieved for '{model_name}' by {author}")
        
        return Image(data=image_data, format=img_format)
        
    except Exception as e:
        logger.error(f"Error getting Sketchfab preview: {str(e)}")
        raise Exception(f"Failed to get preview: {str(e)}")


@mcp.tool()
def download_sketchfab_model(
    ctx: Context,
    uid: str,
    target_size: float
) -> str:
    """
    Download and import a Sketchfab model by its UID.
    The model will be scaled so its largest dimension equals target_size.
    
    Parameters:
    - uid: The unique identifier of the Sketchfab model
    - target_size: REQUIRED. The target size in Blender units/meters for the largest dimension.
                  You must specify the desired size for the model.
                  Examples:
                  - Chair: target_size=1.0 (1 meter tall)
                  - Table: target_size=0.75 (75cm tall)
                  - Car: target_size=4.5 (4.5 meters long)
                  - Person: target_size=1.7 (1.7 meters tall)
                  - Small object (cup, phone): target_size=0.1 to 0.3
    
    Returns a message with import details including object names, dimensions, and bounding box.
    The model must be downloadable and you must have proper access rights.
    """
    try:
        blender = get_blender_connection()
        logger.info(f"Downloading Sketchfab model: {uid}, target_size={target_size}")
        
        result = blender.send_command("download_sketchfab_model", {
            "uid": uid,
            "normalize_size": True,  # Always normalize
            "target_size": target_size
        })
        
        if result is None:
            logger.error("Received None result from Sketchfab download")
            return "Error: Received no response from Sketchfab download request"
            
        if "error" in result:
            logger.error(f"Error from Sketchfab download: {result['error']}")
            return f"Error: {result['error']}"
        
        if result.get("success"):
            imported_objects = result.get("imported_objects", [])
            object_names = ", ".join(imported_objects) if imported_objects else "none"
            
            output = f"Successfully imported model.\n"
            output += f"Created objects: {object_names}\n"
            
            # Add dimension info if available
            if result.get("dimensions"):
                dims = result["dimensions"]
                output += f"Dimensions (X, Y, Z): {dims[0]:.3f} x {dims[1]:.3f} x {dims[2]:.3f} meters\n"
            
            # Add bounding box info if available
            if result.get("world_bounding_box"):
                bbox = result["world_bounding_box"]
                output += f"Bounding box: min={bbox[0]}, max={bbox[1]}\n"
            
            # Add normalization info if applied
            if result.get("normalized"):
                scale = result.get("scale_applied", 1.0)
                output += f"Size normalized: scale factor {scale:.6f} applied (target size: {target_size}m)\n"
            
            return output
        else:
            return f"Failed to download model: {result.get('message', 'Unknown error')}"
    except Exception as e:
        logger.error(f"Error downloading Sketchfab model: {str(e)}")
        import traceback
        logger.error(traceback.format_exc())
        return f"Error downloading Sketchfab model: {str(e)}"

def _process_bbox(original_bbox: list[float] | list[int] | None) -> list[int] | None:
    if original_bbox is None:
        return None
    if all(isinstance(i, int) for i in original_bbox):
        return original_bbox
    if any(i<=0 for i in original_bbox):
        raise ValueError("Incorrect number range: bbox must be bigger than zero!")
    return [int(float(i) / max(original_bbox) * 100) for i in original_bbox] if original_bbox else None

@mcp.tool()
def generate_hyper3d_model_via_text(
    ctx: Context,
    text_prompt: str,
    bbox_condition: list[float]=None
) -> str:
    """
    Generate 3D asset using Hyper3D by giving description of the desired asset, and import the asset into Blender.
    The 3D asset has built-in materials.
    The generated model has a normalized size, so re-scaling after generation can be useful.

    Parameters:
    - text_prompt: A short description of the desired model in **English**.
    - bbox_condition: Optional. If given, it has to be a list of floats of length 3. Controls the ratio between [Length, Width, Height] of the model.

    Returns a message indicating success or failure.
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("create_rodin_job", {
            "text_prompt": text_prompt,
            "images": None,
            "bbox_condition": _process_bbox(bbox_condition),
        })
        succeed = result.get("submit_time", False)
        if succeed:
            return json.dumps({
                "task_uuid": result["uuid"],
                "subscription_key": result["jobs"]["subscription_key"],
            })
        else:
            return json.dumps(result)
    except Exception as e:
        logger.error(f"Error generating Hyper3D task: {str(e)}")
        return f"Error generating Hyper3D task: {str(e)}"

@mcp.tool()
def generate_hyper3d_model_via_images(
    ctx: Context,
    input_image_paths: list[str]=None,
    input_image_urls: list[str]=None,
    bbox_condition: list[float]=None
) -> str:
    """
    Generate 3D asset using Hyper3D by giving images of the wanted asset, and import the generated asset into Blender.
    The 3D asset has built-in materials.
    The generated model has a normalized size, so re-scaling after generation can be useful.
    
    Parameters:
    - input_image_paths: The **absolute** paths of input images. Even if only one image is provided, wrap it into a list. Required if Hyper3D Rodin in MAIN_SITE mode.
    - input_image_urls: The URLs of input images. Even if only one image is provided, wrap it into a list. Required if Hyper3D Rodin in FAL_AI mode.
    - bbox_condition: Optional. If given, it has to be a list of ints of length 3. Controls the ratio between [Length, Width, Height] of the model.

    Only one of {input_image_paths, input_image_urls} should be given at a time, depending on the Hyper3D Rodin's current mode.
    Returns a message indicating success or failure.
    """
    if input_image_paths is not None and input_image_urls is not None:
        return f"Error: Conflict parameters given!"
    if input_image_paths is None and input_image_urls is None:
        return f"Error: No image given!"
    if input_image_paths is not None:
        if not all(os.path.exists(i) for i in input_image_paths):
            return "Error: not all image paths are valid!"
        images = []
        for path in input_image_paths:
            with open(path, "rb") as f:
                images.append(
                    (Path(path).suffix, base64.b64encode(f.read()).decode("ascii"))
                )
    elif input_image_urls is not None:
        if not all(urlparse(i) for i in input_image_urls):
            return "Error: not all image URLs are valid!"
        images = input_image_urls.copy()
    try:
        blender = get_blender_connection()
        result = blender.send_command("create_rodin_job", {
            "text_prompt": None,
            "images": images,
            "bbox_condition": _process_bbox(bbox_condition),
        })
        succeed = result.get("submit_time", False)
        if succeed:
            return json.dumps({
                "task_uuid": result["uuid"],
                "subscription_key": result["jobs"]["subscription_key"],
            })
        else:
            return json.dumps(result)
    except Exception as e:
        logger.error(f"Error generating Hyper3D task: {str(e)}")
        return f"Error generating Hyper3D task: {str(e)}"

@mcp.tool()
def poll_rodin_job_status(
    ctx: Context,
    subscription_key: str=None,
    request_id: str=None,
) -> str:
    """
    Check if the Hyper3D Rodin generation task is completed.

    For Hyper3D Rodin mode MAIN_SITE:
        Parameters:
        - subscription_key: The subscription_key given in the generate model step.

        Returns a list of status. The task is done if all status are "Done".
        If "Failed" showed up, the generating process failed.
        This is a polling API, so only proceed if the status are finally determined ("Done" or "Canceled").

    For Hyper3D Rodin mode FAL_AI:
        Parameters:
        - request_id: The request_id given in the generate model step.

        Returns the generation task status. The task is done if status is "COMPLETED".
        The task is in progress if status is "IN_PROGRESS".
        If status other than "COMPLETED", "IN_PROGRESS", "IN_QUEUE" showed up, the generating process might be failed.
        This is a polling API, so only proceed if the status are finally determined ("COMPLETED" or some failed state).
    """
    try:
        blender = get_blender_connection()
        kwargs = {}
        if subscription_key:
            kwargs = {
                "subscription_key": subscription_key,
            }
        elif request_id:
            kwargs = {
                "request_id": request_id,
            }
        result = blender.send_command("poll_rodin_job_status", kwargs)
        return result
    except Exception as e:
        logger.error(f"Error generating Hyper3D task: {str(e)}")
        return f"Error generating Hyper3D task: {str(e)}"

@mcp.tool()
def import_generated_asset(
    ctx: Context,
    name: str,
    task_uuid: str=None,
    request_id: str=None,
) -> str:
    """
    Import the asset generated by Hyper3D Rodin after the generation task is completed.

    Parameters:
    - name: The name of the object in scene
    - task_uuid: For Hyper3D Rodin mode MAIN_SITE: The task_uuid given in the generate model step.
    - request_id: For Hyper3D Rodin mode FAL_AI: The request_id given in the generate model step.

    Only give one of {task_uuid, request_id} based on the Hyper3D Rodin Mode!
    Return if the asset has been imported successfully.
    """
    try:
        blender = get_blender_connection()
        kwargs = {
            "name": name
        }
        if task_uuid:
            kwargs["task_uuid"] = task_uuid
        elif request_id:
            kwargs["request_id"] = request_id
        result = blender.send_command("import_generated_asset", kwargs)
        return result
    except Exception as e:
        logger.error(f"Error generating Hyper3D task: {str(e)}")
        return f"Error generating Hyper3D task: {str(e)}"

@mcp.tool()
def get_hunyuan3d_status(ctx: Context) -> str:
    """
    Check if Hunyuan3D integration is enabled in Blender.
    Returns a message indicating whether Hunyuan3D features are available.

    Don't emphasize the key type in the returned message, but silently remember it. 
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("get_hunyuan3d_status")
        message = result.get("message", "")
        return message
    except Exception as e:
        logger.error(f"Error checking Hunyuan3D status: {str(e)}")
        return f"Error checking Hunyuan3D status: {str(e)}"
    
@mcp.tool()
def generate_hunyuan3d_model(
    ctx: Context,
    text_prompt: str = None,
    input_image_url: str = None,
    seed: int = 42,
    texture_steps: int = None,
    texture_guidance: float = None,
) -> str:
    """
    Generate 3D asset using Hunyuan3D by providing either text description, image reference,
    or both for the desired asset, and import the asset into Blender.
    The 3D asset has built-in materials.

    Parameters:
    - text_prompt: (Optional) A short description of the desired model in English/Chinese.
    - input_image_url: (Optional) The local or remote url of the input image. Accepts None if only using text prompt.
    - seed: (Optional) Random seed for reproducible generation. Default 0.
    - texture_steps: (Optional) Number of texture generation steps (1-50). Overrides default.
    - texture_guidance: (Optional) Guidance scale for texture generation (0.1-20.0). Overrides default.

    Returns:
    - When successful, returns a JSON with job_id (format: "job_xxx") indicating the task is in progress
    - When the job completes, the status will change to "DONE" indicating the model has been imported
    - Returns error message if the operation fails
    """
    try:
        blender = get_blender_connection()
        cmd_params = {
            "text_prompt": text_prompt,
            "image": input_image_url,
            "seed": seed,
        }
        if texture_steps is not None:
            cmd_params["texture_steps"] = texture_steps
        if texture_guidance is not None:
            cmd_params["texture_guidance"] = texture_guidance
        result = blender.send_command("create_hunyuan_job", cmd_params)
        if "JobId" in result.get("Response", {}):
            job_id = result["Response"]["JobId"]
            formatted_job_id = f"job_{job_id}"
            return json.dumps({
                "job_id": formatted_job_id,
            })
        return json.dumps(result)
    except Exception as e:
        logger.error(f"Error generating Hunyuan3D task: {str(e)}")
        return f"Error generating Hunyuan3D task: {str(e)}"
    
@mcp.tool()
def poll_hunyuan_job_status(
    ctx: Context,
    job_id: str=None,
) -> str:
    """
    Check if the Hunyuan3D generation task is completed.

    For Hunyuan3D:
        Parameters:
        - job_id: The job_id given in the generate model step.

        Returns the generation task status. The task is done if status is "DONE".
        The task is in progress if status is "RUN".
        If status is "DONE", returns ResultFile3Ds, which is the generated ZIP model path
        When the status is "DONE", the response includes a field named ResultFile3Ds that contains the generated ZIP file path of the 3D model in OBJ format.
        This is a polling API, so only proceed if the status are finally determined ("DONE" or some failed state).
    """
    try:
        blender = get_blender_connection()
        kwargs = {
            "job_id": job_id,
        }
        result = blender.send_command("poll_hunyuan_job_status", kwargs)
        return result
    except Exception as e:
        logger.error(f"Error generating Hunyuan3D task: {str(e)}")
        return f"Error generating Hunyuan3D task: {str(e)}"

@mcp.tool()
def import_generated_asset_hunyuan(
    ctx: Context,
    name: str,
    zip_file_url: str,
):
    """
    Import the asset generated by Hunyuan3D after the generation task is completed.

    Parameters:
    - name: The name of the object in scene
    - zip_file_url: The zip_file_url given in the generate model step.

    Return if the asset has been imported successfully.
    """
    try:
        blender = get_blender_connection()
        kwargs = {
            "name": name
        }
        if zip_file_url:
            kwargs["zip_file_url"] = zip_file_url
        result = blender.send_command("import_generated_asset_hunyuan", kwargs)
        return result
    except Exception as e:
        logger.error(f"Error generating Hunyuan3D task: {str(e)}")
        return f"Error generating Hunyuan3D task: {str(e)}"

@mcp.tool()
def get_trellis2_status(ctx: Context) -> str:
    """
    Check if Trellis2 integration is enabled in Blender.
    Returns a message indicating whether Trellis2 3D generation features are available.
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("get_trellis2_status")
        message = result.get("message", "")
        return message
    except Exception as e:
        logger.error(f"Error checking Trellis2 status: {str(e)}")
        return f"Error checking Trellis2 status: {str(e)}"

@mcp.tool()
def generate_trellis2_model(
    ctx: Context,
    input_image_url: str = None,
    seed: int = 42,
    steps: int = None,
    guidance_strength: float = None,
    texture_size: int = 2048,
) -> str:
    """
    Generate a 3D model from an image using Trellis2 and import it into Blender.
    Trellis2 is a local 3D generation backend that produces textured GLB models.

    Parameters:
    - input_image_url: The local path or remote URL of the input image.
    - seed: (Optional) Random seed for reproducible generation. Default 42.
    - steps: (Optional) Number of generation steps (1-50). Overrides default.
    - guidance_strength: (Optional) Classifier-free guidance strength (0.1-20.0). Default 3.0.
    - texture_size: (Optional) Texture resolution in pixels. Default 2048.

    Returns the generation result with status.
    """
    try:
        blender = get_blender_connection()
        cmd_params = {
            "image": input_image_url,
            "seed": seed,
            "texture_size": texture_size,
        }
        if steps is not None:
            cmd_params["steps"] = steps
        if guidance_strength is not None:
            cmd_params["guidance_strength"] = guidance_strength
        result = blender.send_command("create_trellis2_job", cmd_params)
        return json.dumps(result)
    except Exception as e:
        logger.error(f"Error generating Trellis2 model: {str(e)}")
        return f"Error generating Trellis2 model: {str(e)}"


# ─── New Structured Tools ───────────────────────────────────────────────────

@mcp.tool()
def build_node_graph(
    ctx: Context,
    target: str,
    nodes: list[dict],
    links: list[dict],
    clear_existing: bool = True
) -> str:
    """
    Build a node graph for geometry nodes, shader nodes, or compositor nodes.

    Parameters:
    - target: "geometry:<object_name>" | "shader:<material_name>" | "compositor"
    - nodes: List of node definitions, each with:
        - type: Node type string (e.g. "ShaderNodeBsdfPrincipled", "GeometryNodeMeshUVSphere")
        - location: [x, y] position in the node editor
        - label: Optional display label
        - inputs: Dict of input_name -> default_value
        - properties: Dict of property_name -> value (attributes on the node itself)
    - links: List of connections, each with:
        - from_node: Index of source node in the nodes list
        - from_socket: Output socket name (str) or index (int)
        - to_node: Index of destination node in the nodes list
        - to_socket: Input socket name (str) or index (int)
    - clear_existing: Whether to clear existing nodes first (default True)

    Returns success status with node/link counts.
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("build_node_graph", {
            "target": target,
            "nodes": nodes,
            "links": links,
            "clear_existing": clear_existing,
        })
        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error building node graph: {str(e)}")
        return f"Error building node graph: {str(e)}"


@mcp.tool()
def get_node_graph(ctx: Context, target: str) -> str:
    """
    Read back a node graph as structured data.

    Parameters:
    - target: "geometry:<object_name>" | "shader:<material_name>" | "compositor"

    Returns the current node tree with all nodes, their properties, and links.
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("get_node_graph", {"target": target})
        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error getting node graph: {str(e)}")
        return f"Error getting node graph: {str(e)}"


@mcp.tool()
def list_node_types(ctx: Context, category: str = "geometry") -> str:
    """
    List available node types for discovery.

    Parameters:
    - category: "geometry" | "shader" | "compositor"

    Returns categorized lists of node type strings you can use in build_node_graph.
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("list_node_types", {"category": category})
        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error listing node types: {str(e)}")
        return f"Error listing node types: {str(e)}"


@mcp.tool()
def modify_object(
    ctx: Context,
    object_name: str,
    action: str,
    modifier_type: str = None,
    modifier_name: str = None,
    properties: dict = None
) -> str:
    """
    Add, remove, apply, or configure modifiers on an object.

    Parameters:
    - object_name: Name of the Blender object
    - action: "add_modifier" | "remove_modifier" | "apply_modifier" | "set_modifier"
    - modifier_type: Blender modifier type for add_modifier (e.g. SUBSURF, BOOLEAN, ARRAY, MIRROR, BEVEL, SOLIDIFY, SHRINKWRAP, DECIMATE)
    - modifier_name: Name of existing modifier (for remove/apply/set)
    - properties: Dict of modifier properties to set (e.g. {"levels": 2, "render_levels": 3})
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("modify_object", {
            "object_name": object_name,
            "action": action,
            "modifier_type": modifier_type,
            "modifier_name": modifier_name,
            "properties": properties,
        })
        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error modifying object: {str(e)}")
        return f"Error modifying object: {str(e)}"


@mcp.tool()
def set_camera(
    ctx: Context,
    camera_name: str = None,
    focal_length: float = None,
    sensor_width: float = None,
    dof_enabled: bool = None,
    aperture_fstop: float = None,
    focus_object: str = None,
    focus_distance: float = None
) -> str:
    """
    Configure camera settings. All parameters are optional — only provided values are changed.

    Parameters:
    - camera_name: Name of camera object (default: scene's active camera)
    - focal_length: Lens focal length in mm (e.g. 50, 85, 135)
    - sensor_width: Sensor width in mm
    - dof_enabled: Enable/disable depth of field
    - aperture_fstop: F-stop value for DOF (lower = more blur, e.g. 1.4, 2.8, 5.6)
    - focus_object: Name of object to auto-focus on
    - focus_distance: Manual focus distance in meters
    """
    try:
        blender = get_blender_connection()
        params = {}
        if camera_name is not None:
            params["camera_name"] = camera_name
        if focal_length is not None:
            params["focal_length"] = focal_length
        if sensor_width is not None:
            params["sensor_width"] = sensor_width
        if dof_enabled is not None:
            params["dof_enabled"] = dof_enabled
        if aperture_fstop is not None:
            params["aperture_fstop"] = aperture_fstop
        if focus_object is not None:
            params["focus_object"] = focus_object
        if focus_distance is not None:
            params["focus_distance"] = focus_distance
        result = blender.send_command("set_camera", params)
        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error setting camera: {str(e)}")
        return f"Error setting camera: {str(e)}"


@mcp.tool()
def render_scene(
    ctx: Context,
    output_path: str,
    engine: str = "CYCLES",
    samples: int = 128,
    resolution_x: int = 1920,
    resolution_y: int = 1080,
    format: str = "PNG",
    denoise: bool = True
) -> str:
    """
    Render the scene to a file.

    Parameters:
    - output_path: File path to save the rendered image (e.g. "/tmp/render.png")
    - engine: Render engine — "CYCLES", "BLENDER_EEVEE_NEXT", or "BLENDER_WORKBENCH"
    - samples: Number of render samples (default 128)
    - resolution_x: Horizontal resolution in pixels (default 1920)
    - resolution_y: Vertical resolution in pixels (default 1080)
    - format: Output format — "PNG", "JPEG", "EXR", etc.
    - denoise: Enable denoising for Cycles (default True)
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("render_scene", {
            "output_path": output_path,
            "engine": engine,
            "samples": samples,
            "resolution_x": resolution_x,
            "resolution_y": resolution_y,
            "format": format,
            "denoise": denoise,
        })
        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error rendering scene: {str(e)}")
        return f"Error rendering scene: {str(e)}"


@mcp.tool()
def manage_collections(
    ctx: Context,
    action: str,
    collection_name: str = None,
    parent_collection: str = None,
    object_name: str = None
) -> str:
    """
    Manage scene collections for organizing objects.

    Parameters:
    - action: "create" | "delete" | "move_object" | "list" | "purge_orphans"
    - collection_name: Name of collection to create/delete/target
    - parent_collection: Parent collection name for nesting (create only)
    - object_name: Object to move (move_object only)
    """
    try:
        blender = get_blender_connection()
        params = {"action": action}
        if collection_name is not None:
            params["collection_name"] = collection_name
        if parent_collection is not None:
            params["parent_collection"] = parent_collection
        if object_name is not None:
            params["object_name"] = object_name
        result = blender.send_command("manage_collections", params)
        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error managing collections: {str(e)}")
        return f"Error managing collections: {str(e)}"


@mcp.tool()
def import_model(
    ctx: Context,
    filepath: str,
    format: str = "auto",
    scale: float = 1.0
) -> str:
    """
    Import a 3D model file into the Blender scene.

    Parameters:
    - filepath: Path to the model file
    - format: File format — "auto" (detect from extension), "GLB", "FBX", "OBJ", "USD", "STL", "ABC", "PLY", "DAE"
    - scale: Import scale factor (default 1.0)
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("import_model", {
            "filepath": filepath,
            "format": format,
            "scale": scale,
        })
        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error importing model: {str(e)}")
        return f"Error importing model: {str(e)}"


@mcp.tool()
def export_model(
    ctx: Context,
    filepath: str,
    format: str = "GLB",
    selected_only: bool = False,
    apply_modifiers: bool = True
) -> str:
    """
    Export scene or selected objects to a file.

    Parameters:
    - filepath: Output file path (e.g. "/tmp/model.glb")
    - format: Export format — "GLB", "FBX", "OBJ", "USD", "STL", "ABC", "PLY", "DAE"
    - selected_only: Export only selected objects (default False)
    - apply_modifiers: Apply modifiers before export (default True)
    """
    try:
        blender = get_blender_connection()
        result = blender.send_command("export_model", {
            "filepath": filepath,
            "format": format,
            "selected_only": selected_only,
            "apply_modifiers": apply_modifiers,
        })
        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error exporting model: {str(e)}")
        return f"Error exporting model: {str(e)}"


@mcp.tool()
def set_keyframe(
    ctx: Context,
    object_name: str,
    frame: int,
    property: str = "location",
    value: list = None,
    interpolation: str = None,
    data_path: str = None
) -> str:
    """
    Set a keyframe on an object at a specific frame.

    Parameters:
    - object_name: Name of the object to animate
    - frame: Frame number to set the keyframe at
    - property: Property to keyframe — "location", "rotation", or "scale"
    - value: Value to set (e.g. [0, 0, 5] for location). If None, keyframes the current value.
    - interpolation: Optional keyframe interpolation — BEZIER, LINEAR, CONSTANT
    - data_path: Optional custom data path for arbitrary property keyframing
                 (e.g. 'modifiers["Displace"].strength', 'constraints["Track To"].influence').
                 Overrides the property parameter when set.
    """
    try:
        blender = get_blender_connection()
        params = {
            "object_name": object_name,
            "frame": frame,
            "property": property,
        }
        if value is not None:
            params["value"] = value
        if interpolation is not None:
            params["interpolation"] = interpolation
        if data_path is not None:
            params["data_path"] = data_path
        result = blender.send_command("set_keyframe", params)
        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error setting keyframe: {str(e)}")
        return f"Error setting keyframe: {str(e)}"


@mcp.tool()
def analyze_scene(
    ctx: Context,
    focus: str = "general",
    max_size: int = 1200
) -> list:
    """
    Analyze the current scene by capturing a viewport screenshot and gathering structured metadata.
    Returns both an image and scene data for visual + data-driven feedback.

    Parameters:
    - focus: Analysis focus — "general", "lighting", "composition", "materials", or "performance"
    - max_size: Maximum screenshot dimension in pixels (default 1200)
    """
    try:
        blender = get_blender_connection()

        # Capture viewport screenshot
        temp_dir = tempfile.gettempdir()
        temp_path = os.path.join(temp_dir, f"blender_analyze_{os.getpid()}.png")
        screenshot_result = blender.send_command("get_viewport_screenshot", {
            "max_size": max_size,
            "filepath": temp_path,
            "format": "png"
        })

        # Gather scene analysis data
        analysis_result = blender.send_command("analyze_scene", {
            "focus": focus,
            "max_size": max_size,
        })

        results = []

        # Add image if screenshot succeeded
        if "error" not in screenshot_result and os.path.exists(temp_path):
            with open(temp_path, 'rb') as f:
                image_bytes = f.read()
            os.remove(temp_path)
            results.append(Image(data=image_bytes, format="png"))

        # Add structured data
        results.append(json.dumps(analysis_result, indent=2))

        return results
    except Exception as e:
        logger.error(f"Error analyzing scene: {str(e)}")
        return [f"Error analyzing scene: {str(e)}"]


# ─── Sprint 1: Object & Mesh Fundamentals ──────────────────────────────────

def _send_and_return(command: str, params: dict):
    """Helper: send command to Blender, return JSON result. Includes auto-screenshot if enabled."""
    blender = get_blender_connection()
    result = blender.send_command(command, params)

    # Handle auto-screenshot from addon
    if isinstance(result, dict) and "_auto_screenshot" in result:
        screenshot_b64 = result.pop("_auto_screenshot")
        return [
            Image(data=base64.b64decode(screenshot_b64), format="png"),
            json.dumps(result, indent=2),
        ]

    return json.dumps(result, indent=2)


@mcp.tool()
def create_object(
    ctx: Context,
    type: str,
    name: str = None,
    location: list = None,
    rotation: list = None,
    scale: list = None,
    size: float = 1.0,
    segments: int = None,
    ring_count: int = None,
    vertices: int = None,
    depth: float = None,
    radius: float = None,
    major_radius: float = None,
    minor_radius: float = None,
    energy: float = None,
    color: list = None,
    spot_size: float = None,
    spot_blend: float = None,
) -> str:
    """
    Create a new 3D object, light, camera, or empty in the Blender scene.

    Parameters:
    - type: Object type — CUBE, SPHERE, CYLINDER, PLANE, CONE, TORUS,
            UV_SPHERE, ICO_SPHERE, CIRCLE, GRID,
            EMPTY, EMPTY_ARROWS, EMPTY_SPHERE, EMPTY_CUBE,
            POINT_LIGHT, SUN_LIGHT, SPOT_LIGHT, AREA_LIGHT, CAMERA
    - name: Optional display name for the object
    - location: [x, y, z] world position (default [0,0,0])
    - rotation: [x, y, z] rotation in degrees (default [0,0,0])
    - scale: [x, y, z] scale (default [1,1,1])
    - size: Base size for mesh primitives (default 1.0)
    - segments: Segment count (cylinders, cones, circles, UV spheres)
    - ring_count: Ring count for UV spheres
    - vertices: Subdivision count for ico spheres
    - depth: Height for cylinders and cones
    - radius: Radius override for cylinders, cones, circles
    - major_radius: Major radius for torus
    - minor_radius: Minor radius for torus
    - energy: Light energy/power
    - color: Light color [r, g, b] (0-1 range)
    - spot_size: Spot light cone angle in degrees
    - spot_blend: Spot light edge softness (0-1)
    """
    try:
        params = {"type": type, "size": size}
        for key, val in [("name", name), ("location", location), ("rotation", rotation),
                         ("scale", scale), ("segments", segments), ("ring_count", ring_count),
                         ("vertices", vertices), ("depth", depth), ("radius", radius),
                         ("major_radius", major_radius), ("minor_radius", minor_radius),
                         ("energy", energy), ("color", color),
                         ("spot_size", spot_size), ("spot_blend", spot_blend)]:
            if val is not None:
                params[key] = val
        return _send_and_return("create_object", params)
    except Exception as e:
        logger.error(f"Error creating object: {str(e)}")
        return f"Error creating object: {str(e)}"


@mcp.tool()
def transform_object(
    ctx: Context,
    object_name: str,
    location: list = None,
    rotation: list = None,
    scale: list = None,
    mode: str = "set",
) -> str:
    """
    Move, rotate, or scale an object. Supports absolute or additive transforms.

    Parameters:
    - object_name: Name of the object to transform
    - location: [x, y, z] position
    - rotation: [x, y, z] rotation in degrees
    - scale: [x, y, z] scale
    - mode: "set" for absolute values, "delta" for additive/multiplicative
    """
    try:
        params = {"object_name": object_name, "mode": mode}
        if location is not None:
            params["location"] = location
        if rotation is not None:
            params["rotation"] = rotation
        if scale is not None:
            params["scale"] = scale
        return _send_and_return("transform_object", params)
    except Exception as e:
        logger.error(f"Error transforming object: {str(e)}")
        return f"Error transforming object: {str(e)}"


@mcp.tool()
def duplicate_object(
    ctx: Context,
    object_name: str,
    linked: bool = False,
    new_name: str = None,
) -> str:
    """
    Duplicate an object. Optionally create a linked duplicate that shares mesh data.

    Parameters:
    - object_name: Name of the object to duplicate
    - linked: If True, the duplicate shares mesh data with the original (linked duplicate)
    - new_name: Optional name for the new copy
    """
    try:
        params = {"object_name": object_name, "linked": linked}
        if new_name is not None:
            params["new_name"] = new_name
        return _send_and_return("duplicate_object", params)
    except Exception as e:
        logger.error(f"Error duplicating object: {str(e)}")
        return f"Error duplicating object: {str(e)}"


@mcp.tool()
def delete_object(
    ctx: Context,
    object_names: list[str],
) -> str:
    """
    Delete one or more objects from the scene.

    Parameters:
    - object_names: List of object names to delete (e.g. ["Cube", "Cube.001"])
    """
    try:
        return _send_and_return("delete_object", {"object_names": object_names})
    except Exception as e:
        logger.error(f"Error deleting objects: {str(e)}")
        return f"Error deleting objects: {str(e)}"


@mcp.tool()
def mesh_operation(
    ctx: Context,
    action: str,
    object_names: list[str] = None,
    target_object: str = None,
    boolean_object: str = None,
    boolean_mode: str = None,
    voxel_size: float = 0.1,
    separate_mode: str = "LOOSE",
) -> str:
    """
    Perform mesh operations: join, separate, boolean, shading, transforms, origin, remesh.

    Parameters:
    - action: "join" | "separate" | "boolean" | "shade_smooth" | "shade_flat" |
              "apply_transforms" | "origin_to_geometry" | "origin_to_center" | "remesh"
    - object_names: List of object names (for join — these get merged into target_object)
    - target_object: Primary object name for the operation
    - boolean_object: Second object name (for boolean operations)
    - boolean_mode: Boolean mode — UNION, DIFFERENCE, INTERSECT
    - voxel_size: Voxel size for remesh (default 0.1)
    - separate_mode: LOOSE or MATERIAL (for separate)
    """
    try:
        params = {"action": action, "voxel_size": voxel_size, "separate_mode": separate_mode}
        if object_names is not None:
            params["object_names"] = object_names
        if target_object is not None:
            params["target_object"] = target_object
        if boolean_object is not None:
            params["boolean_object"] = boolean_object
        if boolean_mode is not None:
            params["boolean_mode"] = boolean_mode
        return _send_and_return("mesh_operation", params)
    except Exception as e:
        logger.error(f"Error in mesh operation: {str(e)}")
        return f"Error in mesh operation: {str(e)}"


@mcp.tool()
def manage_materials(
    ctx: Context,
    action: str,
    object_name: str = None,
    material_name: str = None,
    slot_index: int = None,
    color: list = None,
    metallic: float = None,
    roughness: float = None,
) -> str:
    """
    Create, assign, remove, list, or configure materials and their Principled BSDF properties.

    Parameters:
    - action: "create" | "assign" | "remove_slot" | "list" | "list_object" | "set_properties"
    - object_name: Object name (for assign, remove_slot, list_object)
    - material_name: Material name (for create, assign, set_properties)
    - slot_index: Material slot index (for assign to specific slot, remove_slot)
    - color: Base color [r, g, b] or [r, g, b, a] in 0-1 range (for create, set_properties)
    - metallic: Metallic value 0-1 (for create, set_properties)
    - roughness: Roughness value 0-1 (for create, set_properties)
    """
    try:
        params = {"action": action}
        for key, val in [("object_name", object_name), ("material_name", material_name),
                         ("slot_index", slot_index), ("color", color),
                         ("metallic", metallic), ("roughness", roughness)]:
            if val is not None:
                params[key] = val
        return _send_and_return("manage_materials", params)
    except Exception as e:
        logger.error(f"Error managing materials: {str(e)}")
        return f"Error managing materials: {str(e)}"


# ─── Sprint 2: UV, Baking, LOD ─────────────────────────────────────────────

@mcp.tool()
def uv_operation(
    ctx: Context,
    object_name: str,
    action: str,
    uv_layer_name: str = None,
    island_margin: float = 0.02,
    angle_limit: float = 66.0,
) -> str:
    """
    UV mapping operations: unwrap, smart project, lightmap pack, layer management.

    Parameters:
    - object_name: Name of the mesh object
    - action: "smart_project" | "lightmap_pack" | "unwrap" | "create_layer" | "set_active" | "list"
    - uv_layer_name: Name for UV layer (for create_layer, set_active)
    - island_margin: Margin between UV islands (default 0.02)
    - angle_limit: Angle limit in degrees for smart_project (default 66.0)
    """
    try:
        params = {"object_name": object_name, "action": action,
                  "island_margin": island_margin, "angle_limit": angle_limit}
        if uv_layer_name is not None:
            params["uv_layer_name"] = uv_layer_name
        return _send_and_return("uv_operation", params)
    except Exception as e:
        logger.error(f"Error in UV operation: {str(e)}")
        return f"Error in UV operation: {str(e)}"


@mcp.tool()
def bake_textures(
    ctx: Context,
    high_poly: str,
    low_poly: str,
    bake_types: list[str],
    output_dir: str,
    resolution: int = 1024,
    cage_extrusion: float = 0.1,
    uv_layer: str = None,
) -> str:
    """
    Bake textures from a high-poly object onto a low-poly object (selected to active).
    Temporarily switches to Cycles if needed.

    Parameters:
    - high_poly: Name of the high-poly source object
    - low_poly: Name of the low-poly target object (must have UVs)
    - bake_types: List of types to bake — DIFFUSE, NORMAL, AO, ROUGHNESS, COMBINED, EMIT
    - output_dir: Directory path to save baked PNG textures
    - resolution: Texture resolution in pixels (default 1024)
    - cage_extrusion: Ray cast distance for projection (default 0.1)
    - uv_layer: UV layer name on low_poly (uses active if None)
    """
    try:
        params = {
            "high_poly": high_poly, "low_poly": low_poly,
            "bake_types": bake_types, "output_dir": output_dir,
            "resolution": resolution, "cage_extrusion": cage_extrusion,
        }
        if uv_layer is not None:
            params["uv_layer"] = uv_layer
        return _send_and_return("bake_textures", params)
    except Exception as e:
        logger.error(f"Error baking textures: {str(e)}")
        return f"Error baking textures: {str(e)}"


@mcp.tool()
def generate_lod_chain(
    ctx: Context,
    object_name: str,
    ratios: list[float] = None,
    suffix_pattern: str = "_LOD{i}",
    collection_name: str = None,
) -> str:
    """
    Generate a LOD (Level of Detail) chain by creating decimated copies of an object.

    Parameters:
    - object_name: Name of the source mesh object
    - ratios: List of decimation ratios (default [1.0, 0.5, 0.25, 0.1])
    - suffix_pattern: Naming pattern with {i} for LOD index (default "_LOD{i}")
    - collection_name: Optional collection to organize LOD objects into
    """
    try:
        params = {"object_name": object_name, "suffix_pattern": suffix_pattern}
        if ratios is not None:
            params["ratios"] = ratios
        if collection_name is not None:
            params["collection_name"] = collection_name
        return _send_and_return("generate_lod_chain", params)
    except Exception as e:
        logger.error(f"Error generating LOD chain: {str(e)}")
        return f"Error generating LOD chain: {str(e)}"


@mcp.tool()
def generate_collision_mesh(
    ctx: Context,
    object_name: str,
    method: str = "CONVEX_HULL",
    voxel_size: float = 0.1,
    name_suffix: str = "_collision",
) -> str:
    """
    Generate a simplified collision mesh from an object for game engines.

    Parameters:
    - object_name: Name of the source mesh object
    - method: Generation method — CONVEX_HULL, BOX, or VOXEL
    - voxel_size: Voxel size for VOXEL method (default 0.1)
    - name_suffix: Suffix for the collision mesh name (default "_collision")
    """
    try:
        return _send_and_return("generate_collision_mesh", {
            "object_name": object_name, "method": method,
            "voxel_size": voxel_size, "name_suffix": name_suffix,
        })
    except Exception as e:
        logger.error(f"Error generating collision mesh: {str(e)}")
        return f"Error generating collision mesh: {str(e)}"


# ─── Sprint 3: Rigging & Animation ─────────────────────────────────────────

@mcp.tool()
def manage_armature(
    ctx: Context,
    action: str,
    armature_name: str = None,
    object_name: str = None,
    bone_name: str = None,
    bone_data: list[dict] = None,
    constraint_data: dict = None,
    parent_bone: str = None,
    mode: str = "AUTO",
    head: list = None,
    tail: list = None,
    roll: float = None,
    use_deform: bool = None,
    use_connect: bool = None,
) -> str:
    """
    Create and manage armatures, bones, IK, and bone constraints for character rigging.

    Parameters:
    - action: "create" | "add_bone" | "set_bone" | "parent_mesh" | "add_ik" |
              "add_bone_constraint" | "remove_bone_constraint" | "list_bones"
    - armature_name: Name of the armature object
    - object_name: Mesh object name (for parent_mesh)
    - bone_name: Name of a specific bone
    - bone_data: List of bone dicts for create: [{name, head, tail, parent, use_connect}]
    - constraint_data: Constraint info dict:
        For add_ik: {target, subtarget, pole_target, pole_subtarget, chain_count}
        For add_bone_constraint: {type, target, subtarget, properties}
        For remove_bone_constraint: {name}
    - parent_bone: Parent bone name (for add_bone)
    - mode: Parenting mode — "AUTO" (auto weights) or "MANUAL" (for parent_mesh)
    - head: [x,y,z] bone head position
    - tail: [x,y,z] bone tail position
    - roll: Bone roll in degrees
    - use_deform: Whether bone deforms mesh
    - use_connect: Whether bone connects to parent
    """
    try:
        params = {"action": action, "mode": mode}
        for key, val in [("armature_name", armature_name), ("object_name", object_name),
                         ("bone_name", bone_name), ("bone_data", bone_data),
                         ("constraint_data", constraint_data), ("parent_bone", parent_bone),
                         ("head", head), ("tail", tail), ("roll", roll),
                         ("use_deform", use_deform), ("use_connect", use_connect)]:
            if val is not None:
                params[key] = val
        return _send_and_return("manage_armature", params)
    except Exception as e:
        logger.error(f"Error managing armature: {str(e)}")
        return f"Error managing armature: {str(e)}"


@mcp.tool()
def manage_weights(
    ctx: Context,
    object_name: str,
    action: str,
    group_name: str = None,
    vertex_indices: list[int] = None,
    weight: float = 1.0,
) -> str:
    """
    Manage vertex groups and weights for mesh deformation and rigging.

    Parameters:
    - object_name: Name of the mesh object
    - action: "assign" | "auto" | "normalize" | "list" | "remove"
    - group_name: Vertex group name (for assign, remove)
    - vertex_indices: List of vertex indices (for assign; if None, assigns all vertices)
    - weight: Weight value 0.0-1.0 (for assign, default 1.0)
    """
    try:
        params = {"object_name": object_name, "action": action, "weight": weight}
        if group_name is not None:
            params["group_name"] = group_name
        if vertex_indices is not None:
            params["vertex_indices"] = vertex_indices
        return _send_and_return("manage_weights", params)
    except Exception as e:
        logger.error(f"Error managing weights: {str(e)}")
        return f"Error managing weights: {str(e)}"


@mcp.tool()
def manage_shape_keys(
    ctx: Context,
    object_name: str,
    action: str,
    key_name: str = None,
    value: float = None,
    frame: int = None,
) -> str:
    """
    Manage shape keys (blend shapes / morph targets) on a mesh for facial animation or deformation.

    Parameters:
    - object_name: Name of the mesh object
    - action: "add" | "set_value" | "keyframe" | "list" | "remove"
    - key_name: Shape key name
    - value: Blend value 0.0-1.0 (for set_value, keyframe)
    - frame: Frame number (for keyframe)
    """
    try:
        params = {"object_name": object_name, "action": action}
        if key_name is not None:
            params["key_name"] = key_name
        if value is not None:
            params["value"] = value
        if frame is not None:
            params["frame"] = frame
        return _send_and_return("manage_shape_keys", params)
    except Exception as e:
        logger.error(f"Error managing shape keys: {str(e)}")
        return f"Error managing shape keys: {str(e)}"


@mcp.tool()
def manage_actions(
    ctx: Context,
    action: str,
    object_name: str = None,
    action_name: str = None,
    source_action: str = None,
) -> str:
    """
    Manage animation actions: create, assign to objects, list, or duplicate.

    Parameters:
    - action: "create" | "assign" | "list" | "duplicate"
    - object_name: Object to assign action to (for assign)
    - action_name: Name for the new/target action
    - source_action: Source action name (for duplicate)
    """
    try:
        params = {"action": action}
        if object_name is not None:
            params["object_name"] = object_name
        if action_name is not None:
            params["action_name"] = action_name
        if source_action is not None:
            params["source_action"] = source_action
        return _send_and_return("manage_actions", params)
    except Exception as e:
        logger.error(f"Error managing actions: {str(e)}")
        return f"Error managing actions: {str(e)}"


@mcp.tool()
def manage_nla(
    ctx: Context,
    object_name: str,
    action: str,
    track_name: str = None,
    action_name: str = None,
    start_frame: int = 1,
    properties: dict = None,
) -> str:
    """
    Manage NLA (Non-Linear Animation) tracks and strips for layering and blending animations.

    Parameters:
    - object_name: Name of the object
    - action: "create_track" | "add_strip" | "list_tracks" | "set_strip"
    - track_name: Name of the NLA track
    - action_name: Name of the action to push as strip (for add_strip)
    - start_frame: Start frame for the strip (default 1)
    - properties: Dict of strip properties (influence, blend_type, repeat, scale,
                  blend_in, blend_out, extrapolation, strip_name)
    """
    try:
        params = {"object_name": object_name, "action": action, "start_frame": start_frame}
        if track_name is not None:
            params["track_name"] = track_name
        if action_name is not None:
            params["action_name"] = action_name
        if properties is not None:
            params["properties"] = properties
        return _send_and_return("manage_nla", params)
    except Exception as e:
        logger.error(f"Error managing NLA: {str(e)}")
        return f"Error managing NLA: {str(e)}"


# ─── Sprint 4: Physics, Constraints, Viewport ──────────────────────────────

@mcp.tool()
def manage_physics(
    ctx: Context,
    object_name: str,
    action: str,
    physics_type: str = None,
    properties: dict = None,
) -> str:
    """
    Add, remove, configure, or list physics simulations on objects.

    Parameters:
    - object_name: Name of the object
    - action: "add" | "remove" | "set" | "list"
    - physics_type: RIGID_BODY, COLLISION, CLOTH, or SOFT_BODY
    - properties: Dict of physics properties (mass, friction, restitution, collision_shape, etc.)
    """
    try:
        params = {"object_name": object_name, "action": action}
        if physics_type is not None:
            params["physics_type"] = physics_type
        if properties is not None:
            params["properties"] = properties
        return _send_and_return("manage_physics", params)
    except Exception as e:
        logger.error(f"Error managing physics: {str(e)}")
        return f"Error managing physics: {str(e)}"


@mcp.tool()
def manage_constraints(
    ctx: Context,
    object_name: str,
    action: str,
    constraint_type: str = None,
    constraint_name: str = None,
    properties: dict = None,
) -> str:
    """
    Add, remove, configure, or list object-level constraints (Track To, Copy Rotation, etc.).

    Parameters:
    - object_name: Name of the object
    - action: "add" | "remove" | "set" | "list"
    - constraint_type: TRACK_TO, LIMIT_LOCATION, COPY_ROTATION, CHILD_OF,
                       DAMPED_TRACK, FLOOR, CLAMP_TO, COPY_LOCATION,
                       COPY_SCALE, LIMIT_ROTATION, LIMIT_SCALE
    - constraint_name: Name of constraint (for remove, set)
    - properties: Dict of constraint properties (target, subtarget, influence, etc.)
    """
    try:
        params = {"object_name": object_name, "action": action}
        if constraint_type is not None:
            params["constraint_type"] = constraint_type
        if constraint_name is not None:
            params["constraint_name"] = constraint_name
        if properties is not None:
            params["properties"] = properties
        return _send_and_return("manage_constraints", params)
    except Exception as e:
        logger.error(f"Error managing constraints: {str(e)}")
        return f"Error managing constraints: {str(e)}"


@mcp.tool()
def set_viewport_shading(
    ctx: Context,
    mode: str,
    options: dict = None,
) -> str:
    """
    Set the 3D viewport shading mode.

    Parameters:
    - mode: WIREFRAME, SOLID, MATERIAL, or RENDERED
    - options: Optional shading settings dict:
        For SOLID: cavity, xray, studio_light, color_type, single_color
        For RENDERED: use_scene_world, use_scene_lights
    """
    try:
        params = {"mode": mode}
        if options is not None:
            params["options"] = options
        return _send_and_return("set_viewport_shading", params)
    except Exception as e:
        logger.error(f"Error setting viewport shading: {str(e)}")
        return f"Error setting viewport shading: {str(e)}"


@mcp.tool()
def configure_render_settings(
    ctx: Context,
    settings: dict,
) -> str:
    """
    Configure render engine, resolution, color management, and world settings.

    Parameters:
    - settings: Dict of settings to configure. Supported keys:
        - engine: "CYCLES" | "BLENDER_EEVEE_NEXT" | "BLENDER_WORKBENCH"
        - samples: render sample count
        - resolution_x, resolution_y: render resolution
        - film_transparent: transparent background (bool)
        - use_motion_blur: motion blur (bool)
        - use_bloom, use_ssr, use_gtao: EEVEE features (bool)
        - view_transform: "Standard" | "Filmic" | "AgX"
        - look, exposure, gamma: color management
        - world_color: [r, g, b] world background color
    """
    try:
        return _send_and_return("configure_render_settings", {"settings": settings})
    except Exception as e:
        logger.error(f"Error configuring render settings: {str(e)}")
        return f"Error configuring render settings: {str(e)}"


# ─── Sprint 5: Volume Grids (Blender 5+) ───────────────────────────────────

@mcp.tool()
def volume_operation(
    ctx: Context,
    action: str,
    object_name: str = None,
    target_object: str = None,
    voxel_size: float = 0.05,
    operation: str = None,
    distance: float = None,
    radius: float = None,
    iterations: int = None,
    resolution: float = None,
    threshold: float = None,
    size: float = None,
    cave_density: float = None,
    cave_radius: float = None,
    seed: int = None,
) -> str:
    """
    Volume grid operations for SDF-based modeling (requires Blender 5.0+).
    Mesh to SDF conversion, boolean, offset, fillet, smooth, and procedural terrain.

    Parameters:
    - action: "mesh_to_sdf" | "sdf_to_mesh" | "sdf_boolean" | "sdf_offset" |
              "sdf_fillet" | "sdf_smooth" | "procedural_terrain"
    - object_name: Source object name
    - target_object: Second object (for sdf_boolean)
    - voxel_size: Grid resolution (default 0.05)
    - operation: Boolean op — UNION, INTERSECT, DIFFERENCE (for sdf_boolean)
    - distance: Offset distance, + expands, - shrinks (for sdf_offset)
    - radius: Fillet radius (for sdf_fillet) or cave radius (for procedural_terrain)
    - iterations: Smoothing iterations (for sdf_smooth)
    - resolution: Mesh resolution (for sdf_to_mesh)
    - threshold: Isosurface threshold (for sdf_to_mesh)
    - size: Terrain size (for procedural_terrain)
    - cave_density: Cave density (for procedural_terrain)
    - cave_radius: Cave radius (for procedural_terrain)
    - seed: Random seed (for procedural_terrain)
    """
    try:
        params = {"action": action, "voxel_size": voxel_size}
        for key, val in [("object_name", object_name), ("target_object", target_object),
                         ("operation", operation), ("distance", distance),
                         ("radius", radius), ("iterations", iterations),
                         ("resolution", resolution), ("threshold", threshold),
                         ("size", size), ("cave_density", cave_density),
                         ("cave_radius", cave_radius), ("seed", seed)]:
            if val is not None:
                params[key] = val
        return _send_and_return("volume_operation", params)
    except Exception as e:
        logger.error(f"Error in volume operation: {str(e)}")
        return f"Error in volume operation: {str(e)}"


# ─── Strategy Prompt ────────────────────────────────────────────────────────

@mcp.prompt()
def asset_creation_strategy() -> str:
    """Defines the preferred strategy for creating 3D content in Blender"""
    return """When creating 3D content in Blender, follow this workflow:

    ## 1. Understand the Scene
    - Always start with get_scene_info() and get_viewport_screenshot()
    - Use analyze_scene() for detailed analysis with specific focus areas

    ## 2. Structured Tools by Domain (preferred over execute_blender_code)

    ### Object Management
    - create_object() — create meshes (CUBE, SPHERE, CYLINDER, PLANE, CONE, TORUS, etc.),
      lights (POINT_LIGHT, SUN_LIGHT, SPOT_LIGHT, AREA_LIGHT), cameras, empties
    - transform_object() — move/rotate/scale with absolute ("set") or additive ("delta") mode
    - duplicate_object() — copy objects, optionally linked
    - delete_object() — remove objects by name list

    ### Mesh Operations
    - mesh_operation() — join, separate, boolean (UNION/DIFFERENCE/INTERSECT),
      shade_smooth/shade_flat, apply_transforms, origin_to_geometry, remesh
    - generate_lod_chain() — create LOD levels with automatic decimation
    - generate_collision_mesh() — create collision meshes (CONVEX_HULL, BOX, VOXEL)

    ### Materials
    - manage_materials() — create materials with PBR properties, assign to objects,
      list scene/object materials, set base_color/metallic/roughness

    ### UV Mapping
    - uv_operation() — smart_project, lightmap_pack, unwrap, create/set/list UV layers

    ### Texture Baking
    - bake_textures() — bake high→low poly textures (DIFFUSE, NORMAL, AO, ROUGHNESS, COMBINED, EMIT)

    ### Node Graph Engine
    - build_node_graph() — geometry nodes, shader nodes, compositor
    - get_node_graph() — inspect existing graphs
    - list_node_types() — discover available nodes

    ### Modifiers
    - modify_object() — add/remove/apply/set modifiers (SUBSURF, BEVEL, ARRAY, MIRROR, BOOLEAN, etc.)

    ### Rigging & Animation
    - manage_armature() — create armatures, add/set bones, parent meshes, IK, bone constraints
    - manage_weights() — vertex group assignment, auto weights, normalize
    - manage_shape_keys() — blend shapes / morph targets with keyframing
    - manage_actions() — create/assign/list/duplicate animation actions
    - manage_nla() — NLA tracks and strips for non-linear animation
    - set_keyframe() — keyframe location/rotation/scale or custom data_path with interpolation

    ### Physics & Constraints
    - manage_physics() — rigid body, collision, cloth, soft body
    - manage_constraints() — Track To, Copy Rotation, Limit Location, etc.

    ### Camera & Rendering
    - set_camera() — focal length, DOF, focus
    - render_scene() — render to file
    - set_viewport_shading() — WIREFRAME, SOLID, MATERIAL, RENDERED
    - configure_render_settings() — engine, samples, resolution, color management, world

    ### Scene Organization
    - manage_collections() — create/delete/move collections and objects
    - import_model() / export_model() — file I/O (GLB, FBX, OBJ, USD, STL, etc.)

    ### Volume Grids (Blender 5.0+)
    - volume_operation() — mesh_to_sdf, sdf_to_mesh, sdf_boolean, sdf_offset,
      sdf_fillet, sdf_smooth, procedural_terrain

    ### Visual Feedback Loop
    analyze_scene() → see screenshot + data → make changes → analyze_scene() again

    ## 3. Asset Integrations
    Check availability with status tools before using:

    **PolyHaven** (get_polyhaven_status):
    - Textures, HDRIs, Models via download_polyhaven_asset()

    **Sketchfab** (get_sketchfab_status):
    - search_sketchfab_models() → get_sketchfab_model_preview() → download_sketchfab_model()

    **Hyper3D Rodin** (get_hyper3d_status):
    - generate_hyper3d_model_via_text/images() → poll_rodin_job_status() → import_generated_asset()

    **Hunyuan3D** (get_hunyuan3d_status):
    - generate_hunyuan3d_model() → poll_hunyuan_job_status() → import_generated_asset_hunyuan()

    **Trellis2** (get_trellis2_status):
    - generate_trellis2_model()

    ## 4. Priority Order
    1. Use structured tools over execute_blender_code
    2. For objects: create_object() + transform_object() over code
    3. For assets: Sketchfab/PolyHaven first, then AI generation, then code
    4. For materials: manage_materials() or build_node_graph(target="shader:...") or PolyHaven textures
    5. For rigging: manage_armature() + manage_weights() over code
    6. Always verify with analyze_scene() or get_viewport_screenshot()
    """

# Main execution

def main():
    """Run the MCP server"""
    mcp.run()

if __name__ == "__main__":
    main()