blender-mcp/blender_mcp_server.py

604 lines
22 KiB
Python

# 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_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.tool()
def create_3d_scene(ctx: Context, description: str) -> str:
"""
Create a 3D scene based on a text description.
This is a higher-level tool that will interpret the description and create
appropriate objects, materials, and lighting.
Parameters:
- description: Text description of the scene to create
"""
try:
# Get the global connection
blender = get_blender_connection()
# Parse the description and create a scene
# This is a simplified implementation - in a real tool, you would use more
# sophisticated parsing and scene generation logic
# For now, we'll just create a simple scene with a few objects
# Clear existing objects (optional)
try:
blender.send_command("execute_code", {
"code": """
import bpy
# Delete all objects except camera and light
for obj in bpy.data.objects:
if obj.type not in ['CAMERA', 'LIGHT']:
bpy.data.objects.remove(obj)
"""
})
except Exception as e:
logger.warning(f"Error clearing scene: {str(e)}")
# Create a simple scene based on the description
objects_created = []
# Add a ground plane
try:
result = blender.send_command("create_object", {
"type": "PLANE",
"name": "Ground",
"location": [0, 0, 0],
"scale": [5, 5, 1]
})
objects_created.append(result["name"])
# Set a material for the ground
blender.send_command("set_material", {
"object_name": "Ground",
"material_name": "GroundMaterial",
"color": [0.8, 0.8, 0.8]
})
except Exception as e:
logger.warning(f"Error creating ground: {str(e)}")
# Simple keyword-based object creation
if "cube" in description.lower():
try:
result = blender.send_command("create_object", {
"type": "CUBE",
"name": "Cube",
"location": [0, 0, 1]
})
objects_created.append(result["name"])
except Exception as e:
logger.warning(f"Error creating cube: {str(e)}")
if "sphere" in description.lower():
try:
result = blender.send_command("create_object", {
"type": "SPHERE",
"name": "Sphere",
"location": [2, 0, 1]
})
objects_created.append(result["name"])
except Exception as e:
logger.warning(f"Error creating sphere: {str(e)}")
if "cylinder" in description.lower():
try:
result = blender.send_command("create_object", {
"type": "CYLINDER",
"name": "Cylinder",
"location": [-2, 0, 1]
})
objects_created.append(result["name"])
except Exception as e:
logger.warning(f"Error creating cylinder: {str(e)}")
return f"Created scene with objects: {', '.join(objects_created)}"
except Exception as e:
logger.error(f"Error creating scene: {str(e)}")
return f"Error creating scene: {str(e)}"
# Prompts to help users interact with Blender
@mcp.prompt()
def create_simple_scene() -> str:
"""Create a simple Blender scene with basic objects"""
return """
I'd like to create a simple scene in Blender. Please create:
1. A ground plane
2. A cube above the ground
3. A sphere to the side
4. Make sure there's a camera and light
5. Set different colors for the objects
"""
@mcp.prompt()
def animate_object() -> str:
"""Create keyframe animation for an object"""
return """
I want to animate a cube moving from point A to point B over 30 frames.
Can you help me create this animation?
"""
@mcp.prompt()
def add_material() -> str:
"""Add a material to an object"""
return """
I have a cube in my scene. Can you create a blue metallic material and apply it to the cube?
"""
# Main execution
def main():
"""Run the MCP server"""
mcp.run()
if __name__ == "__main__":
main()