Repository: ahujasid/blender-mcp
Branch: main
Commit: 7636d13bded8
Files: 12
Total size: 187.2 KB
Directory structure:
gitextract_u30293zn/
├── .gitignore
├── .python-version
├── LICENSE
├── README.md
├── TERMS_AND_CONDITIONS.md
├── addon.py
├── main.py
├── pyproject.toml
└── src/
└── blender_mcp/
├── __init__.py
├── server.py
├── telemetry.py
└── telemetry_decorator.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# macOS
.DS_Store
# Local config secrets
src/blender_mcp/config.py
================================================
FILE: .python-version
================================================
3.13.2
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2025 Siddharth Ahuja
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# BlenderMCP - Blender Model Context Protocol Integration
BlenderMCP connects Blender to Claude AI through the Model Context Protocol (MCP), allowing Claude to directly interact with and control Blender. This integration enables prompt assisted 3D modeling, scene creation, and manipulation.
**We have no official website. Any website you see online is unofficial and has no affiliation with this project. Use them at your own risk.**
[Full tutorial](https://www.youtube.com/watch?v=lCyQ717DuzQ)
### Join the Community
Give feedback, get inspired, and build on top of the MCP: [Discord](https://discord.gg/z5apgR8TFU)
### Supporters
[CodeRabbit](https://www.coderabbit.ai/)
**All supporters:**
[Support this project](https://github.com/sponsors/ahujasid)
## Current version(1.5.5)
- Added Hunyuan3D support
- View screenshots for Blender viewport to better understand the scene
- Search and download Sketchfab models
- Support for Poly Haven assets through their API
- Support to generate 3D models using Hyper3D Rodin
- Run Blender MCP on a remote host
- Telemetry for tools executed (completely anonymous)
### Installating a new version (existing users)
- For newcomers, you can go straight to Installation. For existing users, see the points below
- Download the latest addon.py file and replace the older one, then add it to Blender
- Delete the MCP server from Claude and add it back again, and you should be good to go!
## Features
- **Two-way communication**: Connect Claude AI to Blender through a socket-based server
- **Object manipulation**: Create, modify, and delete 3D objects in Blender
- **Material control**: Apply and modify materials and colors
- **Scene inspection**: Get detailed information about the current Blender scene
- **Code execution**: Run arbitrary Python code in Blender from Claude
## Components
The system consists of two main components:
1. **Blender Addon (`addon.py`)**: A Blender addon that creates a socket server within Blender to receive and execute commands
2. **MCP Server (`src/blender_mcp/server.py`)**: A Python server that implements the Model Context Protocol and connects to the Blender addon
## Installation
### Prerequisites
- Blender 3.0 or newer
- Python 3.10 or newer
- uv package manager:
**If you're on Mac, please install uv as**
```bash
brew install uv
```
**On Windows**
```powershell
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
```
and then add uv to the user path in Windows (you may need to restart Claude Desktop after):
```powershell
$localBin = "$env:USERPROFILE\.local\bin"
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
[Environment]::SetEnvironmentVariable("Path", "$userPath;$localBin", "User")
```
Otherwise installation instructions are on their website: [Install uv](https://docs.astral.sh/uv/getting-started/installation/)
**⚠️ Do not proceed before installing UV**
### Environment Variables
The following environment variables can be used to configure the Blender connection:
- `BLENDER_HOST`: Host address for Blender socket server (default: "localhost")
- `BLENDER_PORT`: Port number for Blender socket server (default: 9876)
Example:
```bash
export BLENDER_HOST='host.docker.internal'
export BLENDER_PORT=9876
```
### Claude for Desktop Integration
[Watch the setup instruction video](https://www.youtube.com/watch?v=neoK_WMq92g) (Assuming you have already installed uv)
Go to Claude > Settings > Developer > Edit Config > claude_desktop_config.json to include the following:
```json
{
"mcpServers": {
"blender": {
"command": "uvx",
"args": [
"blender-mcp"
]
}
}
}
```
<details>
<summary>Claude Code</summary>
Use the Claude Code CLI to add the blender MCP server:
```bash
claude mcp add blender uvx blender-mcp
```
</details>
### Cursor integration
[](https://cursor.com/link/mcp%2Finstall?name=blender&config=eyJjb21tYW5kIjoidXZ4IGJsZW5kZXItbWNwIn0%3D)
For Mac users, go to Settings > MCP and paste the following
- To use as a global server, use "add new global MCP server" button and paste
- To use as a project specific server, create `.cursor/mcp.json` in the root of the project and paste
```json
{
"mcpServers": {
"blender": {
"command": "uvx",
"args": [
"blender-mcp"
]
}
}
}
```
For Windows users, go to Settings > MCP > Add Server, add a new server with the following settings:
```json
{
"mcpServers": {
"blender": {
"command": "cmd",
"args": [
"/c",
"uvx",
"blender-mcp"
]
}
}
}
```
[Cursor setup video](https://www.youtube.com/watch?v=wgWsJshecac)
**⚠️ Only run one instance of the MCP server (either on Cursor or Claude Desktop), not both**
### Visual Studio Code Integration
_Prerequisites_: Make sure you have [Visual Studio Code](https://code.visualstudio.com/) installed before proceeding.
[](vscode:mcp/install?%7B%22name%22%3A%22blender-mcp%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22blender-mcp%22%5D%7D)
### Installing the Blender Addon
1. Download the `addon.py` file from this repo
1. Open Blender
2. Go to Edit > Preferences > Add-ons
3. Click "Install..." and select the `addon.py` file
4. Enable the addon by checking the box next to "Interface: Blender MCP"
## Usage
### Starting the Connection

1. In Blender, go to the 3D View sidebar (press N if not visible)
2. Find the "BlenderMCP" tab
3. Turn on the Poly Haven checkbox if you want assets from their API (optional)
4. Click "Connect to Claude"
5. Make sure the MCP server is running in your terminal
### Using with Claude
Once the config file has been set on Claude, and the addon is running on Blender, you will see a hammer icon with tools for the Blender MCP.

#### Capabilities
- Get scene and object information
- Create, delete and modify shapes
- Apply or create materials for objects
- Execute any Python code in Blender
- Download the right models, assets and HDRIs through [Poly Haven](https://polyhaven.com/)
- AI generated 3D models through [Hyper3D Rodin](https://hyper3d.ai/)
### Example Commands
Here are some examples of what you can ask Claude to do:
- "Create a low poly scene in a dungeon, with a dragon guarding a pot of gold" [Demo](https://www.youtube.com/watch?v=DqgKuLYUv00)
- "Create a beach vibe using HDRIs, textures, and models like rocks and vegetation from Poly Haven" [Demo](https://www.youtube.com/watch?v=I29rn92gkC4)
- Give a reference image, and create a Blender scene out of it [Demo](https://www.youtube.com/watch?v=FDRb03XPiRo)
- "Generate a 3D model of a garden gnome through Hyper3D"
- "Get information about the current scene, and make a threejs sketch from it" [Demo](https://www.youtube.com/watch?v=jxbNI5L7AH8)
- "Make this car red and metallic"
- "Create a sphere and place it above the cube"
- "Make the lighting like a studio"
- "Point the camera at the scene, and make it isometric"
## Hyper3D integration
Hyper3D's free trial key allows you to generate a limited number of models per day. If the daily limit is reached, you can wait for the next day's reset or obtain your own key from hyper3d.ai and fal.ai.
## Troubleshooting
- **Connection issues**: Make sure the Blender addon server is running, and the MCP server is configured on Claude, DO NOT run the uvx command in the terminal. Sometimes, the first command won't go through but after that it starts working.
- **Timeout errors**: Try simplifying your requests or breaking them into smaller steps
- **Poly Haven integration**: Claude is sometimes erratic with its behaviour
- **Have you tried turning it off and on again?**: If you're still having connection errors, try restarting both Claude and the Blender server
## Technical Details
### Communication Protocol
The system uses a simple JSON-based protocol over TCP sockets:
- **Commands** are sent as JSON objects with a `type` and optional `params`
- **Responses** are JSON objects with a `status` and `result` or `message`
## Limitations & Security Considerations
- The `execute_blender_code` tool allows running arbitrary Python code in Blender, which can be powerful but potentially dangerous. Use with caution in production environments. ALWAYS save your work before using it.
- Poly Haven requires downloading models, textures, and HDRI images. If you do not want to use it, please turn it off in the checkbox in Blender.
- Complex operations might need to be broken down into smaller steps
#### Telemetry Control
BlenderMCP collects anonymous usage data to help improve the tool. You can control telemetry in two ways:
1. **In Blender**: Go to Edit > Preferences > Add-ons > Blender MCP and uncheck the telemetry consent checkbox
- With consent (checked): Collects anonymized prompts, code snippets, and screenshots
- Without consent (unchecked): Only collects minimal anonymous usage data (tool names, success/failure, duration)
2. **Environment Variable**: Completely disable all telemetry by running:
```bash
DISABLE_TELEMETRY=true uvx blender-mcp
```
Or add it to your MCP config:
```json
{
"mcpServers": {
"blender": {
"command": "uvx",
"args": ["blender-mcp"],
"env": {
"DISABLE_TELEMETRY": "true"
}
}
}
}
```
All telemetry data is fully anonymized and used solely to improve BlenderMCP.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## Disclaimer
This is a third-party integration and not made by Blender. Made by [Siddharth](https://x.com/sidahuj)
================================================
FILE: TERMS_AND_CONDITIONS.md
================================================
# Blender MCP - Terms of Use and Privacy Policy
**Last Updated: January 2025**
---
## 1. About This Project
Blender MCP is a free, open-source project maintained by Siddharth Ahuja ("I," "me," "my"). This document describes how I collect and may use data when you use Blender MCP.
By using Blender MCP, you agree to these terms. If you do not agree, please do not use the software.
---
## 2. Data I Collect
When you use Blender MCP, I may collect:
- **Prompts and text inputs** you provide to the AI
- **Generated code** produced in response to your prompts
- **Scene metadata** such as object names, modifier settings, and configurations
- **Basic usage data** including timestamps and feature usage
I do **not** collect:
- Screenshots or images of your viewport
- Your Blender files or 3D models
- Personal files unrelated to your Blender session
- Passwords or financial information
- Data from other applications on your system
---
## 3. How I May Use Your Data
I am currently collecting data for potential future use. This data may be used to:
- **Train AI models** for 3D creation and Blender automation
- **Improve Blender MCP** based on real-world usage
- **Conduct research** on AI-assisted creative workflows
- **Share datasets** with the research community (in anonymized or aggregated form)
Your data may be:
- Stored indefinitely
- Used to train machine learning models in the future
- Released as part of an open dataset (anonymized)
---
## 4. Data Sharing
I may share collected data with:
- **The open-source/research community** as part of public datasets
- **Collaborators** working on AI or Blender-related research
- **Legal authorities** if required by law
I do not sell your data.
---
## 5. Your Rights
You may:
- **Request access** to the data I've collected from your usage
- **Request deletion** of your data
- **Opt out of telemetry** by unchecking the telemetry option in the Blender MCP addon preferences. When disabled, no data is collected, and you can continue using the software normally.
To exercise these rights, contact me at ahujasid@gmail.com.
**Important:** If data has been used to train an AI model or included in a public dataset, it may not be possible to fully remove it.
---
## 6. Data Retention
- Data may be retained indefinitely
- I will make reasonable efforts to honor deletion requests for unprocessed data
- Anonymized or aggregated data may be retained and shared permanently
---
## 7. Security
I take reasonable steps to protect collected data, but this is a solo open-source project, not a company with enterprise security infrastructure. I cannot guarantee absolute security.
---
## 8. Children
Blender MCP is not intended for users under 16. I do not knowingly collect data from children.
---
## 9. International Users
Your data may be stored and processed in any country. By using Blender MCP, you consent to international data transfers.
---
## 10. Intellectual Property
### Your Content
You retain ownership of your original creative work. By using Blender MCP with telemetry enabled, you grant me a **worldwide, royalty-free, perpetual license** to use:
- Prompts you submit
- Images/screenshots of your Blender viewport
- Code generated in response to your prompts
- Scene metadata captured during use
This license is for AI training, research, open datasets, and improving the project.
**Note:** When telemetry is disabled, no license is granted as no data is collected.
### AI-Generated Content
You may use AI-generated code however you like, but it's provided "as is" with no guarantees.
### Blender MCP
The Blender MCP source code is open source under its stated license. These terms apply only to data collection.
---
## 11. No Warranty
BLENDER MCP IS PROVIDED "AS IS" WITHOUT ANY WARRANTIES.
I do not guarantee that:
- The software will work correctly
- AI-generated code will be safe or functional
- Your data will be secure
**You are responsible for reviewing any AI-generated code before using it.**
---
## 12. Limitation of Liability
TO THE MAXIMUM EXTENT PERMITTED BY LAW, I AM NOT LIABLE FOR ANY DAMAGES ARISING FROM YOUR USE OF BLENDER MCP.
This is a free, open-source project maintained in my spare time. Use at your own risk.
---
## 13. Changes
I may update these terms at any time. Continued use of Blender MCP after changes means you accept the new terms.
---
## 14. Contact
Questions or requests? Email me at ahujasid@gmail.com.
---
## 15. Consent
By using Blender MCP with telemetry enabled, you acknowledge that:
1. You have read and understood these terms
2. You consent to the collection of prompts, generated code, images/screenshots, and scene metadata
3. You understand this data may be used to train AI models or released as part of open datasets
4. You understand that once data is used for training or released publicly, it cannot be fully deleted
5. You are at least 16 years old
6. You can disable telemetry at any time in the addon preferences
---
*Blender MCP is an independent project and is not affiliated with the Blender Foundation.*
================================================
FILE: addon.py
================================================
# Code created by Siddharth Ahuja: www.github.com/ahujasid © 2025
import re
import bpy
import mathutils
import json
import threading
import socket
import time
import requests
import tempfile
import traceback
import os
import shutil
import zipfile
from bpy.props import IntProperty, BoolProperty
import io
from datetime import datetime
import hashlib, hmac, base64
import os.path as osp
from contextlib import redirect_stdout, suppress
bl_info = {
"name": "Blender MCP",
"author": "BlenderMCP",
"version": (1, 2),
"blender": (3, 0, 0),
"location": "View3D > Sidebar > BlenderMCP",
"description": "Connect Blender to Claude via MCP",
"category": "Interface",
}
RODIN_FREE_TRIAL_KEY = "k9TcfFoEhNd9cCPP2guHAHHHkctZHIRhZDywZ1euGUXwihbYLpOjQhofby80NJez"
# Add User-Agent as required by Poly Haven API
REQ_HEADERS = requests.utils.default_headers()
REQ_HEADERS.update({"User-Agent": "blender-mcp"})
class BlenderMCPServer:
def __init__(self, host='localhost', port=9876):
self.host = host
self.port = port
self.running = False
self.socket = None
self.server_thread = None
def start(self):
if self.running:
print("Server is already running")
return
self.running = True
try:
# Create socket
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.socket.bind((self.host, self.port))
self.socket.listen(1)
# Start server thread
self.server_thread = threading.Thread(target=self._server_loop)
self.server_thread.daemon = True
self.server_thread.start()
print(f"BlenderMCP server started on {self.host}:{self.port}")
except Exception as e:
print(f"Failed to start server: {str(e)}")
self.stop()
def stop(self):
self.running = False
# Close socket
if self.socket:
try:
self.socket.close()
except:
pass
self.socket = None
# Wait for thread to finish
if self.server_thread:
try:
if self.server_thread.is_alive():
self.server_thread.join(timeout=1.0)
except:
pass
self.server_thread = None
print("BlenderMCP server stopped")
def _server_loop(self):
"""Main server loop in a separate thread"""
print("Server thread started")
self.socket.settimeout(1.0) # Timeout to allow for stopping
while self.running:
try:
# Accept new connection
try:
client, address = self.socket.accept()
print(f"Connected to client: {address}")
# Handle client in a separate thread
client_thread = threading.Thread(
target=self._handle_client,
args=(client,)
)
client_thread.daemon = True
client_thread.start()
except socket.timeout:
# Just check running condition
continue
except Exception as e:
print(f"Error accepting connection: {str(e)}")
time.sleep(0.5)
except Exception as e:
print(f"Error in server loop: {str(e)}")
if not self.running:
break
time.sleep(0.5)
print("Server thread stopped")
def _handle_client(self, client):
"""Handle connected client"""
print("Client handler started")
client.settimeout(None) # No timeout
buffer = b''
try:
while self.running:
# Receive data
try:
data = client.recv(8192)
if not data:
print("Client disconnected")
break
buffer += data
try:
# Try to parse command
command = json.loads(buffer.decode('utf-8'))
buffer = b''
# Execute command in Blender's main thread
def execute_wrapper():
try:
response = self.execute_command(command)
response_json = json.dumps(response)
try:
client.sendall(response_json.encode('utf-8'))
except:
print("Failed to send response - client disconnected")
except Exception as e:
print(f"Error executing command: {str(e)}")
traceback.print_exc()
try:
error_response = {
"status": "error",
"message": str(e)
}
client.sendall(json.dumps(error_response).encode('utf-8'))
except:
pass
return None
# Schedule execution in main thread
bpy.app.timers.register(execute_wrapper, first_interval=0.0)
except json.JSONDecodeError:
# Incomplete data, wait for more
pass
except Exception as e:
print(f"Error receiving data: {str(e)}")
break
except Exception as e:
print(f"Error in client handler: {str(e)}")
finally:
try:
client.close()
except:
pass
print("Client handler stopped")
def execute_command(self, command):
"""Execute a command in the main Blender thread"""
try:
return self._execute_command_internal(command)
except Exception as e:
print(f"Error executing command: {str(e)}")
traceback.print_exc()
return {"status": "error", "message": str(e)}
def _execute_command_internal(self, command):
"""Internal command execution with proper context"""
cmd_type = command.get("type")
params = command.get("params", {})
# Add a handler for checking PolyHaven status
if cmd_type == "get_polyhaven_status":
return {"status": "success", "result": self.get_polyhaven_status()}
# Base handlers that are always available
handlers = {
"get_scene_info": self.get_scene_info,
"get_object_info": self.get_object_info,
"get_viewport_screenshot": self.get_viewport_screenshot,
"execute_code": self.execute_code,
"get_telemetry_consent": self.get_telemetry_consent,
"get_polyhaven_status": self.get_polyhaven_status,
"get_hyper3d_status": self.get_hyper3d_status,
"get_sketchfab_status": self.get_sketchfab_status,
"get_hunyuan3d_status": self.get_hunyuan3d_status,
}
# Add Polyhaven handlers only if enabled
if bpy.context.scene.blendermcp_use_polyhaven:
polyhaven_handlers = {
"get_polyhaven_categories": self.get_polyhaven_categories,
"search_polyhaven_assets": self.search_polyhaven_assets,
"download_polyhaven_asset": self.download_polyhaven_asset,
"set_texture": self.set_texture,
}
handlers.update(polyhaven_handlers)
# Add Hyper3d handlers only if enabled
if bpy.context.scene.blendermcp_use_hyper3d:
polyhaven_handlers = {
"create_rodin_job": self.create_rodin_job,
"poll_rodin_job_status": self.poll_rodin_job_status,
"import_generated_asset": self.import_generated_asset,
}
handlers.update(polyhaven_handlers)
# Add Sketchfab handlers only if enabled
if bpy.context.scene.blendermcp_use_sketchfab:
sketchfab_handlers = {
"search_sketchfab_models": self.search_sketchfab_models,
"get_sketchfab_model_preview": self.get_sketchfab_model_preview,
"download_sketchfab_model": self.download_sketchfab_model,
}
handlers.update(sketchfab_handlers)
# Add Hunyuan3d handlers only if enabled
if bpy.context.scene.blendermcp_use_hunyuan3d:
hunyuan_handlers = {
"create_hunyuan_job": self.create_hunyuan_job,
"poll_hunyuan_job_status": self.poll_hunyuan_job_status,
"import_generated_asset_hunyuan": self.import_generated_asset_hunyuan
}
handlers.update(hunyuan_handlers)
handler = handlers.get(cmd_type)
if handler:
try:
print(f"Executing handler for {cmd_type}")
result = handler(**params)
print(f"Handler execution complete")
return {"status": "success", "result": result}
except Exception as e:
print(f"Error in handler: {str(e)}")
traceback.print_exc()
return {"status": "error", "message": str(e)}
else:
return {"status": "error", "message": f"Unknown command type: {cmd_type}"}
def get_scene_info(self):
"""Get information about the current Blender scene"""
try:
print("Getting scene info...")
# Simplify the scene info to reduce data size
scene_info = {
"name": bpy.context.scene.name,
"object_count": len(bpy.context.scene.objects),
"objects": [],
"materials_count": len(bpy.data.materials),
}
# Collect minimal object information (limit to first 10 objects)
for i, obj in enumerate(bpy.context.scene.objects):
if i >= 10: # Reduced from 20 to 10
break
obj_info = {
"name": obj.name,
"type": obj.type,
# Only include basic location data
"location": [round(float(obj.location.x), 2),
round(float(obj.location.y), 2),
round(float(obj.location.z), 2)],
}
scene_info["objects"].append(obj_info)
print(f"Scene info collected: {len(scene_info['objects'])} objects")
return scene_info
except Exception as e:
print(f"Error in get_scene_info: {str(e)}")
traceback.print_exc()
return {"error": str(e)}
@staticmethod
def _get_aabb(obj):
""" Returns the world-space axis-aligned bounding box (AABB) of an object. """
if obj.type != 'MESH':
raise TypeError("Object must be a mesh")
# Get the bounding box corners in local space
local_bbox_corners = [mathutils.Vector(corner) for corner in obj.bound_box]
# Convert to world coordinates
world_bbox_corners = [obj.matrix_world @ corner for corner in local_bbox_corners]
# Compute axis-aligned min/max coordinates
min_corner = mathutils.Vector(map(min, zip(*world_bbox_corners)))
max_corner = mathutils.Vector(map(max, zip(*world_bbox_corners)))
return [
[*min_corner], [*max_corner]
]
def get_object_info(self, name):
"""Get detailed information about a specific object"""
obj = bpy.data.objects.get(name)
if not obj:
raise ValueError(f"Object not found: {name}")
# Basic object info
obj_info = {
"name": obj.name,
"type": obj.type,
"location": [obj.location.x, obj.location.y, obj.location.z],
"rotation": [obj.rotation_euler.x, obj.rotation_euler.y, obj.rotation_euler.z],
"scale": [obj.scale.x, obj.scale.y, obj.scale.z],
"visible": obj.visible_get(),
"materials": [],
}
if obj.type == "MESH":
bounding_box = self._get_aabb(obj)
obj_info["world_bounding_box"] = bounding_box
# Add material slots
for slot in obj.material_slots:
if slot.material:
obj_info["materials"].append(slot.material.name)
# Add mesh data if applicable
if obj.type == 'MESH' and obj.data:
mesh = obj.data
obj_info["mesh"] = {
"vertices": len(mesh.vertices),
"edges": len(mesh.edges),
"polygons": len(mesh.polygons),
}
return obj_info
def get_viewport_screenshot(self, max_size=800, filepath=None, format="png"):
"""
Capture a screenshot of the current 3D viewport and save it to the specified path.
Parameters:
- max_size: Maximum size in pixels for the largest dimension of the image
- filepath: Path where to save the screenshot file
- format: Image format (png, jpg, etc.)
Returns success/error status
"""
try:
if not filepath:
return {"error": "No filepath provided"}
# Find the active 3D viewport
area = None
for a in bpy.context.screen.areas:
if a.type == 'VIEW_3D':
area = a
break
if not area:
return {"error": "No 3D viewport found"}
# Take screenshot with proper context override
with bpy.context.temp_override(area=area):
bpy.ops.screen.screenshot_area(filepath=filepath)
# Load and resize if needed
img = bpy.data.images.load(filepath)
width, height = img.size
if max(width, height) > max_size:
scale = max_size / max(width, height)
new_width = int(width * scale)
new_height = int(height * scale)
img.scale(new_width, new_height)
# Set format and save
img.file_format = format.upper()
img.save()
width, height = new_width, new_height
# Cleanup Blender image data
bpy.data.images.remove(img)
return {
"success": True,
"width": width,
"height": height,
"filepath": filepath
}
except Exception as e:
return {"error": str(e)}
def execute_code(self, code):
"""Execute arbitrary Blender Python code"""
# This is powerful but potentially dangerous - use with caution
try:
# Create a local namespace for execution
namespace = {"bpy": bpy}
# Capture stdout during execution, and return it as result
capture_buffer = io.StringIO()
with redirect_stdout(capture_buffer):
exec(code, namespace)
captured_output = capture_buffer.getvalue()
return {"executed": True, "result": captured_output}
except Exception as e:
raise Exception(f"Code execution error: {str(e)}")
def get_polyhaven_categories(self, asset_type):
"""Get categories for a specific asset type from Polyhaven"""
try:
if asset_type not in ["hdris", "textures", "models", "all"]:
return {"error": f"Invalid asset type: {asset_type}. Must be one of: hdris, textures, models, all"}
response = requests.get(f"https://api.polyhaven.com/categories/{asset_type}", headers=REQ_HEADERS)
if response.status_code == 200:
return {"categories": response.json()}
else:
return {"error": f"API request failed with status code {response.status_code}"}
except Exception as e:
return {"error": str(e)}
def search_polyhaven_assets(self, asset_type=None, categories=None):
"""Search for assets from Polyhaven with optional filtering"""
try:
url = "https://api.polyhaven.com/assets"
params = {}
if asset_type and asset_type != "all":
if asset_type not in ["hdris", "textures", "models"]:
return {"error": f"Invalid asset type: {asset_type}. Must be one of: hdris, textures, models, all"}
params["type"] = asset_type
if categories:
params["categories"] = categories
response = requests.get(url, params=params, headers=REQ_HEADERS)
if response.status_code == 200:
# Limit the response size to avoid overwhelming Blender
assets = response.json()
# Return only the first 20 assets to keep response size manageable
limited_assets = {}
for i, (key, value) in enumerate(assets.items()):
if i >= 20: # Limit to 20 assets
break
limited_assets[key] = value
return {"assets": limited_assets, "total_count": len(assets), "returned_count": len(limited_assets)}
else:
return {"error": f"API request failed with status code {response.status_code}"}
except Exception as e:
return {"error": str(e)}
def download_polyhaven_asset(self, asset_id, asset_type, resolution="1k", file_format=None):
try:
# First get the files information
files_response = requests.get(f"https://api.polyhaven.com/files/{asset_id}", headers=REQ_HEADERS)
if files_response.status_code != 200:
return {"error": f"Failed to get asset files: {files_response.status_code}"}
files_data = files_response.json()
# Handle different asset types
if asset_type == "hdris":
# For HDRIs, download the .hdr or .exr file
if not file_format:
file_format = "hdr" # Default format for HDRIs
if "hdri" in files_data and resolution in files_data["hdri"] and file_format in files_data["hdri"][resolution]:
file_info = files_data["hdri"][resolution][file_format]
file_url = file_info["url"]
# For HDRIs, we need to save to a temporary file first
# since Blender can't properly load HDR data directly from memory
with tempfile.NamedTemporaryFile(suffix=f".{file_format}", delete=False) as tmp_file:
# Download the file
response = requests.get(file_url, headers=REQ_HEADERS)
if response.status_code != 200:
return {"error": f"Failed to download HDRI: {response.status_code}"}
tmp_file.write(response.content)
tmp_path = tmp_file.name
try:
# Create a new world if none exists
if not bpy.data.worlds:
bpy.data.worlds.new("World")
world = bpy.data.worlds[0]
world.use_nodes = True
node_tree = world.node_tree
# Clear existing nodes
for node in node_tree.nodes:
node_tree.nodes.remove(node)
# Create nodes
tex_coord = node_tree.nodes.new(type='ShaderNodeTexCoord')
tex_coord.location = (-800, 0)
mapping = node_tree.nodes.new(type='ShaderNodeMapping')
mapping.location = (-600, 0)
# Load the image from the temporary file
env_tex = node_tree.nodes.new(type='ShaderNodeTexEnvironment')
env_tex.location = (-400, 0)
env_tex.image = bpy.data.images.load(tmp_path)
# Use a color space that exists in all Blender versions
if file_format.lower() == 'exr':
# Try to use Linear color space for EXR files
try:
env_tex.image.colorspace_settings.name = 'Linear'
except:
# Fallback to Non-Color if Linear isn't available
env_tex.image.colorspace_settings.name = 'Non-Color'
else: # hdr
# For HDR files, try these options in order
for color_space in ['Linear', 'Linear Rec.709', 'Non-Color']:
try:
env_tex.image.colorspace_settings.name = color_space
break # Stop if we successfully set a color space
except:
continue
background = node_tree.nodes.new(type='ShaderNodeBackground')
background.location = (-200, 0)
output = node_tree.nodes.new(type='ShaderNodeOutputWorld')
output.location = (0, 0)
# Connect nodes
node_tree.links.new(tex_coord.outputs['Generated'], mapping.inputs['Vector'])
node_tree.links.new(mapping.outputs['Vector'], env_tex.inputs['Vector'])
node_tree.links.new(env_tex.outputs['Color'], background.inputs['Color'])
node_tree.links.new(background.outputs['Background'], output.inputs['Surface'])
# Set as active world
bpy.context.scene.world = world
# Clean up temporary file
try:
tempfile._cleanup() # This will clean up all temporary files
except:
pass
return {
"success": True,
"message": f"HDRI {asset_id} imported successfully",
"image_name": env_tex.image.name
}
except Exception as e:
return {"error": f"Failed to set up HDRI in Blender: {str(e)}"}
else:
return {"error": f"Requested resolution or format not available for this HDRI"}
elif asset_type == "textures":
if not file_format:
file_format = "jpg" # Default format for textures
downloaded_maps = {}
try:
for map_type in files_data:
if map_type not in ["blend", "gltf"]: # Skip non-texture files
if resolution in files_data[map_type] and file_format in files_data[map_type][resolution]:
file_info = files_data[map_type][resolution][file_format]
file_url = file_info["url"]
# Use NamedTemporaryFile like we do for HDRIs
with tempfile.NamedTemporaryFile(suffix=f".{file_format}", delete=False) as tmp_file:
# Download the file
response = requests.get(file_url, headers=REQ_HEADERS)
if response.status_code == 200:
tmp_file.write(response.content)
tmp_path = tmp_file.name
# Load image from temporary file
image = bpy.data.images.load(tmp_path)
image.name = f"{asset_id}_{map_type}.{file_format}"
# Pack the image into .blend file
image.pack()
# Set color space based on map type
if map_type in ['color', 'diffuse', 'albedo']:
try:
image.colorspace_settings.name = 'sRGB'
except:
pass
else:
try:
image.colorspace_settings.name = 'Non-Color'
except:
pass
downloaded_maps[map_type] = image
# Clean up temporary file
try:
os.unlink(tmp_path)
except:
pass
if not downloaded_maps:
return {"error": f"No texture maps found for the requested resolution and format"}
# Create a new material with the downloaded textures
mat = bpy.data.materials.new(name=asset_id)
mat.use_nodes = True
nodes = mat.node_tree.nodes
links = mat.node_tree.links
# Clear default nodes
for node in nodes:
nodes.remove(node)
# Create output node
output = nodes.new(type='ShaderNodeOutputMaterial')
output.location = (300, 0)
# Create principled BSDF node
principled = nodes.new(type='ShaderNodeBsdfPrincipled')
principled.location = (0, 0)
links.new(principled.outputs[0], output.inputs[0])
# Add texture nodes based on available maps
tex_coord = nodes.new(type='ShaderNodeTexCoord')
tex_coord.location = (-800, 0)
mapping = nodes.new(type='ShaderNodeMapping')
mapping.location = (-600, 0)
mapping.vector_type = 'TEXTURE' # Changed from default 'POINT' to 'TEXTURE'
links.new(tex_coord.outputs['UV'], mapping.inputs['Vector'])
# Position offset for texture nodes
x_pos = -400
y_pos = 300
# Connect different texture maps
for map_type, image in downloaded_maps.items():
tex_node = nodes.new(type='ShaderNodeTexImage')
tex_node.location = (x_pos, y_pos)
tex_node.image = image
# Set color space based on map type
if map_type.lower() in ['color', 'diffuse', 'albedo']:
try:
tex_node.image.colorspace_settings.name = 'sRGB'
except:
pass # Use default if sRGB not available
else:
try:
tex_node.image.colorspace_settings.name = 'Non-Color'
except:
pass # Use default if Non-Color not available
links.new(mapping.outputs['Vector'], tex_node.inputs['Vector'])
# Connect to appropriate input on Principled BSDF
if map_type.lower() in ['color', 'diffuse', 'albedo']:
links.new(tex_node.outputs['Color'], principled.inputs['Base Color'])
elif map_type.lower() in ['roughness', 'rough']:
links.new(tex_node.outputs['Color'], principled.inputs['Roughness'])
elif map_type.lower() in ['metallic', 'metalness', 'metal']:
links.new(tex_node.outputs['Color'], principled.inputs['Metallic'])
elif map_type.lower() in ['normal', 'nor']:
# Add normal map node
normal_map = nodes.new(type='ShaderNodeNormalMap')
normal_map.location = (x_pos + 200, y_pos)
links.new(tex_node.outputs['Color'], normal_map.inputs['Color'])
links.new(normal_map.outputs['Normal'], principled.inputs['Normal'])
elif map_type in ['displacement', 'disp', 'height']:
# Add displacement node
disp_node = nodes.new(type='ShaderNodeDisplacement')
disp_node.location = (x_pos + 200, y_pos - 200)
links.new(tex_node.outputs['Color'], disp_node.inputs['Height'])
links.new(disp_node.outputs['Displacement'], output.inputs['Displacement'])
y_pos -= 250
return {
"success": True,
"message": f"Texture {asset_id} imported as material",
"material": mat.name,
"maps": list(downloaded_maps.keys())
}
except Exception as e:
return {"error": f"Failed to process textures: {str(e)}"}
elif asset_type == "models":
# For models, prefer glTF format if available
if not file_format:
file_format = "gltf" # Default format for models
if file_format in files_data and resolution in files_data[file_format]:
file_info = files_data[file_format][resolution][file_format]
file_url = file_info["url"]
# Create a temporary directory to store the model and its dependencies
temp_dir = tempfile.mkdtemp()
main_file_path = ""
try:
# Download the main model file
main_file_name = file_url.split("/")[-1]
main_file_path = os.path.join(temp_dir, main_file_name)
response = requests.get(file_url, headers=REQ_HEADERS)
if response.status_code != 200:
return {"error": f"Failed to download model: {response.status_code}"}
with open(main_file_path, "wb") as f:
f.write(response.content)
# Check for included files and download them
if "include" in file_info and file_info["include"]:
for include_path, include_info in file_info["include"].items():
# Get the URL for the included file - this is the fix
include_url = include_info["url"]
# Create the directory structure for the included file
include_file_path = os.path.join(temp_dir, include_path)
os.makedirs(os.path.dirname(include_file_path), exist_ok=True)
# Download the included file
include_response = requests.get(include_url, headers=REQ_HEADERS)
if include_response.status_code == 200:
with open(include_file_path, "wb") as f:
f.write(include_response.content)
else:
print(f"Failed to download included file: {include_path}")
# Import the model into Blender
if file_format == "gltf" or file_format == "glb":
bpy.ops.import_scene.gltf(filepath=main_file_path)
elif file_format == "fbx":
bpy.ops.import_scene.fbx(filepath=main_file_path)
elif file_format == "obj":
bpy.ops.import_scene.obj(filepath=main_file_path)
elif file_format == "blend":
# For blend files, we need to append or link
with bpy.data.libraries.load(main_file_path, link=False) as (data_from, data_to):
data_to.objects = data_from.objects
# Link the objects to the scene
for obj in data_to.objects:
if obj is not None:
bpy.context.collection.objects.link(obj)
else:
return {"error": f"Unsupported model format: {file_format}"}
# Get the names of imported objects
imported_objects = [obj.name for obj in bpy.context.selected_objects]
return {
"success": True,
"message": f"Model {asset_id} imported successfully",
"imported_objects": imported_objects
}
except Exception as e:
return {"error": f"Failed to import model: {str(e)}"}
finally:
# Clean up temporary directory
with suppress(Exception):
shutil.rmtree(temp_dir)
else:
return {"error": f"Requested format or resolution not available for this model"}
else:
return {"error": f"Unsupported asset type: {asset_type}"}
except Exception as e:
return {"error": f"Failed to download asset: {str(e)}"}
def set_texture(self, object_name, texture_id):
"""Apply a previously downloaded Polyhaven texture to an object by creating a new material"""
try:
# Get the object
obj = bpy.data.objects.get(object_name)
if not obj:
return {"error": f"Object not found: {object_name}"}
# Make sure object can accept materials
if not hasattr(obj, 'data') or not hasattr(obj.data, 'materials'):
return {"error": f"Object {object_name} cannot accept materials"}
# Find all images related to this texture and ensure they're properly loaded
texture_images = {}
for img in bpy.data.images:
if img.name.startswith(texture_id + "_"):
# Extract the map type from the image name
map_type = img.name.split('_')[-1].split('.')[0]
# Force a reload of the image
img.reload()
# Ensure proper color space
if map_type.lower() in ['color', 'diffuse', 'albedo']:
try:
img.colorspace_settings.name = 'sRGB'
except:
pass
else:
try:
img.colorspace_settings.name = 'Non-Color'
except:
pass
# Ensure the image is packed
if not img.packed_file:
img.pack()
texture_images[map_type] = img
print(f"Loaded texture map: {map_type} - {img.name}")
# Debug info
print(f"Image size: {img.size[0]}x{img.size[1]}")
print(f"Color space: {img.colorspace_settings.name}")
print(f"File format: {img.file_format}")
print(f"Is packed: {bool(img.packed_file)}")
if not texture_images:
return {"error": f"No texture images found for: {texture_id}. Please download the texture first."}
# Create a new material
new_mat_name = f"{texture_id}_material_{object_name}"
# Remove any existing material with this name to avoid conflicts
existing_mat = bpy.data.materials.get(new_mat_name)
if existing_mat:
bpy.data.materials.remove(existing_mat)
new_mat = bpy.data.materials.new(name=new_mat_name)
new_mat.use_nodes = True
# Set up the material nodes
nodes = new_mat.node_tree.nodes
links = new_mat.node_tree.links
# Clear default nodes
nodes.clear()
# Create output node
output = nodes.new(type='ShaderNodeOutputMaterial')
output.location = (600, 0)
# Create principled BSDF node
principled = nodes.new(type='ShaderNodeBsdfPrincipled')
principled.location = (300, 0)
links.new(principled.outputs[0], output.inputs[0])
# Add texture nodes based on available maps
tex_coord = nodes.new(type='ShaderNodeTexCoord')
tex_coord.location = (-800, 0)
mapping = nodes.new(type='ShaderNodeMapping')
mapping.location = (-600, 0)
mapping.vector_type = 'TEXTURE' # Changed from default 'POINT' to 'TEXTURE'
links.new(tex_coord.outputs['UV'], mapping.inputs['Vector'])
# Position offset for texture nodes
x_pos = -400
y_pos = 300
# Connect different texture maps
for map_type, image in texture_images.items():
tex_node = nodes.new(type='ShaderNodeTexImage')
tex_node.location = (x_pos, y_pos)
tex_node.image = image
# Set color space based on map type
if map_type.lower() in ['color', 'diffuse', 'albedo']:
try:
tex_node.image.colorspace_settings.name = 'sRGB'
except:
pass # Use default if sRGB not available
else:
try:
tex_node.image.colorspace_settings.name = 'Non-Color'
except:
pass # Use default if Non-Color not available
links.new(mapping.outputs['Vector'], tex_node.inputs['Vector'])
# Connect to appropriate input on Principled BSDF
if map_type.lower() in ['color', 'diffuse', 'albedo']:
links.new(tex_node.outputs['Color'], principled.inputs['Base Color'])
elif map_type.lower() in ['roughness', 'rough']:
links.new(tex_node.outputs['Color'], principled.inputs['Roughness'])
elif map_type.lower() in ['metallic', 'metalness', 'metal']:
links.new(tex_node.outputs['Color'], principled.inputs['Metallic'])
elif map_type.lower() in ['normal', 'nor', 'dx', 'gl']:
# Add normal map node
normal_map = nodes.new(type='ShaderNodeNormalMap')
normal_map.location = (x_pos + 200, y_pos)
links.new(tex_node.outputs['Color'], normal_map.inputs['Color'])
links.new(normal_map.outputs['Normal'], principled.inputs['Normal'])
elif map_type.lower() in ['displacement', 'disp', 'height']:
# Add displacement node
disp_node = nodes.new(type='ShaderNodeDisplacement')
disp_node.location = (x_pos + 200, y_pos - 200)
disp_node.inputs['Scale'].default_value = 0.1 # Reduce displacement strength
links.new(tex_node.outputs['Color'], disp_node.inputs['Height'])
links.new(disp_node.outputs['Displacement'], output.inputs['Displacement'])
y_pos -= 250
# Second pass: Connect nodes with proper handling for special cases
texture_nodes = {}
# First find all texture nodes and store them by map type
for node in nodes:
if node.type == 'TEX_IMAGE' and node.image:
for map_type, image in texture_images.items():
if node.image == image:
texture_nodes[map_type] = node
break
# Now connect everything using the nodes instead of images
# Handle base color (diffuse)
for map_name in ['color', 'diffuse', 'albedo']:
if map_name in texture_nodes:
links.new(texture_nodes[map_name].outputs['Color'], principled.inputs['Base Color'])
print(f"Connected {map_name} to Base Color")
break
# Handle roughness
for map_name in ['roughness', 'rough']:
if map_name in texture_nodes:
links.new(texture_nodes[map_name].outputs['Color'], principled.inputs['Roughness'])
print(f"Connected {map_name} to Roughness")
break
# Handle metallic
for map_name in ['metallic', 'metalness', 'metal']:
if map_name in texture_nodes:
links.new(texture_nodes[map_name].outputs['Color'], principled.inputs['Metallic'])
print(f"Connected {map_name} to Metallic")
break
# Handle normal maps
for map_name in ['gl', 'dx', 'nor']:
if map_name in texture_nodes:
normal_map_node = nodes.new(type='ShaderNodeNormalMap')
normal_map_node.location = (100, 100)
links.new(texture_nodes[map_name].outputs['Color'], normal_map_node.inputs['Color'])
links.new(normal_map_node.outputs['Normal'], principled.inputs['Normal'])
print(f"Connected {map_name} to Normal")
break
# Handle displacement
for map_name in ['displacement', 'disp', 'height']:
if map_name in texture_nodes:
disp_node = nodes.new(type='ShaderNodeDisplacement')
disp_node.location = (300, -200)
disp_node.inputs['Scale'].default_value = 0.1 # Reduce displacement strength
links.new(texture_nodes[map_name].outputs['Color'], disp_node.inputs['Height'])
links.new(disp_node.outputs['Displacement'], output.inputs['Displacement'])
print(f"Connected {map_name} to Displacement")
break
# Handle ARM texture (Ambient Occlusion, Roughness, Metallic)
if 'arm' in texture_nodes:
separate_rgb = nodes.new(type='ShaderNodeSeparateRGB')
separate_rgb.location = (-200, -100)
links.new(texture_nodes['arm'].outputs['Color'], separate_rgb.inputs['Image'])
# Connect Roughness (G) if no dedicated roughness map
if not any(map_name in texture_nodes for map_name in ['roughness', 'rough']):
links.new(separate_rgb.outputs['G'], principled.inputs['Roughness'])
print("Connected ARM.G to Roughness")
# Connect Metallic (B) if no dedicated metallic map
if not any(map_name in texture_nodes for map_name in ['metallic', 'metalness', 'metal']):
links.new(separate_rgb.outputs['B'], principled.inputs['Metallic'])
print("Connected ARM.B to Metallic")
# For AO (R channel), multiply with base color if we have one
base_color_node = None
for map_name in ['color', 'diffuse', 'albedo']:
if map_name in texture_nodes:
base_color_node = texture_nodes[map_name]
break
if base_color_node:
mix_node = nodes.new(type='ShaderNodeMixRGB')
mix_node.location = (100, 200)
mix_node.blend_type = 'MULTIPLY'
mix_node.inputs['Fac'].default_value = 0.8 # 80% influence
# Disconnect direct connection to base color
for link in base_color_node.outputs['Color'].links:
if link.to_socket == principled.inputs['Base Color']:
links.remove(link)
# Connect through the mix node
links.new(base_color_node.outputs['Color'], mix_node.inputs[1])
links.new(separate_rgb.outputs['R'], mix_node.inputs[2])
links.new(mix_node.outputs['Color'], principled.inputs['Base Color'])
print("Connected ARM.R to AO mix with Base Color")
# Handle AO (Ambient Occlusion) if separate
if 'ao' in texture_nodes:
base_color_node = None
for map_name in ['color', 'diffuse', 'albedo']:
if map_name in texture_nodes:
base_color_node = texture_nodes[map_name]
break
if base_color_node:
mix_node = nodes.new(type='ShaderNodeMixRGB')
mix_node.location = (100, 200)
mix_node.blend_type = 'MULTIPLY'
mix_node.inputs['Fac'].default_value = 0.8 # 80% influence
# Disconnect direct connection to base color
for link in base_color_node.outputs['Color'].links:
if link.to_socket == principled.inputs['Base Color']:
links.remove(link)
# Connect through the mix node
links.new(base_color_node.outputs['Color'], mix_node.inputs[1])
links.new(texture_nodes['ao'].outputs['Color'], mix_node.inputs[2])
links.new(mix_node.outputs['Color'], principled.inputs['Base Color'])
print("Connected AO to mix with Base Color")
# CRITICAL: Make sure to clear all existing materials from the object
while len(obj.data.materials) > 0:
obj.data.materials.pop(index=0)
# Assign the new material to the object
obj.data.materials.append(new_mat)
# CRITICAL: Make the object active and select it
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
# CRITICAL: Force Blender to update the material
bpy.context.view_layer.update()
# Get the list of texture maps
texture_maps = list(texture_images.keys())
# Get info about texture nodes for debugging
material_info = {
"name": new_mat.name,
"has_nodes": new_mat.use_nodes,
"node_count": len(new_mat.node_tree.nodes),
"texture_nodes": []
}
for node in new_mat.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
connections = []
for output in node.outputs:
for link in output.links:
connections.append(f"{output.name} → {link.to_node.name}.{link.to_socket.name}")
material_info["texture_nodes"].append({
"name": node.name,
"image": node.image.name,
"colorspace": node.image.colorspace_settings.name,
"connections": connections
})
return {
"success": True,
"message": f"Created new material and applied texture {texture_id} to {object_name}",
"material": new_mat.name,
"maps": texture_maps,
"material_info": material_info
}
except Exception as e:
print(f"Error in set_texture: {str(e)}")
traceback.print_exc()
return {"error": f"Failed to apply texture: {str(e)}"}
def get_telemetry_consent(self):
"""Get the current telemetry consent status"""
try:
# Get addon preferences - use the module name
addon_prefs = bpy.context.preferences.addons.get(__name__)
if addon_prefs:
consent = addon_prefs.preferences.telemetry_consent
else:
# Fallback to default if preferences not available
consent = True
except (AttributeError, KeyError):
# Fallback to default if preferences not available
consent = True
return {"consent": consent}
def get_polyhaven_status(self):
"""Get the current status of PolyHaven integration"""
enabled = bpy.context.scene.blendermcp_use_polyhaven
if enabled:
return {"enabled": True, "message": "PolyHaven integration is enabled and ready to use."}
else:
return {
"enabled": False,
"message": """PolyHaven integration is currently disabled. To enable it:
1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden)
2. Check the 'Use assets from Poly Haven' checkbox
3. Restart the connection to Claude"""
}
#region Hyper3D
def get_hyper3d_status(self):
"""Get the current status of Hyper3D Rodin integration"""
enabled = bpy.context.scene.blendermcp_use_hyper3d
if enabled:
if not bpy.context.scene.blendermcp_hyper3d_api_key:
return {
"enabled": False,
"message": """Hyper3D Rodin integration is currently enabled, but API key is not given. To enable it:
1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden)
2. Keep the 'Use Hyper3D Rodin 3D model generation' checkbox checked
3. Choose the right plaform and fill in the API Key
4. Restart the connection to Claude"""
}
mode = bpy.context.scene.blendermcp_hyper3d_mode
message = f"Hyper3D Rodin integration is enabled and ready to use. Mode: {mode}. " + \
f"Key type: {'private' if bpy.context.scene.blendermcp_hyper3d_api_key != RODIN_FREE_TRIAL_KEY else 'free_trial'}"
return {
"enabled": True,
"message": message
}
else:
return {
"enabled": False,
"message": """Hyper3D Rodin integration is currently disabled. To enable it:
1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden)
2. Check the 'Use Hyper3D Rodin 3D model generation' checkbox
3. Restart the connection to Claude"""
}
def create_rodin_job(self, *args, **kwargs):
match bpy.context.scene.blendermcp_hyper3d_mode:
case "MAIN_SITE":
return self.create_rodin_job_main_site(*args, **kwargs)
case "FAL_AI":
return self.create_rodin_job_fal_ai(*args, **kwargs)
case _:
return f"Error: Unknown Hyper3D Rodin mode!"
def create_rodin_job_main_site(
self,
text_prompt: str=None,
images: list[tuple[str, str]]=None,
bbox_condition=None
):
try:
if images is None:
images = []
"""Call Rodin API, get the job uuid and subscription key"""
files = [
*[("images", (f"{i:04d}{img_suffix}", img)) for i, (img_suffix, img) in enumerate(images)],
("tier", (None, "Sketch")),
("mesh_mode", (None, "Raw")),
]
if text_prompt:
files.append(("prompt", (None, text_prompt)))
if bbox_condition:
files.append(("bbox_condition", (None, json.dumps(bbox_condition))))
response = requests.post(
"https://hyperhuman.deemos.com/api/v2/rodin",
headers={
"Authorization": f"Bearer {bpy.context.scene.blendermcp_hyper3d_api_key}",
},
files=files
)
data = response.json()
return data
except Exception as e:
return {"error": str(e)}
def create_rodin_job_fal_ai(
self,
text_prompt: str=None,
images: list[tuple[str, str]]=None,
bbox_condition=None
):
try:
req_data = {
"tier": "Sketch",
}
if images:
req_data["input_image_urls"] = images
if text_prompt:
req_data["prompt"] = text_prompt
if bbox_condition:
req_data["bbox_condition"] = bbox_condition
response = requests.post(
"https://queue.fal.run/fal-ai/hyper3d/rodin",
headers={
"Authorization": f"Key {bpy.context.scene.blendermcp_hyper3d_api_key}",
"Content-Type": "application/json",
},
json=req_data
)
data = response.json()
return data
except Exception as e:
return {"error": str(e)}
def poll_rodin_job_status(self, *args, **kwargs):
match bpy.context.scene.blendermcp_hyper3d_mode:
case "MAIN_SITE":
return self.poll_rodin_job_status_main_site(*args, **kwargs)
case "FAL_AI":
return self.poll_rodin_job_status_fal_ai(*args, **kwargs)
case _:
return f"Error: Unknown Hyper3D Rodin mode!"
def poll_rodin_job_status_main_site(self, subscription_key: str):
"""Call the job status API to get the job status"""
response = requests.post(
"https://hyperhuman.deemos.com/api/v2/status",
headers={
"Authorization": f"Bearer {bpy.context.scene.blendermcp_hyper3d_api_key}",
},
json={
"subscription_key": subscription_key,
},
)
data = response.json()
return {
"status_list": [i["status"] for i in data["jobs"]]
}
def poll_rodin_job_status_fal_ai(self, request_id: str):
"""Call the job status API to get the job status"""
response = requests.get(
f"https://queue.fal.run/fal-ai/hyper3d/requests/{request_id}/status",
headers={
"Authorization": f"KEY {bpy.context.scene.blendermcp_hyper3d_api_key}",
},
)
data = response.json()
return data
@staticmethod
def _clean_imported_glb(filepath, mesh_name=None):
# Get the set of existing objects before import
existing_objects = set(bpy.data.objects)
# Import the GLB file
bpy.ops.import_scene.gltf(filepath=filepath)
# Ensure the context is updated
bpy.context.view_layer.update()
# Get all imported objects
imported_objects = list(set(bpy.data.objects) - existing_objects)
# imported_objects = [obj for obj in bpy.context.view_layer.objects if obj.select_get()]
if not imported_objects:
print("Error: No objects were imported.")
return
# Identify the mesh object
mesh_obj = None
if len(imported_objects) == 1 and imported_objects[0].type == 'MESH':
mesh_obj = imported_objects[0]
print("Single mesh imported, no cleanup needed.")
else:
if len(imported_objects) == 2:
empty_objs = [i for i in imported_objects if i.type == "EMPTY"]
if len(empty_objs) != 1:
print("Error: Expected an empty node with one mesh child or a single mesh object.")
return
parent_obj = empty_objs.pop()
if len(parent_obj.children) == 1:
potential_mesh = parent_obj.children[0]
if potential_mesh.type == 'MESH':
print("GLB structure confirmed: Empty node with one mesh child.")
# Unparent the mesh from the empty node
potential_mesh.parent = None
# Remove the empty node
bpy.data.objects.remove(parent_obj)
print("Removed empty node, keeping only the mesh.")
mesh_obj = potential_mesh
else:
print("Error: Child is not a mesh object.")
return
else:
print("Error: Expected an empty node with one mesh child or a single mesh object.")
return
else:
print("Error: Expected an empty node with one mesh child or a single mesh object.")
return
# Rename the mesh if needed
try:
if mesh_obj and mesh_obj.name is not None and mesh_name:
mesh_obj.name = mesh_name
if mesh_obj.data.name is not None:
mesh_obj.data.name = mesh_name
print(f"Mesh renamed to: {mesh_name}")
except Exception as e:
print("Having issue with renaming, give up renaming.")
return mesh_obj
def import_generated_asset(self, *args, **kwargs):
match bpy.context.scene.blendermcp_hyper3d_mode:
case "MAIN_SITE":
return self.import_generated_asset_main_site(*args, **kwargs)
case "FAL_AI":
return self.import_generated_asset_fal_ai(*args, **kwargs)
case _:
return f"Error: Unknown Hyper3D Rodin mode!"
def import_generated_asset_main_site(self, task_uuid: str, name: str):
"""Fetch the generated asset, import into blender"""
response = requests.post(
"https://hyperhuman.deemos.com/api/v2/download",
headers={
"Authorization": f"Bearer {bpy.context.scene.blendermcp_hyper3d_api_key}",
},
json={
'task_uuid': task_uuid
}
)
data_ = response.json()
temp_file = None
for i in data_["list"]:
if i["name"].endswith(".glb"):
temp_file = tempfile.NamedTemporaryFile(
delete=False,
prefix=task_uuid,
suffix=".glb",
)
try:
# Download the content
response = requests.get(i["url"], stream=True)
response.raise_for_status() # Raise an exception for HTTP errors
# Write the content to the temporary file
for chunk in response.iter_content(chunk_size=8192):
temp_file.write(chunk)
# Close the file
temp_file.close()
except Exception as e:
# Clean up the file if there's an error
temp_file.close()
os.unlink(temp_file.name)
return {"succeed": False, "error": str(e)}
break
else:
return {"succeed": False, "error": "Generation failed. Please first make sure that all jobs of the task are done and then try again later."}
try:
obj = self._clean_imported_glb(
filepath=temp_file.name,
mesh_name=name
)
result = {
"name": obj.name,
"type": obj.type,
"location": [obj.location.x, obj.location.y, obj.location.z],
"rotation": [obj.rotation_euler.x, obj.rotation_euler.y, obj.rotation_euler.z],
"scale": [obj.scale.x, obj.scale.y, obj.scale.z],
}
if obj.type == "MESH":
bounding_box = self._get_aabb(obj)
result["world_bounding_box"] = bounding_box
return {
"succeed": True, **result
}
except Exception as e:
return {"succeed": False, "error": str(e)}
def import_generated_asset_fal_ai(self, request_id: str, name: str):
"""Fetch the generated asset, import into blender"""
response = requests.get(
f"https://queue.fal.run/fal-ai/hyper3d/requests/{request_id}",
headers={
"Authorization": f"Key {bpy.context.scene.blendermcp_hyper3d_api_key}",
}
)
data_ = response.json()
temp_file = None
temp_file = tempfile.NamedTemporaryFile(
delete=False,
prefix=request_id,
suffix=".glb",
)
try:
# Download the content
response = requests.get(data_["model_mesh"]["url"], stream=True)
response.raise_for_status() # Raise an exception for HTTP errors
# Write the content to the temporary file
for chunk in response.iter_content(chunk_size=8192):
temp_file.write(chunk)
# Close the file
temp_file.close()
except Exception as e:
# Clean up the file if there's an error
temp_file.close()
os.unlink(temp_file.name)
return {"succeed": False, "error": str(e)}
try:
obj = self._clean_imported_glb(
filepath=temp_file.name,
mesh_name=name
)
result = {
"name": obj.name,
"type": obj.type,
"location": [obj.location.x, obj.location.y, obj.location.z],
"rotation": [obj.rotation_euler.x, obj.rotation_euler.y, obj.rotation_euler.z],
"scale": [obj.scale.x, obj.scale.y, obj.scale.z],
}
if obj.type == "MESH":
bounding_box = self._get_aabb(obj)
result["world_bounding_box"] = bounding_box
return {
"succeed": True, **result
}
except Exception as e:
return {"succeed": False, "error": str(e)}
#endregion
#region Sketchfab API
def get_sketchfab_status(self):
"""Get the current status of Sketchfab integration"""
enabled = bpy.context.scene.blendermcp_use_sketchfab
api_key = bpy.context.scene.blendermcp_sketchfab_api_key
# Test the API key if present
if api_key:
try:
headers = {
"Authorization": f"Token {api_key}"
}
response = requests.get(
"https://api.sketchfab.com/v3/me",
headers=headers,
timeout=30 # Add timeout of 30 seconds
)
if response.status_code == 200:
user_data = response.json()
username = user_data.get("username", "Unknown user")
return {
"enabled": True,
"message": f"Sketchfab integration is enabled and ready to use. Logged in as: {username}"
}
else:
return {
"enabled": False,
"message": f"Sketchfab API key seems invalid. Status code: {response.status_code}"
}
except requests.exceptions.Timeout:
return {
"enabled": False,
"message": "Timeout connecting to Sketchfab API. Check your internet connection."
}
except Exception as e:
return {
"enabled": False,
"message": f"Error testing Sketchfab API key: {str(e)}"
}
if enabled and api_key:
return {"enabled": True, "message": "Sketchfab integration is enabled and ready to use."}
elif enabled and not api_key:
return {
"enabled": False,
"message": """Sketchfab integration is currently enabled, but API key is not given. To enable it:
1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden)
2. Keep the 'Use Sketchfab' checkbox checked
3. Enter your Sketchfab API Key
4. Restart the connection to Claude"""
}
else:
return {
"enabled": False,
"message": """Sketchfab integration is currently disabled. To enable it:
1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden)
2. Check the 'Use assets from Sketchfab' checkbox
3. Enter your Sketchfab API Key
4. Restart the connection to Claude"""
}
def search_sketchfab_models(self, query, categories=None, count=20, downloadable=True):
"""Search for models on Sketchfab based on query and optional filters"""
try:
api_key = bpy.context.scene.blendermcp_sketchfab_api_key
if not api_key:
return {"error": "Sketchfab API key is not configured"}
# Build search parameters with exact fields from Sketchfab API docs
params = {
"type": "models",
"q": query,
"count": count,
"downloadable": downloadable,
"archives_flavours": False
}
if categories:
params["categories"] = categories
# Make API request to Sketchfab search endpoint
# The proper format according to Sketchfab API docs for API key auth
headers = {
"Authorization": f"Token {api_key}"
}
# Use the search endpoint as specified in the API documentation
response = requests.get(
"https://api.sketchfab.com/v3/search",
headers=headers,
params=params,
timeout=30 # Add timeout of 30 seconds
)
if response.status_code == 401:
return {"error": "Authentication failed (401). Check your API key."}
if response.status_code != 200:
return {"error": f"API request failed with status code {response.status_code}"}
response_data = response.json()
# Safety check on the response structure
if response_data is None:
return {"error": "Received empty response from Sketchfab API"}
# Handle 'results' potentially missing from response
results = response_data.get("results", [])
if not isinstance(results, list):
return {"error": f"Unexpected response format from Sketchfab API: {response_data}"}
return response_data
except requests.exceptions.Timeout:
return {"error": "Request timed out. Check your internet connection."}
except json.JSONDecodeError as e:
return {"error": f"Invalid JSON response from Sketchfab API: {str(e)}"}
except Exception as e:
import traceback
traceback.print_exc()
return {"error": str(e)}
def get_sketchfab_model_preview(self, uid):
"""Get thumbnail preview image of a Sketchfab model by its UID"""
try:
import base64
api_key = bpy.context.scene.blendermcp_sketchfab_api_key
if not api_key:
return {"error": "Sketchfab API key is not configured"}
headers = {"Authorization": f"Token {api_key}"}
# Get model info which includes thumbnails
response = requests.get(
f"https://api.sketchfab.com/v3/models/{uid}",
headers=headers,
timeout=30
)
if response.status_code == 401:
return {"error": "Authentication failed (401). Check your API key."}
if response.status_code == 404:
return {"error": f"Model not found: {uid}"}
if response.status_code != 200:
return {"error": f"Failed to get model info: {response.status_code}"}
data = response.json()
thumbnails = data.get("thumbnails", {}).get("images", [])
if not thumbnails:
return {"error": "No thumbnail available for this model"}
# Find a suitable thumbnail (prefer medium size ~640px)
selected_thumbnail = None
for thumb in thumbnails:
width = thumb.get("width", 0)
if 400 <= width <= 800:
selected_thumbnail = thumb
break
# Fallback to the first available thumbnail
if not selected_thumbnail:
selected_thumbnail = thumbnails[0]
thumbnail_url = selected_thumbnail.get("url")
if not thumbnail_url:
return {"error": "Thumbnail URL not found"}
# Download the thumbnail image
img_response = requests.get(thumbnail_url, timeout=30)
if img_response.status_code != 200:
return {"error": f"Failed to download thumbnail: {img_response.status_code}"}
# Encode image as base64
image_data = base64.b64encode(img_response.content).decode('ascii')
# Determine format from content type or URL
content_type = img_response.headers.get("Content-Type", "")
if "png" in content_type or thumbnail_url.endswith(".png"):
img_format = "png"
else:
img_format = "jpeg"
# Get additional model info for context
model_name = data.get("name", "Unknown")
author = data.get("user", {}).get("username", "Unknown")
return {
"success": True,
"image_data": image_data,
"format": img_format,
"model_name": model_name,
"author": author,
"uid": uid,
"thumbnail_width": selected_thumbnail.get("width"),
"thumbnail_height": selected_thumbnail.get("height")
}
except requests.exceptions.Timeout:
return {"error": "Request timed out. Check your internet connection."}
except Exception as e:
import traceback
traceback.print_exc()
return {"error": f"Failed to get model preview: {str(e)}"}
def download_sketchfab_model(self, uid, normalize_size=False, target_size=1.0):
"""Download a model from Sketchfab by its UID
Parameters:
- uid: The unique identifier of the Sketchfab model
- normalize_size: If True, scale the model so its largest dimension equals target_size
- target_size: The target size in Blender units (meters) for the largest dimension
"""
try:
api_key = bpy.context.scene.blendermcp_sketchfab_api_key
if not api_key:
return {"error": "Sketchfab API key is not configured"}
# Use proper authorization header for API key auth
headers = {
"Authorization": f"Token {api_key}"
}
# Request download URL using the exact endpoint from the documentation
download_endpoint = f"https://api.sketchfab.com/v3/models/{uid}/download"
response = requests.get(
download_endpoint,
headers=headers,
timeout=30 # Add timeout of 30 seconds
)
if response.status_code == 401:
return {"error": "Authentication failed (401). Check your API key."}
if response.status_code != 200:
return {"error": f"Download request failed with status code {response.status_code}"}
data = response.json()
# Safety check for None data
if data is None:
return {"error": "Received empty response from Sketchfab API for download request"}
# Extract download URL with safety checks
gltf_data = data.get("gltf")
if not gltf_data:
return {"error": "No gltf download URL available for this model. Response: " + str(data)}
download_url = gltf_data.get("url")
if not download_url:
return {"error": "No download URL available for this model. Make sure the model is downloadable and you have access."}
# Download the model (already has timeout)
model_response = requests.get(download_url, timeout=60) # 60 second timeout
if model_response.status_code != 200:
return {"error": f"Model download failed with status code {model_response.status_code}"}
# Save to temporary file
temp_dir = tempfile.mkdtemp()
zip_file_path = os.path.join(temp_dir, f"{uid}.zip")
with open(zip_file_path, "wb") as f:
f.write(model_response.content)
# Extract the zip file with enhanced security
with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:
# More secure zip slip prevention
for file_info in zip_ref.infolist():
# Get the path of the file
file_path = file_info.filename
# Convert directory separators to the current OS style
# This handles both / and \ in zip entries
target_path = os.path.join(temp_dir, os.path.normpath(file_path))
# Get absolute paths for comparison
abs_temp_dir = os.path.abspath(temp_dir)
abs_target_path = os.path.abspath(target_path)
# Ensure the normalized path doesn't escape the target directory
if not abs_target_path.startswith(abs_temp_dir):
with suppress(Exception):
shutil.rmtree(temp_dir)
return {"error": "Security issue: Zip contains files with path traversal attempt"}
# Additional explicit check for directory traversal
if ".." in file_path:
with suppress(Exception):
shutil.rmtree(temp_dir)
return {"error": "Security issue: Zip contains files with directory traversal sequence"}
# If all files passed security checks, extract them
zip_ref.extractall(temp_dir)
# Find the main glTF file
gltf_files = [f for f in os.listdir(temp_dir) if f.endswith('.gltf') or f.endswith('.glb')]
if not gltf_files:
with suppress(Exception):
shutil.rmtree(temp_dir)
return {"error": "No glTF file found in the downloaded model"}
main_file = os.path.join(temp_dir, gltf_files[0])
# Import the model
bpy.ops.import_scene.gltf(filepath=main_file)
# Get the imported objects
imported_objects = list(bpy.context.selected_objects)
imported_object_names = [obj.name for obj in imported_objects]
# Clean up temporary files
with suppress(Exception):
shutil.rmtree(temp_dir)
# Find root objects (objects without parents in the imported set)
root_objects = [obj for obj in imported_objects if obj.parent is None]
# Helper function to recursively get all mesh children
def get_all_mesh_children(obj):
"""Recursively collect all mesh objects in the hierarchy"""
meshes = []
if obj.type == 'MESH':
meshes.append(obj)
for child in obj.children:
meshes.extend(get_all_mesh_children(child))
return meshes
# Collect ALL meshes from the entire hierarchy (starting from roots)
all_meshes = []
for obj in root_objects:
all_meshes.extend(get_all_mesh_children(obj))
if all_meshes:
# Calculate combined world bounding box for all meshes
all_min = mathutils.Vector((float('inf'), float('inf'), float('inf')))
all_max = mathutils.Vector((float('-inf'), float('-inf'), float('-inf')))
for mesh_obj in all_meshes:
# Get world-space bounding box corners
for corner in mesh_obj.bound_box:
world_corner = mesh_obj.matrix_world @ mathutils.Vector(corner)
all_min.x = min(all_min.x, world_corner.x)
all_min.y = min(all_min.y, world_corner.y)
all_min.z = min(all_min.z, world_corner.z)
all_max.x = max(all_max.x, world_corner.x)
all_max.y = max(all_max.y, world_corner.y)
all_max.z = max(all_max.z, world_corner.z)
# Calculate dimensions
dimensions = [
all_max.x - all_min.x,
all_max.y - all_min.y,
all_max.z - all_min.z
]
max_dimension = max(dimensions)
# Apply normalization if requested
scale_applied = 1.0
if normalize_size and max_dimension > 0:
scale_factor = target_size / max_dimension
scale_applied = scale_factor
# ✅ Only apply scale to ROOT objects (not children!)
# Child objects inherit parent's scale through matrix_world
for root in root_objects:
root.scale = (
root.scale.x * scale_factor,
root.scale.y * scale_factor,
root.scale.z * scale_factor
)
# Update the scene to recalculate matrix_world for all objects
bpy.context.view_layer.update()
# Recalculate bounding box after scaling
all_min = mathutils.Vector((float('inf'), float('inf'), float('inf')))
all_max = mathutils.Vector((float('-inf'), float('-inf'), float('-inf')))
for mesh_obj in all_meshes:
for corner in mesh_obj.bound_box:
world_corner = mesh_obj.matrix_world @ mathutils.Vector(corner)
all_min.x = min(all_min.x, world_corner.x)
all_min.y = min(all_min.y, world_corner.y)
all_min.z = min(all_min.z, world_corner.z)
all_max.x = max(all_max.x, world_corner.x)
all_max.y = max(all_max.y, world_corner.y)
all_max.z = max(all_max.z, world_corner.z)
dimensions = [
all_max.x - all_min.x,
all_max.y - all_min.y,
all_max.z - all_min.z
]
world_bounding_box = [[all_min.x, all_min.y, all_min.z], [all_max.x, all_max.y, all_max.z]]
else:
world_bounding_box = None
dimensions = None
scale_applied = 1.0
result = {
"success": True,
"message": "Model imported successfully",
"imported_objects": imported_object_names
}
if world_bounding_box:
result["world_bounding_box"] = world_bounding_box
if dimensions:
result["dimensions"] = [round(d, 4) for d in dimensions]
if normalize_size:
result["scale_applied"] = round(scale_applied, 6)
result["normalized"] = True
return result
except requests.exceptions.Timeout:
return {"error": "Request timed out. Check your internet connection and try again with a simpler model."}
except json.JSONDecodeError as e:
return {"error": f"Invalid JSON response from Sketchfab API: {str(e)}"}
except Exception as e:
import traceback
traceback.print_exc()
return {"error": f"Failed to download model: {str(e)}"}
#endregion
#region Hunyuan3D
def get_hunyuan3d_status(self):
"""Get the current status of Hunyuan3D integration"""
enabled = bpy.context.scene.blendermcp_use_hunyuan3d
hunyuan3d_mode = bpy.context.scene.blendermcp_hunyuan3d_mode
if enabled:
match hunyuan3d_mode:
case "OFFICIAL_API":
if not bpy.context.scene.blendermcp_hunyuan3d_secret_id or not bpy.context.scene.blendermcp_hunyuan3d_secret_key:
return {
"enabled": False,
"mode": hunyuan3d_mode,
"message": """Hunyuan3D integration is currently enabled, but SecretId or SecretKey is not given. To enable it:
1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden)
2. Keep the 'Use Tencent Hunyuan 3D model generation' checkbox checked
3. Choose the right platform and fill in the SecretId and SecretKey
4. Restart the connection to Claude"""
}
case "LOCAL_API":
if not bpy.context.scene.blendermcp_hunyuan3d_api_url:
return {
"enabled": False,
"mode": hunyuan3d_mode,
"message": """Hunyuan3D integration is currently enabled, but API URL is not given. To enable it:
1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden)
2. Keep the 'Use Tencent Hunyuan 3D model generation' checkbox checked
3. Choose the right platform and fill in the API URL
4. Restart the connection to Claude"""
}
case _:
return {
"enabled": False,
"message": "Hunyuan3D integration is enabled and mode is not supported."
}
return {
"enabled": True,
"mode": hunyuan3d_mode,
"message": "Hunyuan3D integration is enabled and ready to use."
}
return {
"enabled": False,
"message": """Hunyuan3D integration is currently disabled. To enable it:
1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden)
2. Check the 'Use Tencent Hunyuan 3D model generation' checkbox
3. Restart the connection to Claude"""
}
@staticmethod
def get_tencent_cloud_sign_headers(
method: str,
path: str,
headParams: dict,
data: dict,
service: str,
region: str,
secret_id: str,
secret_key: str,
host: str = None
):
"""Generate the signature header required for Tencent Cloud API requests headers"""
# Generate timestamp
timestamp = int(time.time())
date = datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%d")
# If host is not provided, it is generated based on service and region.
if not host:
host = f"{service}.tencentcloudapi.com"
endpoint = f"https://{host}"
# Constructing the request body
payload_str = json.dumps(data)
# ************* Step 1: Concatenate the canonical request string *************
canonical_uri = path
canonical_querystring = ""
ct = "application/json; charset=utf-8"
canonical_headers = f"content-type:{ct}\nhost:{host}\nx-tc-action:{headParams.get('Action', '').lower()}\n"
signed_headers = "content-type;host;x-tc-action"
hashed_request_payload = hashlib.sha256(payload_str.encode("utf-8")).hexdigest()
canonical_request = (method + "\n" +
canonical_uri + "\n" +
canonical_querystring + "\n" +
canonical_headers + "\n" +
signed_headers + "\n" +
hashed_request_payload)
# ************* Step 2: Construct the reception signature string *************
credential_scope = f"{date}/{service}/tc3_request"
hashed_canonical_request = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
string_to_sign = ("TC3-HMAC-SHA256" + "\n" +
str(timestamp) + "\n" +
credential_scope + "\n" +
hashed_canonical_request)
# ************* Step 3: Calculate the signature *************
def sign(key, msg):
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
secret_date = sign(("TC3" + secret_key).encode("utf-8"), date)
secret_service = sign(secret_date, service)
secret_signing = sign(secret_service, "tc3_request")
signature = hmac.new(
secret_signing,
string_to_sign.encode("utf-8"),
hashlib.sha256
).hexdigest()
# ************* Step 4: Connect Authorization *************
authorization = ("TC3-HMAC-SHA256" + " " +
"Credential=" + secret_id + "/" + credential_scope + ", " +
"SignedHeaders=" + signed_headers + ", " +
"Signature=" + signature)
# Constructing request headers
headers = {
"Authorization": authorization,
"Content-Type": "application/json; charset=utf-8",
"Host": host,
"X-TC-Action": headParams.get("Action", ""),
"X-TC-Timestamp": str(timestamp),
"X-TC-Version": headParams.get("Version", ""),
"X-TC-Region": region
}
return headers, endpoint
def create_hunyuan_job(self, *args, **kwargs):
match bpy.context.scene.blendermcp_hunyuan3d_mode:
case "OFFICIAL_API":
return self.create_hunyuan_job_main_site(*args, **kwargs)
case "LOCAL_API":
return self.create_hunyuan_job_local_site(*args, **kwargs)
case _:
return f"Error: Unknown Hunyuan3D mode!"
def create_hunyuan_job_main_site(
self,
text_prompt: str = None,
image: str = None
):
try:
secret_id = bpy.context.scene.blendermcp_hunyuan3d_secret_id
secret_key = bpy.context.scene.blendermcp_hunyuan3d_secret_key
if not secret_id or not secret_key:
return {"error": "SecretId or SecretKey is not given"}
# Parameter verification
if not text_prompt and not image:
return {"error": "Prompt or Image is required"}
if text_prompt and image:
return {"error": "Prompt and Image cannot be provided simultaneously"}
# Fixed parameter configuration
service = "hunyuan"
action = "SubmitHunyuanTo3DJob"
version = "2023-09-01"
region = "ap-guangzhou"
headParams={
"Action": action,
"Version": version,
"Region": region,
}
# Constructing request parameters
data = {
"Num": 1 # The current API limit is only 1
}
# Handling text prompts
if text_prompt:
if len(text_prompt) > 200:
return {"error": "Prompt exceeds 200 characters limit"}
data["Prompt"] = text_prompt
# Handling image
if image:
if re.match(r'^https?://', image, re.IGNORECASE) is not None:
data["ImageUrl"] = image
else:
try:
# Convert to Base64 format
with open(image, "rb") as f:
image_base64 = base64.b64encode(f.read()).decode("ascii")
data["ImageBase64"] = image_base64
except Exception as e:
return {"error": f"Image encoding failed: {str(e)}"}
# Get signed headers
headers, endpoint = self.get_tencent_cloud_sign_headers("POST", "/", headParams, data, service, region, secret_id, secret_key)
response = requests.post(
endpoint,
headers = headers,
data = json.dumps(data)
)
if response.status_code == 200:
return response.json()
return {
"error": f"API request failed with status {response.status_code}: {response}"
}
except Exception as e:
return {"error": str(e)}
def create_hunyuan_job_local_site(
self,
text_prompt: str = None,
image: str = None):
try:
base_url = bpy.context.scene.blendermcp_hunyuan3d_api_url.rstrip('/')
octree_resolution = bpy.context.scene.blendermcp_hunyuan3d_octree_resolution
num_inference_steps = bpy.context.scene.blendermcp_hunyuan3d_num_inference_steps
guidance_scale = bpy.context.scene.blendermcp_hunyuan3d_guidance_scale
texture = bpy.context.scene.blendermcp_hunyuan3d_texture
if not base_url:
return {"error": "API URL is not given"}
# Parameter verification
if not text_prompt and not image:
return {"error": "Prompt or Image is required"}
# Constructing request parameters
data = {
"octree_resolution": octree_resolution,
"num_inference_steps": num_inference_steps,
"guidance_scale": guidance_scale,
"texture": texture,
}
# Handling text prompts
if text_prompt:
data["text"] = text_prompt
# Handling image
if image:
if re.match(r'^https?://', image, re.IGNORECASE) is not None:
try:
resImg = requests.get(image)
resImg.raise_for_status()
image_base64 = base64.b64encode(resImg.content).decode("ascii")
data["image"] = image_base64
except Exception as e:
return {"error": f"Failed to download or encode image: {str(e)}"}
else:
try:
# Convert to Base64 format
with open(image, "rb") as f:
image_base64 = base64.b64encode(f.read()).decode("ascii")
data["image"] = image_base64
except Exception as e:
return {"error": f"Image encoding failed: {str(e)}"}
response = requests.post(
f"{base_url}/generate",
json = data,
)
if response.status_code != 200:
return {
"error": f"Generation failed: {response.text}"
}
# Decode base64 and save to temporary file
with tempfile.NamedTemporaryFile(delete=False, suffix=".glb") as temp_file:
temp_file.write(response.content)
temp_file_name = temp_file.name
# Import the GLB file in the main thread
def import_handler():
bpy.ops.import_scene.gltf(filepath=temp_file_name)
os.unlink(temp_file.name)
return None
bpy.app.timers.register(import_handler)
return {
"status": "DONE",
"message": "Generation and Import glb succeeded"
}
except Exception as e:
print(f"An error occurred: {e}")
return {"error": str(e)}
def poll_hunyuan_job_status(self, *args, **kwargs):
return self.poll_hunyuan_job_status_ai(*args, **kwargs)
def poll_hunyuan_job_status_ai(self, job_id: str):
"""Call the job status API to get the job status"""
print(job_id)
try:
secret_id = bpy.context.scene.blendermcp_hunyuan3d_secret_id
secret_key = bpy.context.scene.blendermcp_hunyuan3d_secret_key
if not secret_id or not secret_key:
return {"error": "SecretId or SecretKey is not given"}
if not job_id:
return {"error": "JobId is required"}
service = "hunyuan"
action = "QueryHunyuanTo3DJob"
version = "2023-09-01"
region = "ap-guangzhou"
headParams={
"Action": action,
"Version": version,
"Region": region,
}
clean_job_id = job_id.removeprefix("job_")
data = {
"JobId": clean_job_id
}
headers, endpoint = self.get_tencent_cloud_sign_headers("POST", "/", headParams, data, service, region, secret_id, secret_key)
response = requests.post(
endpoint,
headers=headers,
data=json.dumps(data)
)
if response.status_code == 200:
return response.json()
return {
"error": f"API request failed with status {response.status_code}: {response}"
}
except Exception as e:
return {"error": str(e)}
def import_generated_asset_hunyuan(self, *args, **kwargs):
return self.import_generated_asset_hunyuan_ai(*args, **kwargs)
def import_generated_asset_hunyuan_ai(self, name: str , zip_file_url: str):
if not zip_file_url:
return {"error": "Zip file not found"}
# Validate URL
if not re.match(r'^https?://', zip_file_url, re.IGNORECASE):
return {"error": "Invalid URL format. Must start with http:// or https://"}
# Create a temporary directory
temp_dir = tempfile.mkdtemp(prefix="tencent_obj_")
zip_file_path = osp.join(temp_dir, "model.zip")
obj_file_path = osp.join(temp_dir, "model.obj")
mtl_file_path = osp.join(temp_dir, "model.mtl")
try:
# Download ZIP file
zip_response = requests.get(zip_file_url, stream=True)
zip_response.raise_for_status()
with open(zip_file_path, "wb") as f:
for chunk in zip_response.iter_content(chunk_size=8192):
f.write(chunk)
# Unzip the ZIP
with zipfile.ZipFile(zip_file_path, "r") as zip_ref:
zip_ref.extractall(temp_dir)
# Find the .obj file (there may be multiple, assuming the main file is model.obj)
for file in os.listdir(temp_dir):
if file.endswith(".obj"):
obj_file_path = osp.join(temp_dir, file)
if not osp.exists(obj_file_path):
return {"succeed": False, "error": "OBJ file not found after extraction"}
# Import obj file
if bpy.app.version>=(4, 0, 0):
bpy.ops.wm.obj_import(filepath=obj_file_path)
else:
bpy.ops.import_scene.obj(filepath=obj_file_path)
imported_objs = [obj for obj in bpy.context.selected_objects if obj.type == 'MESH']
if not imported_objs:
return {"succeed": False, "error": "No mesh objects imported"}
obj = imported_objs[0]
if name:
obj.name = name
result = {
"name": obj.name,
"type": obj.type,
"location": [obj.location.x, obj.location.y, obj.location.z],
"rotation": [obj.rotation_euler.x, obj.rotation_euler.y, obj.rotation_euler.z],
"scale": [obj.scale.x, obj.scale.y, obj.scale.z],
}
if obj.type == "MESH":
bounding_box = self._get_aabb(obj)
result["world_bounding_box"] = bounding_box
return {"succeed": True, **result}
except Exception as e:
return {"succeed": False, "error": str(e)}
finally:
# Clean up temporary zip and obj, save texture and mtl
try:
if os.path.exists(zip_file_path):
os.remove(zip_file_path)
if os.path.exists(obj_file_path):
os.remove(obj_file_path)
except Exception as e:
print(f"Failed to clean up temporary directory {temp_dir}: {e}")
#endregion
# Blender Addon Preferences
class BLENDERMCP_AddonPreferences(bpy.types.AddonPreferences):
bl_idname = __name__
telemetry_consent: BoolProperty(
name="Allow Telemetry",
description="Allow collection of prompts, code snippets, and screenshots to help improve Blender MCP",
default=True
)
def draw(self, context):
layout = self.layout
# Telemetry section
layout.label(text="Telemetry & Privacy:", icon='PREFERENCES')
box = layout.box()
row = box.row()
row.prop(self, "telemetry_consent", text="Allow Telemetry")
# Info text
box.separator()
if self.telemetry_consent:
box.label(text="With consent: We collect anonymized prompts, code, and screenshots.", icon='INFO')
else:
box.label(text="Without consent: We only collect minimal anonymous usage data", icon='INFO')
box.label(text="(tool names, success/failure, duration - no prompts or code).", icon='BLANK1')
box.separator()
box.label(text="All data is fully anonymized. You can change this anytime.", icon='CHECKMARK')
# Terms and Conditions link
box.separator()
row = box.row()
row.operator("blendermcp.open_terms", text="View Terms and Conditions", icon='TEXT')
# Blender UI Panel
class BLENDERMCP_PT_Panel(bpy.types.Panel):
bl_label = "Blender MCP"
bl_idname = "BLENDERMCP_PT_Panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'BlenderMCP'
def draw(self, context):
layout = self.layout
scene = context.scene
layout.prop(scene, "blendermcp_port")
layout.prop(scene, "blendermcp_use_polyhaven", text="Use assets from Poly Haven")
layout.prop(scene, "blendermcp_use_hyper3d", text="Use Hyper3D Rodin 3D model generation")
if scene.blendermcp_use_hyper3d:
layout.prop(scene, "blendermcp_hyper3d_mode", text="Rodin Mode")
layout.prop(scene, "blendermcp_hyper3d_api_key", text="API Key")
layout.operator("blendermcp.set_hyper3d_free_trial_api_key", text="Set Free Trial API Key")
layout.prop(scene, "blendermcp_use_sketchfab", text="Use assets from Sketchfab")
if scene.blendermcp_use_sketchfab:
layout.prop(scene, "blendermcp_sketchfab_api_key", text="API Key")
layout.prop(scene, "blendermcp_use_hunyuan3d", text="Use Tencent Hunyuan 3D model generation")
if scene.blendermcp_use_hunyuan3d:
layout.prop(scene, "blendermcp_hunyuan3d_mode", text="Hunyuan3D Mode")
if scene.blendermcp_hunyuan3d_mode == 'OFFICIAL_API':
layout.prop(scene, "blendermcp_hunyuan3d_secret_id", text="SecretId")
layout.prop(scene, "blendermcp_hunyuan3d_secret_key", text="SecretKey")
if scene.blendermcp_hunyuan3d_mode == 'LOCAL_API':
layout.prop(scene, "blendermcp_hunyuan3d_api_url", text="API URL")
layout.prop(scene, "blendermcp_hunyuan3d_octree_resolution", text="Octree Resolution")
layout.prop(scene, "blendermcp_hunyuan3d_num_inference_steps", text="Number of Inference Steps")
layout.prop(scene, "blendermcp_hunyuan3d_guidance_scale", text="Guidance Scale")
layout.prop(scene, "blendermcp_hunyuan3d_texture", text="Generate Texture")
if not scene.blendermcp_server_running:
layout.operator("blendermcp.start_server", text="Connect to MCP server")
else:
layout.operator("blendermcp.stop_server", text="Disconnect from MCP server")
layout.label(text=f"Running on port {scene.blendermcp_port}")
# Operator to set Hyper3D API Key
class BLENDERMCP_OT_SetFreeTrialHyper3DAPIKey(bpy.types.Operator):
bl_idname = "blendermcp.set_hyper3d_free_trial_api_key"
bl_label = "Set Free Trial API Key"
def execute(self, context):
context.scene.blendermcp_hyper3d_api_key = RODIN_FREE_TRIAL_KEY
context.scene.blendermcp_hyper3d_mode = 'MAIN_SITE'
self.report({'INFO'}, "API Key set successfully!")
return {'FINISHED'}
# Operator to start the server
class BLENDERMCP_OT_StartServer(bpy.types.Operator):
bl_idname = "blendermcp.start_server"
bl_label = "Connect to Claude"
bl_description = "Start the BlenderMCP server to connect with Claude"
def execute(self, context):
scene = context.scene
# Create a new server instance
if not hasattr(bpy.types, "blendermcp_server") or not bpy.types.blendermcp_server:
bpy.types.blendermcp_server = BlenderMCPServer(port=scene.blendermcp_port)
# Start the server
bpy.types.blendermcp_server.start()
scene.blendermcp_server_running = True
return {'FINISHED'}
# Operator to stop the server
class BLENDERMCP_OT_StopServer(bpy.types.Operator):
bl_idname = "blendermcp.stop_server"
bl_label = "Stop the connection to Claude"
bl_description = "Stop the connection to Claude"
def execute(self, context):
scene = context.scene
# Stop the server if it exists
if hasattr(bpy.types, "blendermcp_server") and bpy.types.blendermcp_server:
bpy.types.blendermcp_server.stop()
del bpy.types.blendermcp_server
scene.blendermcp_server_running = False
return {'FINISHED'}
# Operator to open Terms and Conditions
class BLENDERMCP_OT_OpenTerms(bpy.types.Operator):
bl_idname = "blendermcp.open_terms"
bl_label = "View Terms and Conditions"
bl_description = "Open the Terms and Conditions document"
def execute(self, context):
# Open the Terms and Conditions on GitHub
terms_url = "https://github.com/ahujasid/blender-mcp/blob/main/TERMS_AND_CONDITIONS.md"
try:
import webbrowser
webbrowser.open(terms_url)
self.report({'INFO'}, "Terms and Conditions opened in browser")
except Exception as e:
self.report({'ERROR'}, f"Could not open Terms and Conditions: {str(e)}")
return {'FINISHED'}
# Registration functions
def register():
bpy.types.Scene.blendermcp_port = IntProperty(
name="Port",
description="Port for the BlenderMCP server",
default=9876,
min=1024,
max=65535
)
bpy.types.Scene.blendermcp_server_running = bpy.props.BoolProperty(
name="Server Running",
default=False
)
bpy.types.Scene.blendermcp_use_polyhaven = bpy.props.BoolProperty(
name="Use Poly Haven",
description="Enable Poly Haven asset integration",
default=False
)
bpy.types.Scene.blendermcp_use_hyper3d = bpy.props.BoolProperty(
name="Use Hyper3D Rodin",
description="Enable Hyper3D Rodin generatino integration",
default=False
)
bpy.types.Scene.blendermcp_hyper3d_mode = bpy.props.EnumProperty(
name="Rodin Mode",
description="Choose the platform used to call Rodin APIs",
items=[
("MAIN_SITE", "hyper3d.ai", "hyper3d.ai"),
("FAL_AI", "fal.ai", "fal.ai"),
],
default="MAIN_SITE"
)
bpy.types.Scene.blendermcp_hyper3d_api_key = bpy.props.StringProperty(
name="Hyper3D API Key",
subtype="PASSWORD",
description="API Key provided by Hyper3D",
default=""
)
bpy.types.Scene.blendermcp_use_hunyuan3d = bpy.props.BoolProperty(
name="Use Hunyuan 3D",
description="Enable Hunyuan asset integration",
default=False
)
bpy.types.Scene.blendermcp_hunyuan3d_mode = bpy.props.EnumProperty(
name="Hunyuan3D Mode",
description="Choose a local or official APIs",
items=[
("LOCAL_API", "local api", "local api"),
("OFFICIAL_API", "official api", "official api"),
],
default="LOCAL_API"
)
bpy.types.Scene.blendermcp_hunyuan3d_secret_id = bpy.props.StringProperty(
name="Hunyuan 3D SecretId",
description="SecretId provided by Hunyuan 3D",
default=""
)
bpy.types.Scene.blendermcp_hunyuan3d_secret_key = bpy.props.StringProperty(
name="Hunyuan 3D SecretKey",
subtype="PASSWORD",
description="SecretKey provided by Hunyuan 3D",
default=""
)
bpy.types.Scene.blendermcp_hunyuan3d_api_url = bpy.props.StringProperty(
name="API URL",
description="URL of the Hunyuan 3D API service",
default="http://localhost:8081"
)
bpy.types.Scene.blendermcp_hunyuan3d_octree_resolution = bpy.props.IntProperty(
name="Octree Resolution",
description="Octree resolution for the 3D generation",
default=256,
min=128,
max=512,
)
bpy.types.Scene.blendermcp_hunyuan3d_num_inference_steps = bpy.props.IntProperty(
name="Number of Inference Steps",
description="Number of inference steps for the 3D generation",
default=20,
min=20,
max=50,
)
bpy.types.Scene.blendermcp_hunyuan3d_guidance_scale = bpy.props.FloatProperty(
name="Guidance Scale",
description="Guidance scale for the 3D generation",
default=5.5,
min=1.0,
max=10.0,
)
bpy.types.Scene.blendermcp_hunyuan3d_texture = bpy.props.BoolProperty(
name="Generate Texture",
description="Whether to generate texture for the 3D model",
default=False,
)
bpy.types.Scene.blendermcp_use_sketchfab = bpy.props.BoolProperty(
name="Use Sketchfab",
description="Enable Sketchfab asset integration",
default=False
)
bpy.types.Scene.blendermcp_sketchfab_api_key = bpy.props.StringProperty(
name="Sketchfab API Key",
subtype="PASSWORD",
description="API Key provided by Sketchfab",
default=""
)
# Register preferences class
bpy.utils.register_class(BLENDERMCP_AddonPreferences)
bpy.utils.register_class(BLENDERMCP_PT_Panel)
bpy.utils.register_class(BLENDERMCP_OT_SetFreeTrialHyper3DAPIKey)
bpy.utils.register_class(BLENDERMCP_OT_StartServer)
bpy.utils.register_class(BLENDERMCP_OT_StopServer)
bpy.utils.register_class(BLENDERMCP_OT_OpenTerms)
print("BlenderMCP addon registered")
def unregister():
# Stop the server if it's running
if hasattr(bpy.types, "blendermcp_server") and bpy.types.blendermcp_server:
bpy.types.blendermcp_server.stop()
del bpy.types.blendermcp_server
bpy.utils.unregister_class(BLENDERMCP_PT_Panel)
bpy.utils.unregister_class(BLENDERMCP_OT_SetFreeTrialHyper3DAPIKey)
bpy.utils.unregister_class(BLENDERMCP_OT_StartServer)
bpy.utils.unregister_class(BLENDERMCP_OT_StopServer)
bpy.utils.unregister_class(BLENDERMCP_OT_OpenTerms)
bpy.utils.unregister_class(BLENDERMCP_AddonPreferences)
del bpy.types.Scene.blendermcp_port
del bpy.types.Scene.blendermcp_server_running
del bpy.types.Scene.blendermcp_use_polyhaven
del bpy.types.Scene.blendermcp_use_hyper3d
del bpy.types.Scene.blendermcp_hyper3d_mode
del bpy.types.Scene.blendermcp_hyper3d_api_key
del bpy.types.Scene.blendermcp_use_sketchfab
del bpy.types.Scene.blendermcp_sketchfab_api_key
del bpy.types.Scene.blendermcp_use_hunyuan3d
del bpy.types.Scene.blendermcp_hunyuan3d_mode
del bpy.types.Scene.blendermcp_hunyuan3d_secret_id
del bpy.types.Scene.blendermcp_hunyuan3d_secret_key
del bpy.types.Scene.blendermcp_hunyuan3d_api_url
del bpy.types.Scene.blendermcp_hunyuan3d_octree_resolution
del bpy.types.Scene.blendermcp_hunyuan3d_num_inference_steps
del bpy.types.Scene.blendermcp_hunyuan3d_guidance_scale
del bpy.types.Scene.blendermcp_hunyuan3d_texture
print("BlenderMCP addon unregistered")
if __name__ == "__main__":
register()
================================================
FILE: main.py
================================================
from blender_mcp.server import main as server_main
def main():
"""Entry point for the blender-mcp package"""
server_main()
if __name__ == "__main__":
main()
================================================
FILE: pyproject.toml
================================================
[project]
name = "blender-mcp"
version = "1.5.5"
description = "Blender integration through the Model Context Protocol"
readme = "README.md"
requires-python = ">=3.10"
authors = [
{name = "Your Name", email = "your.email@example.com"}
]
license = {text = "MIT"}
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
dependencies = [
"mcp[cli]>=1.3.0",
"supabase>=2.0.0",
"tomli>=2.0.0",
]
[project.scripts]
blender-mcp = "blender_mcp.server:main"
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
package-dir = {"" = "src"}
[project.urls]
"Homepage" = "https://github.com/yourusername/blender-mcp"
"Bug Tracker" = "https://github.com/yourusername/blender-mcp/issues"
================================================
FILE: src/blender_mcp/__init__.py
================================================
"""Blender integration through the Model Context Protocol."""
__version__ = "0.1.0"
# Expose key classes and functions for easier imports
from .server import BlenderConnection, get_blender_connection
================================================
FILE: src/blender_mcp/server.py
================================================
# blender_mcp_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
# Import telemetry
from .telemetry import record_startup, get_telemetry
from .telemetry_decorator import telemetry_tool
# Configure logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("BlenderMCPServer")
# 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("BlenderMCP server starting up")
# Record startup event for telemetry
try:
record_startup()
except Exception as e:
logger.debug(f"Failed to record startup telemetry: {e}")
# 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",
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
@telemetry_tool("get_scene_info")
@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)}"
@telemetry_tool("get_object_info")
@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)}"
@telemetry_tool("get_viewport_screenshot")
@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)}")
@telemetry_tool("execute_blender_code")
@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)}"
@telemetry_tool("get_polyhaven_categories")
@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 BlenderMCP, 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)}"
@telemetry_tool("search_polyhaven_assets")
@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)}"
@telemetry_tool("download_polyhaven_asset")
@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)}"
@telemetry_tool("set_texture")
@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)}"
@telemetry_tool("get_polyhaven_status")
@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)}"
@telemetry_tool("get_hyper3d_status")
@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 sliently 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)}"
@telemetry_tool("get_sketchfab_status")
@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)}"
@telemetry_tool("search_sketchfab_models")
@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)}"
@telemetry_tool("download_sketchfab_model")
@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
@telemetry_tool("generate_hyper3d_model_via_text")
@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)}"
@telemetry_tool("generate_hyper3d_model_via_images")
@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_paths):
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)}"
@telemetry_tool("poll_rodin_job_status")
@mcp.tool()
def poll_rodin_job_status(
ctx: Context,
subscription_key: str=None,
request_id: str=None,
):
"""
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)}"
@telemetry_tool("import_generated_asset")
@mcp.tool()
def import_generated_asset(
ctx: Context,
name: str,
task_uuid: str=None,
request_id: str=None,
):
"""
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
) -> 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.
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()
result = blender.send_command("create_hunyuan_job", {
"text_prompt": text_prompt,
"image": input_image_url,
})
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,
):
"""
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.prompt()
def asset_creation_strategy() -> str:
"""Defines the preferred strategy for creating assets in Blender"""
return """When creating 3D content in Blender, always start by checking if integrations are available:
0. Before anything, always check the scene from get_scene_info()
1. First use the following tools to verify if the following integrations are enabled:
1. PolyHaven
Use get_polyhaven_status() to verify its status
If PolyHaven is enabled:
- For objects/models: Use download_polyhaven_asset() with asset_type="models"
- For materials/textures: Use download_polyhaven_asset() with asset_type="textures"
- For environment lighting: Use download_polyhaven_asset() with asset_type="hdris"
2. Sketchfab
Sketchfab is good at Realistic models, and has a wider variety of models than PolyHaven.
Use get_sketchfab_status() to verify its status
If Sketchfab is enabled:
- For objects/models: First search using search_sketchfab_models() with your query
- Then download specific models using download_sketchfab_model() with the UID
- Note that only downloadable models can be accessed, and API key must be properly configured
- Sketchfab has a wider variety of models than PolyHaven, especially for specific subjects
3. Hyper3D(Rodin)
Hyper3D Rodin is good at generating 3D models for single item.
So don't try to:
1. Generate the whole scene with one shot
2. Generate ground using Hyper3D
3. Generate parts of the items separately and put them together afterwards
Use get_hyper3d_status() to verify its status
If Hyper3D is enabled:
- For objects/models, do the following steps:
1. Create the model generation task
- Use generate_hyper3d_model_via_images() if image(s) is/are given
- Use generate_hyper3d_model_via_text() if generating 3D asset using text prompt
If key type is free_trial and insufficient balance error returned, tell the user that the free trial key can only generated limited models everyday, they can choose to:
- Wait for another day and try again
- Go to hyper3d.ai to find out how to get their own API key
- Go to fal.ai to get their own private API key
2. Poll the status
- Use poll_rodin_job_status() to check if the generation task has completed or failed
3. Import the asset
- Use import_generated_asset() to import the generated GLB model the asset
4. After importing the asset, ALWAYS check the world_bounding_box of the imported mesh, and adjust the mesh's location and size
Adjust the imported mesh's location, scale, rotation, so that the mesh is on the right spot.
You can reuse assets previous generated by running python code to duplicate the object, without creating another generation task.
4. Hunyuan3D
Hunyuan3D is good at generating 3D models for single item.
So don't try to:
1. Generate the whole scene with one shot
2. Generate ground using Hunyuan3D
3. Generate parts of the items separately and put them together afterwards
Use get_hunyuan3d_status() to verify its status
If Hunyuan3D is enabled:
if Hunyuan3D mode is "OFFICIAL_API":
- For objects/models, do the following steps:
1. Create the model generation task
- Use generate_hunyuan3d_model by providing either a **text description** OR an **image(local or urls) reference**.
- Go to cloud.tencent.com out how to get their own SecretId and SecretKey
2. Poll the status
- Use poll_hunyuan_job_status() to check if the generation task has completed or failed
3. Import the asset
- Use import_generated_asset_hunyuan() to import the generated OBJ model the asset
if Hunyuan3D mode is "LOCAL_API":
- For objects/models, do the following steps:
1. Create the model generation task
- Use generate_hunyuan3d_model if image (local or urls) or text prompt is given and import the asset
You can reuse assets previous generated by running python code to duplicate the object, without creating another generation task.
3. Always check the world_bounding_box for each item so that:
- Ensure that all objects that should not be clipping are not clipping.
- Items have right spatial relationship.
4. Recommended asset source priority:
- For specific existing objects: First try Sketchfab, then PolyHaven
- For generic objects/furniture: First try PolyHaven, then Sketchfab
- For custom or unique items not available in libraries: Use Hyper3D Rodin or Hunyuan3D
- For environment lighting: Use PolyHaven HDRIs
- For materials/textures: Use PolyHaven textures
Only fall back to scripting when:
- PolyHaven, Sketchfab, Hyper3D, and Hunyuan3D are all disabled
- A simple primitive is explicitly requested
- No suitable asset exists in any of the libraries
- Hyper3D Rodin or Hunyuan3D failed to generate the desired asset
- The task specifically requires a basic material/color
"""
# Main execution
def main():
"""Run the MCP server"""
mcp.run()
if __name__ == "__main__":
main()
================================================
FILE: src/blender_mcp/telemetry.py
================================================
"""
Privacy-focused, anonymous telemetry for Blender MCP
Tracks tool usage, DAU/MAU, and performance metrics
"""
import contextlib
import json
import logging
import os
import platform
import queue
import sys
import threading
import time
import uuid
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any
try:
from supabase import create_client, Client
HAS_SUPABASE = True
except ImportError:
HAS_SUPABASE = False
try:
import tomli
except ImportError:
try:
import tomllib as tomli
except ImportError:
tomli = None
logger = logging.getLogger("blender-mcp-telemetry")
def get_package_version() -> str:
"""Get version from pyproject.toml"""
try:
pyproject_path = Path(__file__).parent.parent.parent.parent / "pyproject.toml"
if pyproject_path.exists():
if tomli:
with open(pyproject_path, "rb") as f:
data = tomli.load(f)
return data["project"]["version"]
except Exception:
pass
return "unknown"
MCP_VERSION = get_package_version()
class EventType(str, Enum):
"""Types of telemetry events"""
STARTUP = "startup"
TOOL_EXECUTION = "tool_execution"
PROMPT_SENT = "prompt_sent"
CONNECTION = "connection"
ERROR = "error"
@dataclass
class TelemetryEvent:
"""Structure for telemetry events"""
event_type: EventType
customer_uuid: str
session_id: str
timestamp: float
version: str
platform: str
# Optional fields
tool_name: str | None = None
prompt_text: str | None = None
success: bool = True
duration_ms: float | None = None
error_message: str | None = None
blender_version: str | None = None
metadata: dict[str, Any] | None = None
class TelemetryCollector:
"""Main telemetry collection class"""
def __init__(self):
"""Initialize telemetry collector"""
# Import config here to avoid circular imports
from .config import telemetry_config
self.config = telemetry_config
# Check if disabled via environment variables
if self._is_disabled():
self.config.enabled = False
logger.warning("Telemetry disabled via environment variable")
# Generate or load customer UUID
self._customer_uuid: str = self._get_or_create_uuid()
self._session_id: str = str(uuid.uuid4())
# Rate limiting tracking
self._event_timestamps: list[float] = []
self._rate_limit_lock = threading.Lock()
# Background queue and worker
self._queue: "queue.Queue[TelemetryEvent]" = queue.Queue(maxsize=1000)
self._worker: threading.Thread = threading.Thread(
target=self._worker_loop, daemon=True
)
self._worker.start()
logger.warning(f"Telemetry initialized (enabled={self.config.enabled}, has_supabase={HAS_SUPABASE}, customer_uuid={self._customer_uuid})")
def _is_disabled(self) -> bool:
"""Check if telemetry is disabled via environment variables"""
disable_vars = [
"DISABLE_TELEMETRY",
"BLENDER_MCP_DISABLE_TELEMETRY",
"MCP_DISABLE_TELEMETRY"
]
for var in disable_vars:
if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"):
return True
return False
def _get_data_directory(self) -> Path:
"""Get directory for storing telemetry data"""
if sys.platform == "win32":
base_dir = Path(os.environ.get('APPDATA', Path.home() / 'AppData' / 'Roaming'))
elif sys.platform == "darwin":
base_dir = Path.home() / 'Library' / 'Application Support'
else: # Linux
base_dir = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local' / 'share'))
data_dir = base_dir / 'BlenderMCP'
data_dir.mkdir(parents=True, exist_ok=True)
return data_dir
def _get_or_create_uuid(self) -> str:
"""Get or create anonymous customer UUID"""
try:
data_dir = self._get_data_directory()
uuid_file = data_dir / "customer_uuid.txt"
if uuid_file.exists():
customer_uuid = uuid_file.read_text(encoding="utf-8").strip()
if customer_uuid:
return customer_uuid
# Create new UUID
customer_uuid = str(uuid.uuid4())
uuid_file.write_text(customer_uuid, encoding="utf-8")
# Set restrictive permissions on Unix
if sys.platform != "win32":
os.chmod(uuid_file, 0o600)
return customer_uuid
except Exception as e:
logger.debug(f"Failed to persist UUID: {e}")
return str(uuid.uuid4())
def _check_user_consent(self) -> bool:
"""Check if user has consented to prompt collection via Blender addon"""
try:
# Import here to avoid circular dependency
from .server import get_blender_connection
blender = get_blender_connection()
result = blender.send_command("get_telemetry_consent", {})
consent = result.get("consent", False)
return consent
except Exception as e:
# Default to False if we can't check (user hasn't given consent or Blender not connected)
return False
def record_event(
self,
event_type: EventType,
tool_name: str | None = None,
prompt_text: str | None = None,
success: bool = True,
duration_ms: float | None = None,
error_message: str | None = None,
blender_version: str | None = None,
metadata: dict[str, Any] | None = None
):
"""Record a telemetry event (non-blocking)"""
if not self.config.enabled:
logger.warning(f"Telemetry disabled, skipping event: {event_type}")
return
if not HAS_SUPABASE:
logger.warning(f"Supabase not available, skipping event: {event_type}")
return
logger.warning(f"Recording telemetry event: {event_type}, tool={tool_name}")
# Check user consent for private data collection
user_consent = self._check_user_consent()
if not user_consent:
# Without consent, only collect minimal anonymous usage data:
# - Session startup events
# - Tool execution events (tool name, success, duration)
# - Basic session info for DAU/MAU calculation
# Remove all private information:
prompt_text = None # No user prompts
metadata = None # No code snippets, params, screenshots, scene info
# Keep error_message for debugging, but sanitize it
if error_message:
# Only keep generic error type, not specific details
error_message = "Error occurred (details withheld without consent)"
# Truncate prompt if needed (only if consent was given)
if prompt_text and len(prompt_text) > self.config.max_prompt_length:
prompt_text = prompt_text[:self.config.max_prompt_length] + "..."
# Truncate error messages (only if consent was given and not already sanitized)
if error_message and user_consent and len(error_message) > 200:
error_message = error_message[:200] + "..."
event = TelemetryEvent(
event_type=event_type,
customer_uuid=self._customer_uuid,
session_id=self._session_id,
timestamp=time.time(),
version=MCP_VERSION,
platform=platform.system().lower(),
tool_name=tool_name,
prompt_text=prompt_text,
success=success,
duration_ms=duration_ms,
error_message=error_message,
blender_version=blender_version,
metadata=metadata
)
# Enqueue for background worker
try:
self._queue.put_nowait(event)
except queue.Full:
logger.debug("Telemetry queue full, dropping event")
def _worker_loop(self):
"""Background worker that sends telemetry"""
while True:
event = self._queue.get()
try:
self._send_event(event)
except Exception as e:
logger.debug(f"Telemetry send failed: {e}")
finally:
with contextlib.suppress(Exception):
self._queue.task_done()
def _send_event(self, event: TelemetryEvent):
"""Send event to Supabase"""
if not HAS_SUPABASE:
return
try:
# Create Supabase client with explicit options
from supabase import ClientOptions
options = ClientOptions(
auto_refresh_token=False,
persist_session=False
)
supabase: Client = create_client(
self.config.supabase_url,
self.config.supabase_anon_key,
options=options
)
# Prepare data for insertion
data = {
"customer_uuid": event.customer_uuid,
"session_id": event.session_id,
"event_type": event.event_type.value,
"tool_name": event.tool_name,
"prompt_text": event.prompt_text,
"success": event.success,
"duration_ms": event.duration_ms,
"error_message": event.error_message,
"version": event.version,
"platform": event.platform,
"blender_version": event.blender_version,
"metadata": event.metadata or {},
"event_timestamp": int(event.timestamp),
}
response = supabase.table("telemetry_events").insert(data, returning="minimal").execute()
logger.debug(f"Telemetry sent: {event.event_type}")
except Exception as e:
logger.debug(f"Failed to send telemetry: {e}")
# Global telemetry instance
_telemetry_collector: TelemetryCollector | None = None
def get_telemetry() -> TelemetryCollector:
"""Get the global telemetry collector instance"""
global _telemetry_collector
if _telemetry_collector is None:
_telemetry_collector = TelemetryCollector()
return _telemetry_collector
def record_tool_usage(
tool_name: str,
success: bool,
duration_ms: float,
error: str | None = None
):
"""Convenience function to record tool usage"""
get_telemetry().record_event(
event_type=EventType.TOOL_EXECUTION,
tool_name=tool_name,
success=success,
duration_ms=duration_ms,
error_message=error
)
def record_startup(blender_version: str | None = None):
"""Record server startup event"""
get_telemetry().record_event(
event_type=EventType.STARTUP,
blender_version=blender_version
)
def is_telemetry_enabled() -> bool:
"""Check if telemetry is enabled"""
try:
return get_telemetry().config.enabled
except Exception:
return False
================================================
FILE: src/blender_mcp/telemetry_decorator.py
================================================
"""
Telemetry decorator for Blender MCP tools
"""
import functools
import inspect
import logging
import time
from typing import Callable, Any
from .telemetry import record_tool_usage
logger = logging.getLogger("blender-mcp-telemetry")
def telemetry_tool(tool_name: str):
"""Decorator to add telemetry tracking to MCP tools"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def sync_wrapper(*args, **kwargs) -> Any:
start_time = time.time()
success = False
error = None
try:
result = func(*args, **kwargs)
success = True
return result
except Exception as e:
error = str(e)
raise
finally:
duration_ms = (time.time() - start_time) * 1000
try:
record_tool_usage(tool_name, success, duration_ms, error)
except Exception as log_error:
logger.debug(f"Failed to record telemetry: {log_error}")
@functools.wraps(func)
async def async_wrapper(*args, **kwargs) -> Any:
start_time = time.time()
success = False
error = None
try:
result = await func(*args, **kwargs)
success = True
return result
except Exception as e:
error = str(e)
raise
finally:
duration_ms = (time.time() - start_time) * 1000
try:
record_tool_usage(tool_name, success, duration_ms, error)
except Exception as log_error:
logger.debug(f"Failed to record telemetry: {log_error}")
# Return appropriate wrapper based on function type
if inspect.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
return decorator
gitextract_u30293zn/
├── .gitignore
├── .python-version
├── LICENSE
├── README.md
├── TERMS_AND_CONDITIONS.md
├── addon.py
├── main.py
├── pyproject.toml
└── src/
└── blender_mcp/
├── __init__.py
├── server.py
├── telemetry.py
└── telemetry_decorator.py
SYMBOL INDEX (107 symbols across 5 files)
FILE: addon.py
class BlenderMCPServer (line 39) | class BlenderMCPServer:
method __init__ (line 40) | def __init__(self, host='localhost', port=9876):
method start (line 47) | def start(self):
method stop (line 71) | def stop(self):
method _server_loop (line 93) | def _server_loop(self):
method _handle_client (line 126) | def _handle_client(self, client):
method execute_command (line 186) | def execute_command(self, command):
method _execute_command_internal (line 196) | def _execute_command_internal(self, command):
method get_scene_info (line 271) | def get_scene_info(self):
method _get_aabb (line 306) | def _get_aabb(obj):
method get_object_info (line 327) | def get_object_info(self, name):
method get_viewport_screenshot (line 364) | def get_viewport_screenshot(self, max_size=800, filepath=None, format=...
method execute_code (line 421) | def execute_code(self, code):
method get_polyhaven_categories (line 440) | def get_polyhaven_categories(self, asset_type):
method search_polyhaven_assets (line 454) | def search_polyhaven_assets(self, asset_type=None, categories=None):
method download_polyhaven_asset (line 485) | def download_polyhaven_asset(self, asset_id, asset_type, resolution="1...
method set_texture (line 808) | def set_texture(self, object_name, texture_id):
method get_telemetry_consent (line 1112) | def get_telemetry_consent(self):
method get_polyhaven_status (line 1127) | def get_polyhaven_status(self):
method get_hyper3d_status (line 1142) | def get_hyper3d_status(self):
method create_rodin_job (line 1171) | def create_rodin_job(self, *args, **kwargs):
method create_rodin_job_main_site (line 1180) | def create_rodin_job_main_site(
method create_rodin_job_fal_ai (line 1211) | def create_rodin_job_fal_ai(
method poll_rodin_job_status (line 1240) | def poll_rodin_job_status(self, *args, **kwargs):
method poll_rodin_job_status_main_site (line 1249) | def poll_rodin_job_status_main_site(self, subscription_key: str):
method poll_rodin_job_status_fal_ai (line 1265) | def poll_rodin_job_status_fal_ai(self, request_id: str):
method _clean_imported_glb (line 1277) | def _clean_imported_glb(filepath, mesh_name=None):
method import_generated_asset (line 1343) | def import_generated_asset(self, *args, **kwargs):
method import_generated_asset_main_site (line 1352) | def import_generated_asset_main_site(self, task_uuid: str, name: str):
method import_generated_asset_fal_ai (line 1418) | def import_generated_asset_fal_ai(self, request_id: str, name: str):
method get_sketchfab_status (line 1478) | def get_sketchfab_status(self):
method search_sketchfab_models (line 1540) | def search_sketchfab_models(self, query, categories=None, count=20, do...
method get_sketchfab_model_preview (line 1602) | def get_sketchfab_model_preview(self, uid):
method download_sketchfab_model (line 1688) | def download_sketchfab_model(self, uid, normalize_size=False, target_s...
method get_hunyuan3d_status (line 1913) | def get_hunyuan3d_status(self):
method get_tencent_cloud_sign_headers (line 1960) | def get_tencent_cloud_sign_headers(
method create_hunyuan_job (line 2040) | def create_hunyuan_job(self, *args, **kwargs):
method create_hunyuan_job_main_site (line 2049) | def create_hunyuan_job_main_site(
method create_hunyuan_job_local_site (line 2119) | def create_hunyuan_job_local_site(
method poll_hunyuan_job_status (line 2199) | def poll_hunyuan_job_status(self, *args, **kwargs):
method poll_hunyuan_job_status_ai (line 2202) | def poll_hunyuan_job_status_ai(self, job_id: str):
method import_generated_asset_hunyuan (line 2246) | def import_generated_asset_hunyuan(self, *args, **kwargs):
method import_generated_asset_hunyuan_ai (line 2249) | def import_generated_asset_hunyuan_ai(self, name: str , zip_file_url: ...
class BLENDERMCP_AddonPreferences (line 2324) | class BLENDERMCP_AddonPreferences(bpy.types.AddonPreferences):
method draw (line 2333) | def draw(self, context):
class BLENDERMCP_PT_Panel (line 2359) | class BLENDERMCP_PT_Panel(bpy.types.Panel):
method draw (line 2366) | def draw(self, context):
class BLENDERMCP_OT_SetFreeTrialHyper3DAPIKey (line 2403) | class BLENDERMCP_OT_SetFreeTrialHyper3DAPIKey(bpy.types.Operator):
method execute (line 2407) | def execute(self, context):
class BLENDERMCP_OT_StartServer (line 2414) | class BLENDERMCP_OT_StartServer(bpy.types.Operator):
method execute (line 2419) | def execute(self, context):
class BLENDERMCP_OT_StopServer (line 2433) | class BLENDERMCP_OT_StopServer(bpy.types.Operator):
method execute (line 2438) | def execute(self, context):
class BLENDERMCP_OT_OpenTerms (line 2451) | class BLENDERMCP_OT_OpenTerms(bpy.types.Operator):
method execute (line 2456) | def execute(self, context):
function register (line 2469) | def register():
function unregister (line 2601) | def unregister():
FILE: main.py
function main (line 3) | def main():
FILE: src/blender_mcp/server.py
class BlenderConnection (line 30) | class BlenderConnection:
method connect (line 35) | def connect(self) -> bool:
method disconnect (line 50) | def disconnect(self):
method receive_full_response (line 60) | def receive_full_response(self, sock, buffer_size=8192):
method send_command (line 116) | def send_command(self, command_type: str, params: Dict[str, Any] = Non...
function server_lifespan (line 172) | async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, An...
function get_blender_connection (line 219) | def get_blender_connection():
function get_scene_info (line 256) | def get_scene_info(ctx: Context) -> str:
function get_object_info (line 270) | def get_object_info(ctx: Context, object_name: str) -> str:
function get_viewport_screenshot (line 289) | def get_viewport_screenshot(ctx: Context, max_size: int = 800) -> Image:
function execute_blender_code (line 333) | def execute_blender_code(ctx: Context, code: str) -> str:
function get_polyhaven_categories (line 351) | def get_polyhaven_categories(ctx: Context, asset_type: str = "hdris") ->...
function search_polyhaven_assets (line 384) | def search_polyhaven_assets(
function download_polyhaven_asset (line 434) | def download_polyhaven_asset(
function set_texture (line 486) | def set_texture(
function get_polyhaven_status (line 546) | def get_polyhaven_status(ctx: Context) -> str:
function get_hyper3d_status (line 565) | def get_hyper3d_status(ctx: Context) -> str:
function get_sketchfab_status (line 586) | def get_sketchfab_status(ctx: Context) -> str:
function search_sketchfab_models (line 605) | def search_sketchfab_models(
function get_sketchfab_model_preview (line 682) | def get_sketchfab_model_preview(
function download_sketchfab_model (line 724) | def download_sketchfab_model(
function _process_bbox (line 796) | def _process_bbox(original_bbox: list[float] | list[int] | None) -> list...
function generate_hyper3d_model_via_text (line 807) | def generate_hyper3d_model_via_text(
function generate_hyper3d_model_via_images (line 844) | def generate_hyper3d_model_via_images(
function poll_rodin_job_status (line 901) | def poll_rodin_job_status(
function import_generated_asset (line 945) | def import_generated_asset(
function get_hunyuan3d_status (line 978) | def get_hunyuan3d_status(ctx: Context) -> str:
function generate_hunyuan3d_model (line 995) | def generate_hunyuan3d_model(
function poll_hunyuan_job_status (line 1032) | def poll_hunyuan_job_status(
function import_generated_asset_hunyuan (line 1061) | def import_generated_asset_hunyuan(
function asset_creation_strategy (line 1090) | def asset_creation_strategy() -> str:
function main (line 1181) | def main():
FILE: src/blender_mcp/telemetry.py
function get_package_version (line 38) | def get_package_version() -> str:
class EventType (line 55) | class EventType(str, Enum):
class TelemetryEvent (line 65) | class TelemetryEvent:
class TelemetryCollector (line 84) | class TelemetryCollector:
method __init__ (line 87) | def __init__(self):
method _is_disabled (line 115) | def _is_disabled(self) -> bool:
method _get_data_directory (line 128) | def _get_data_directory(self) -> Path:
method _get_or_create_uuid (line 141) | def _get_or_create_uuid(self) -> str:
method _check_user_consent (line 165) | def _check_user_consent(self) -> bool:
method record_event (line 178) | def record_event(
method _worker_loop (line 245) | def _worker_loop(self):
method _send_event (line 257) | def _send_event(self, event: TelemetryEvent):
function get_telemetry (line 305) | def get_telemetry() -> TelemetryCollector:
function record_tool_usage (line 313) | def record_tool_usage(
function record_startup (line 329) | def record_startup(blender_version: str | None = None):
function is_telemetry_enabled (line 337) | def is_telemetry_enabled() -> bool:
FILE: src/blender_mcp/telemetry_decorator.py
function telemetry_tool (line 16) | def telemetry_tool(tool_name: str):
Condensed preview — 12 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (200K chars).
[
{
"path": ".gitignore",
"chars": 178,
"preview": "# Python-generated files\n__pycache__/\n*.py[oc]\nbuild/\ndist/\nwheels/\n*.egg-info\n\n# Virtual environments\n.venv\n\n# macOS\n.D"
},
{
"path": ".python-version",
"chars": 7,
"preview": "3.13.2\n"
},
{
"path": "LICENSE",
"chars": 1071,
"preview": "MIT License\n\nCopyright (c) 2025 Siddharth Ahuja\n\nPermission is hereby granted, free of charge, to any person obtaining a"
},
{
"path": "README.md",
"chars": 10076,
"preview": "\n\n# BlenderMCP - Blender Model Context Protocol Integration\n\nBlenderMCP connects Blender to Claude AI through the Model "
},
{
"path": "TERMS_AND_CONDITIONS.md",
"chars": 5104,
"preview": "# Blender MCP - Terms of Use and Privacy Policy\n\n**Last Updated: January 2025**\n\n---\n\n## 1. About This Project\n\nBlender "
},
{
"path": "addon.py",
"chars": 111920,
"preview": "# Code created by Siddharth Ahuja: www.github.com/ahujasid © 2025\n\nimport re\nimport bpy\nimport mathutils\nimport json\nimp"
},
{
"path": "main.py",
"chars": 171,
"preview": "from blender_mcp.server import main as server_main\n\ndef main():\n \"\"\"Entry point for the blender-mcp package\"\"\"\n se"
},
{
"path": "pyproject.toml",
"chars": 846,
"preview": "[project]\nname = \"blender-mcp\"\nversion = \"1.5.5\"\ndescription = \"Blender integration through the Model Context Protocol\"\n"
},
{
"path": "src/blender_mcp/__init__.py",
"chars": 202,
"preview": "\"\"\"Blender integration through the Model Context Protocol.\"\"\"\n\n__version__ = \"0.1.0\"\n\n# Expose key classes and functions"
},
{
"path": "src/blender_mcp/server.py",
"chars": 48936,
"preview": "# blender_mcp_server.py\nfrom mcp.server.fastmcp import FastMCP, Context, Image\nimport socket\nimport json\nimport asyncio\n"
},
{
"path": "src/blender_mcp/telemetry.py",
"chars": 11227,
"preview": "\"\"\"\nPrivacy-focused, anonymous telemetry for Blender MCP\nTracks tool usage, DAU/MAU, and performance metrics\n\"\"\"\n\nimport"
},
{
"path": "src/blender_mcp/telemetry_decorator.py",
"chars": 1990,
"preview": "\"\"\"\nTelemetry decorator for Blender MCP tools\n\"\"\"\n\nimport functools\nimport inspect\nimport logging\nimport time\nfrom typin"
}
]
About this extraction
This page contains the full source code of the ahujasid/blender-mcp GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 12 files (187.2 KB), approximately 39.8k tokens, and a symbol index with 107 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.