server.py•13.6 kB
from mcp.server.fastmcp import FastMCP
import socket
import json
import sys
import logging
# Force UTF-8 encoding for stdout/stdin
sys.stdout.reconfigure(encoding='utf-8')
sys.stdin.reconfigure(encoding='utf-8')
# Setup logging to stderr (won't interfere with MCP stdio)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
stream=sys.stderr
)
logger = logging.getLogger("blender-mcp")
# Initialize FastMCP server
mcp = FastMCP("Blender MCP")
import os
# Configuration
BLENDER_HOST = os.getenv('BLENDER_HOST', '127.0.0.1')
BLENDER_PORT = int(os.getenv('BLENDER_PORT', '9876'))
def send_to_blender(command_dict):
"""Helper to send JSON command to Blender via socket"""
try:
logger.info(f"Attempting to connect to Blender at {BLENDER_HOST}:{BLENDER_PORT}")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(5.0) # 5 second timeout
s.connect((BLENDER_HOST, BLENDER_PORT))
s.sendall(json.dumps(command_dict).encode('utf-8'))
data = s.recv(4096)
resp = json.loads(data.decode('utf-8'))
# Truncate output if too long to save tokens
if "output" in resp and isinstance(resp["output"], str) and len(resp["output"]) > 1000:
resp["output"] = resp["output"][:1000] + "... [TRUNCATED]"
logger.info(f"Successfully executed command: {command_dict.get('type', 'unknown')}")
return resp
except ConnectionRefusedError:
logger.error(f"Connection refused to Blender at {BLENDER_HOST}:{BLENDER_PORT}")
return {"status": "error", "message": "Could not connect to Blender. Is the Addon installed and Blender running?"}
except socket.timeout:
logger.error("Socket timeout while connecting to Blender")
return {"status": "error", "message": "Timeout connecting to Blender. Make sure Blender is running with the addon enabled."}
except Exception as e:
logger.error(f"Error communicating with Blender: {str(e)}")
return {"status": "error", "message": str(e)}
@mcp.tool()
def run_blender_script(script_code: str) -> str:
"""
Executes a Python script inside the running Blender instance.
Use this to create objects, modify scenes, or query data using the 'bpy' library.
Example:
import bpy
bpy.ops.mesh.primitive_cube_add(size=2)
"""
command = {
"type": "run_script",
"script": script_code
}
response = send_to_blender(command)
# Compact JSON to save tokens
return json.dumps(response, separators=(',', ':'))
@mcp.tool()
def get_blender_version() -> str:
"""Checks the connection and returns the Blender version."""
command = {
"type": "get_version"
}
response = send_to_blender(command)
return json.dumps(response, separators=(',', ':'))
@mcp.tool()
def apply_texture(object_name: str, texture_path: str) -> str:
"""Apply an image texture to an object.
Auto-joins selected objects if multiple are active/selected to ensure single mesh texturing.
Auto-generates UVs if missing.
Args:
object_name: Name of the main object to texture.
texture_path: Absolute path to the image file.
"""
command = {
"type": "apply_texture",
"object_name": object_name,
"texture_path": texture_path
}
return json.dumps(send_to_blender(command), separators=(',', ':'))
@mcp.tool()
def create_primitive(type: str, name: str = "", size: float = 1.0, location: tuple[float, float, float] = (0.0, 0.0, 0.0), rotation: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> str:
"""Create a basic 3D primitive object.
Args:
type: One of 'CUBE', 'SPHERE', 'ICO_SPHERE', 'CYLINDER', 'CONE', 'PLANE', 'MONKEY'.
name: Optional name for the new object.
size: Size/Radius of the object.
location: (x, y, z) location.
rotation: (x, y, z) rotation in degrees.
"""
script = f"""
import bpy
import math
def make_prim():
loc = {location}
rot = [math.radians(a) for a in {rotation}]
sz = {size}
if '{type}' == 'CUBE':
bpy.ops.mesh.primitive_cube_add(size=sz, location=loc, rotation=rot)
elif '{type}' == 'SPHERE':
bpy.ops.mesh.primitive_uv_sphere_add(radius=sz/2, location=loc, rotation=rot)
elif '{type}' == 'ICO_SPHERE':
bpy.ops.mesh.primitive_ico_sphere_add(radius=sz/2, location=loc, rotation=rot)
elif '{type}' == 'CYLINDER':
bpy.ops.mesh.primitive_cylinder_add(radius=sz/2, depth=sz, location=loc, rotation=rot)
elif '{type}' == 'CONE':
bpy.ops.mesh.primitive_cone_add(radius1=sz/2, depth=sz, location=loc, rotation=rot)
elif '{type}' == 'PLANE':
bpy.ops.mesh.primitive_plane_add(size=sz, location=loc, rotation=rot)
elif '{type}' == 'MONKEY':
bpy.ops.mesh.primitive_monkey_add(size=sz, location=loc, rotation=rot)
else:
return "Error: Unknown primitive type '{type}'"
obj = bpy.context.active_object
if "{name}":
obj.name = "{name}"
return f"Created {{obj.name}} ({type})"
make_prim()
"""
return json.dumps(send_to_blender({"type": "run_script", "script": script}), separators=(',', ':'))
@mcp.tool()
def transform_object(name: str, location: tuple[float, float, float] = None, rotation: tuple[float, float, float] = None, scale: tuple[float, float, float] = None) -> str:
"""Transform (move, rotate, scale) an existing object.
Args:
name: Name of the object to transform.
location: New (x, y, z) location. None to keep current.
rotation: New (x, y, z) rotation in degrees. None to keep current.
scale: New (x, y, z) scale. None to keep current.
"""
script = f"""
import bpy
import math
def transform():
name = "{name}"
if name not in bpy.data.objects:
return f"Error: Object '{{name}}' not found"
obj = bpy.data.objects[name]
if {location} is not None:
obj.location = {location}
if {rotation} is not None:
obj.rotation_euler = [math.radians(a) for a in {rotation}]
if {scale} is not None:
obj.scale = {scale}
return f"Transformed {{name}}"
transform()
"""
return json.dumps(send_to_blender({"type": "run_script", "script": script}), separators=(',', ':'))
@mcp.tool()
def apply_material_preset(object_name: str, preset_name: str, color: tuple[float, float, float] = (0.8, 0.8, 0.8)) -> str:
"""Apply a material preset to an object.
Args:
object_name: Name of the object.
preset_name: 'GOLD', 'SILVER', 'GLASS', 'PLASTIC', 'RUBBER', 'EMISSION', 'BASIC'.
color: RGB color tuple (0-1) for the material (if applicable).
"""
script = f"""
import bpy
def apply_preset():
obj_name = "{object_name}"
preset = "{preset_name}"
col = {color} + (1.0,) # Add alpha
if obj_name not in bpy.data.objects:
return f"Error: Object '{{obj_name}}' not found"
obj = bpy.data.objects[obj_name]
mat_name = f"Preset_{{preset}}"
if mat_name in bpy.data.materials:
mat = bpy.data.materials[mat_name]
else:
mat = bpy.data.materials.new(name=mat_name)
mat.use_nodes = True
nodes = mat.node_tree.nodes
nodes.clear()
output = nodes.new(type='ShaderNodeOutputMaterial')
output.location = (300, 0)
bsdf = nodes.new(type='ShaderNodeBsdfPrincipled')
bsdf.location = (0, 0)
mat.node_tree.links.new(bsdf.outputs['BSDF'], output.inputs['Surface'])
# Presets logic
if preset == 'GOLD':
bsdf.inputs['Base Color'].default_value = (1.0, 0.76, 0.33, 1.0)
bsdf.inputs['Metallic'].default_value = 1.0
bsdf.inputs['Roughness'].default_value = 0.1
elif preset == 'SILVER':
bsdf.inputs['Base Color'].default_value = (0.97, 0.97, 0.97, 1.0)
bsdf.inputs['Metallic'].default_value = 1.0
bsdf.inputs['Roughness'].default_value = 0.1
elif preset == 'GLASS':
bsdf.inputs['Base Color'].default_value = col
bsdf.inputs['Transmission Weight'].default_value = 1.0
bsdf.inputs['Roughness'].default_value = 0.0
bsdf.inputs['IOR'].default_value = 1.45
elif preset == 'PLASTIC':
bsdf.inputs['Base Color'].default_value = col
bsdf.inputs['Roughness'].default_value = 0.2
bsdf.inputs['Specular IOR Level'].default_value = 0.5
elif preset == 'RUBBER':
bsdf.inputs['Base Color'].default_value = col
bsdf.inputs['Roughness'].default_value = 0.8
bsdf.inputs['Specular IOR Level'].default_value = 0.2
elif preset == 'EMISSION':
nodes.remove(bsdf)
emit = nodes.new(type='ShaderNodeEmission')
emit.location = (0,0)
emit.inputs['Color'].default_value = col
emit.inputs['Strength'].default_value = 5.0
mat.node_tree.links.new(emit.outputs['Emission'], output.inputs['Surface'])
else: # BASIC
bsdf.inputs['Base Color'].default_value = col
# Assign
if obj.data.materials:
obj.data.materials[0] = mat
else:
obj.data.materials.append(mat)
return f"Applied {{preset}} to {{obj_name}}"
apply_preset()
"""
return json.dumps(send_to_blender({"type": "run_script", "script": script}), separators=(',', ':'))
@mcp.tool()
def scatter_objects(source_obj_name: str, count: int, radius: float, center: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> str:
"""Scatter copies of an object randomly within a radius.
Args:
source_obj_name: Name of the object to duplicate.
count: Number of copies.
radius: Radius of the scatter area.
center: Center point (x, y, z).
"""
script = f"""
import bpy
import random
import math
def scatter():
name = "{source_obj_name}"
if name not in bpy.data.objects:
return f"Error: Object '{{name}}' not found"
src_obj = bpy.data.objects[name]
created = []
for i in range({count}):
# Duplicate
new_obj = src_obj.copy()
new_obj.data = src_obj.data.copy()
new_obj.animation_data_clear()
# Random loc
r = {radius} * math.sqrt(random.random())
theta = random.random() * 2 * math.pi
x = {center}[0] + r * math.cos(theta)
y = {center}[1] + r * math.sin(theta)
z = {center}[2] # Flat scatter for now
new_obj.location = (x, y, z)
# Random rotation Z
new_obj.rotation_euler[2] = random.random() * 2 * math.pi
bpy.context.collection.objects.link(new_obj)
created.append(new_obj.name)
return f"Scattered {count} copies of {{name}}"
scatter()
"""
return json.dumps(send_to_blender({"type": "run_script", "script": script}), separators=(',', ':'))
@mcp.tool()
def get_scene_info(limit: int = 50, name_filter: str = "") -> str:
"""Get a list of objects in the scene.
Args:
limit: Max number of objects to return (default 50).
name_filter: Filter objects by name (contains string).
"""
script = f"""
import bpy
import json
import math
def get_info():
data = []
count = 0
limit = {limit}
filter_txt = "{name_filter}".lower()
for obj in bpy.data.objects:
if count >= limit:
break
if filter_txt and filter_txt not in obj.name.lower():
continue
info = {{
"name": obj.name,
"type": obj.type,
"location": [round(v, 2) for v in obj.location],
"rotation": [round(math.degrees(v), 2) for v in obj.rotation_euler],
"scale": [round(v, 2) for v in obj.scale],
"visible": not obj.hide_viewport
}}
data.append(info)
count += 1
return json.dumps(data, separators=(',', ':'))
get_info()
"""
return json.dumps(send_to_blender({"type": "run_script", "script": script}), separators=(',', ':'))
@mcp.tool()
def delete_object(name: str) -> str:
"""Delete an object by name."""
script = f"""
import bpy
if "{name}" in bpy.data.objects:
bpy.data.objects.remove(bpy.data.objects["{name}"], do_unlink=True)
print("Deleted {name}")
else:
print("Object {name} not found")
"""
return json.dumps(send_to_blender({"type": "run_script", "script": script}), separators=(',', ':'))
@mcp.tool()
def clear_scene() -> str:
"""Delete ALL objects in the scene (Use with caution)."""
script = """
import bpy
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
"""
return json.dumps(send_to_blender({"type": "run_script", "script": script}), separators=(',', ':'))
if __name__ == "__main__":
try:
logger.info("=" * 60)
logger.info("Starting Blender MCP Server")
logger.info(f"Blender connection: {BLENDER_HOST}:{BLENDER_PORT}")
logger.info("=" * 60)
mcp.run(transport='stdio')
except KeyboardInterrupt:
logger.info("Server stopped by user")
sys.exit(0)
except Exception as e:
logger.error(f"Fatal error in MCP server: {e}", exc_info=True)
sys.exit(1)