604 lines
22 KiB
Python
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() |