# blender_mcp_server.py from mcp.server.fastmcp import FastMCP, Context, Image import socket import json import asyncio import logging from dataclasses import dataclass from contextlib import asynccontextmanager from typing import AsyncIterator, Dict, Any, List # Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger("BlenderMCPServer") @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(15.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(15.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("BlenderMCP 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("BlenderMCP server shut down") # Create the MCP server with lifespan support mcp = FastMCP( "BlenderMCP", description="Blender integration through the Model Context Protocol", lifespan=server_lifespan ) # Resource endpoints # Global connection for resources (since resources can't access context) _blender_connection = None def get_blender_connection(): """Get or create a persistent Blender connection""" global _blender_connection # If we have an existing connection, check if it's still valid if _blender_connection is not None: # Test if the connection is still alive with a simple ping try: # Just try to send a small message to check if the socket is still connected _blender_connection.sock.sendall(b'') 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: _blender_connection = BlenderConnection(host="localhost", port=9876) 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.resource("blender://ping") def ping_blender() -> str: """Ping the Blender server to check connectivity""" try: blender = get_blender_connection() result = blender.send_command("ping") return json.dumps({"status": "success", "result": result}) except Exception as e: logger.error(f"Error pinging Blender: {str(e)}") return json.dumps({"status": "error", "message": str(e)}) @mcp.resource("blender://simple") def get_simple_info() -> str: """Get basic information about the Blender instance""" try: blender = get_blender_connection() result = blender.send_command("get_simple_info") return json.dumps({"status": "success", "result": result}) except Exception as e: logger.error(f"Error getting simple info from Blender: {str(e)}") return json.dumps({"status": "error", "message": str(e)}) @mcp.resource("blender://scene") def get_scene_info() -> str: """Get detailed information about the current Blender scene""" try: blender = get_blender_connection() result = blender.send_command("get_scene_info") return json.dumps({"status": "success", "result": result}) except Exception as e: logger.error(f"Error getting scene info from Blender: {str(e)}") return json.dumps({"status": "error", "message": str(e)}) @mcp.resource("blender://object/{object_name}") def get_object_info(object_name: str) -> str: """ Get detailed information about a specific object in the Blender scene. Args: 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}) return json.dumps({"status": "success", "result": result}) except Exception as e: logger.error(f"Error getting object info from Blender: {str(e)}") return json.dumps({"status": "error", "message": str(e)}) # Tool endpoints @mcp.tool() def create_primitive( ctx: Context, type: str = "CUBE", location: List[float] = None, color: List[float] = None ) -> str: """ Create a basic primitive object in Blender. Parameters: - type: Object type (CUBE, SPHERE, CYLINDER, PLANE) - location: Optional [x, y, z] location coordinates - color: Optional [R, G, B] color values (0.0-1.0) """ try: blender = get_blender_connection() loc = location or [0, 0, 0] # First create the object params = { "type": type, "location": loc } result = blender.send_command("create_object", params) # If color specified, set the material if color: blender.send_command("set_material", { "object_name": result["name"], "color": color }) return f"Created {type} at location {loc}" except Exception as e: return f"Error creating primitive: {str(e)}" @mcp.tool() def set_object_property( ctx: Context, name: str, property: str, value: Any ) -> str: """ Set a single property of an object. Parameters: - name: Name of the object - property: Property to set (location, rotation, scale, color, visible) - value: New value for the property """ try: blender = get_blender_connection() params = {"name": name, property: value} result = blender.send_command("modify_object", params) return f"Set {property} of {name} to {value}" except Exception as e: return f"Error setting property: {str(e)}" @mcp.tool() def create_object( ctx: Context, type: str = "CUBE", name: str = None, location: List[float] = None, rotation: List[float] = None, scale: List[float] = None ) -> str: """ Create a new object in the Blender scene. Parameters: - type: Object type (CUBE, SPHERE, CYLINDER, PLANE, CONE, TORUS, EMPTY, CAMERA, LIGHT) - name: Optional name for the object - location: Optional [x, y, z] location coordinates - rotation: Optional [x, y, z] rotation in radians - scale: Optional [x, y, z] scale factors """ try: # Get the global connection blender = get_blender_connection() # Set default values for missing parameters loc = location or [0, 0, 0] rot = rotation or [0, 0, 0] sc = scale or [1, 1, 1] params = { "type": type, "location": loc, "rotation": rot, "scale": sc } if name: params["name"] = name result = blender.send_command("create_object", params) return f"Created {type} object: {result['name']}" except Exception as e: logger.error(f"Error creating object: {str(e)}") return f"Error creating object: {str(e)}" @mcp.tool() def modify_object( ctx: Context, name: str, location: List[float] = None, rotation: List[float] = None, scale: List[float] = None, visible: bool = None ) -> str: """ Modify an existing object in the Blender scene. Parameters: - name: Name of the object to modify - location: Optional [x, y, z] location coordinates - rotation: Optional [x, y, z] rotation in radians - scale: Optional [x, y, z] scale factors - visible: Optional boolean to set visibility """ try: # Get the global connection blender = get_blender_connection() params = {"name": name} if location is not None: params["location"] = location if rotation is not None: params["rotation"] = rotation if scale is not None: params["scale"] = scale if visible is not None: params["visible"] = visible result = blender.send_command("modify_object", params) return f"Modified object: {result['name']}" except Exception as e: logger.error(f"Error modifying object: {str(e)}") return f"Error modifying object: {str(e)}" @mcp.tool() def delete_object(ctx: Context, name: str) -> str: """ Delete an object from the Blender scene. Parameters: - name: Name of the object to delete """ try: # Get the global connection blender = get_blender_connection() result = blender.send_command("delete_object", {"name": name}) return f"Deleted object: {name}" except Exception as e: logger.error(f"Error deleting object: {str(e)}") return f"Error deleting object: {str(e)}" @mcp.tool() def set_material( ctx: Context, object_name: str, material_name: str = None, color: List[float] = None ) -> str: """ Set or create a material for an object. Parameters: - object_name: Name of the object to apply the material to - material_name: Optional name of the material to use or create - color: Optional [R, G, B] color values (0.0-1.0) """ try: # Get the global connection blender = get_blender_connection() params = {"object_name": object_name} if material_name: params["material_name"] = material_name if color: params["color"] = color result = blender.send_command("set_material", params) return f"Applied material to {object_name}: {result.get('material_name', 'unknown')}" except Exception as e: logger.error(f"Error setting material: {str(e)}") return f"Error setting material: {str(e)}" @mcp.tool() def render_scene( ctx: Context, output_path: str = None, resolution_x: int = None, resolution_y: int = None ) -> str: """ Render the current scene and return the image. Parameters: - output_path: Optional path to save the rendered image - resolution_x: Optional horizontal resolution - resolution_y: Optional vertical resolution """ try: # Get the global connection blender = get_blender_connection() params = {} if output_path: params["output_path"] = output_path if resolution_x: params["resolution_x"] = resolution_x if resolution_y: params["resolution_y"] = resolution_y result = blender.send_command("render_scene", params) if "image_path" in result: # If we have an image path, we could potentially load and return the image return f"Scene rendered to {result['image_path']}" else: return "Scene rendered successfully" except Exception as e: logger.error(f"Error rendering scene: {str(e)}") return f"Error rendering scene: {str(e)}" @mcp.tool() def execute_blender_code(ctx: Context, code: str) -> str: """ Execute arbitrary Python code in Blender. 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.prompt() def create_basic_object() -> str: """Create a single object with basic properties""" return """Create a blue cube at position [0, 1, 0]""" @mcp.prompt() def modify_basic_object() -> str: """Modify a single property of an object""" return """Make the cube red""" # Main execution def main(): """Run the MCP server""" mcp.run() if __name__ == "__main__": main()