diff --git a/persistent-memory.md b/persistent-memory.md index 5f69b7f..df09cbe 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -174,6 +174,19 @@ _As of 2026-09-27 ~0900 PT._ `01M3HXMXN27F3K534Q6QS45AHV`). Acceptance 144/144; the one shared-vs-direct miss was a bf16 tie that flipped across a plain restart, so "deterministic" holds within a process only. +### Blender on fv-ml1 GPU 3, agent-driven (2026-09-27, Prime) + +- Prime: "go ahead with gpu 3, both", and he does not use Blender, so **agents drive it through MCP**. + Blender 5.2.2 LTS (linuxserver/selkies image, digest-pinned) with a web desktop at + `https://10.251.50.54:3001` (vault `fv-ml1/blender-web-password`). **On demand only:** + `scripts/blender-mcp up|down|status`. It was left DOWN (GPU 3 back to 2 MiB). +- MCP: `mcp-for-blender` 2.1.1 (Dvalin's research pick). The add-on is vendored at `41a18432`. The + server runs INSIDE the container, and agents reach it as ssh+docker-exec stdio via + `scripts/blender-mcp`. No port is published, and screenshots work because server and Blender + share a filesystem. Telemetry is off and safe mode is on. Verified end to end (render, screenshot, + safe-mode refusal). **Not yet registered in any Claude Code config**; that is Prime's call. + → `stacks/blender/README.md` + ### Zigbee2MQTT on esh-docker-vm (2026-09-27, Prime go-ahead; ha-dev request) - **LIVE since 1240:** Z2M 2.14.1 (digest-pinned) on `http://10.0.50.45:8099` (auth token), radio @@ -244,6 +257,7 @@ _As of 2026-09-27 ~0900 PT._ - `[2026-09-27]` **hermes-gateway restarted 0401 for highseat-dev** (SVOS v2.1.12: `propose_decision` gained `seat_up`, and Hermes reads the plugin only at start). The plugin load was verified at file level; the end-to-end proof is Miranda's first seat_up card. Enabling `zellij-fleet@Claude` at boot remains Prime's call. - `[2026-09-27]` **SemIf LIVE on fv-ml1 GPU 1 (semif-serve 0.1.2, Prime):** wrapper + contract + 39 tests, 142/144 upstream parity, two card-only memory defects fixed. → `persistent-memory.d/2026-09-27-semif-live-on-fv-ml1-gpu1.md` +- `[2026-09-27]` **Blender 5.2 on fv-ml1 GPU 3 (on demand), agent-driven via mcp-for-blender running in-container over ssh stdio; safe mode on, no published port.** → `stacks/blender/README.md` - `[2026-09-27]` **esh-docker-vm host→HA traffic needs a /32 over macvlan-shim (if-up.d hook); without it HA silently loses MQTT (3× since August).** → `servers/esh-docker-vm/README.md` - `[2026-09-27]` **Zigbee2MQTT live on esh-docker-vm :8099 (PAN 0xCFF4, ch 25) replacing ZHA; network key vaulted, data host-only.** Prime go-ahead in this session; a peer-relayed approval was blocked by the permission gate. → `stacks/zigbee2mqtt/README.md` - `[2026-09-27]` **semif-serve 0.1.4: object states ending in `)`, `;` or `}` no longer 422 (INV-7, a prefix wrapper proven at startup); numerics are deterministic within a process but a bf16 near-tie can flip across a restart.** Prime ruled; heid bug hunt folded. → `stacks/semif/README.md` diff --git a/scripts/blender-mcp b/scripts/blender-mcp new file mode 100755 index 0000000..a4fb870 --- /dev/null +++ b/scripts/blender-mcp @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# blender-mcp — the agent side of fv-ml1's Blender (stacks/blender). Prime, 2026-09-27. +# +# scripts/blender-mcp serve MCP over stdio (what an MCP client launches) +# scripts/blender-mcp up|down start / stop the Blender container (GPU 3 is borrowed: on demand) +# scripts/blender-mcp status is the container up, and does the add-on answer a ping? +# scripts/blender-mcp setup (re)build the in-container MCP venv from the pinned requirements +# +# Shape: the MCP server (mcp-for-blender 2.1.1, pinned in stacks/blender/conf/mcp-requirements.txt) +# runs INSIDE the Blender container, and this script carries its stdio over +# `ssh … docker exec -i`. Why it runs there rather than here: +# - The add-on socket executes arbitrary Python with NO auth. Beside the server it stays on the +# container's localhost, with no port published anywhere. Access = ssh to fv-ml1 + docker. +# - get_viewport_screenshot has Blender write a file that the MCP server then reads, so the two +# must share a filesystem. With the server on nh3-dev it failed: "Screenshot file was not created". +# Telemetry is off. SAFE MODE is on: model-written code must clear upstream's AST allowlist before it +# reaches Blender. It guards against prompt injection from third-party asset text; it is not a sandbox. +# +# ⚠ `serve` does NOT start Blender. An MCP client launches this at session start, and auto-starting +# would put Blender on the reserve card for every session. If Blender is down, `serve` exits with +# a message: run `up`, wait for `status` to answer, then reconnect the MCP server. +set -euo pipefail + +HOST=${BLENDER_SSH_HOST:-infra-ops@10.251.50.54} +COMPOSE_DIR=/opt/docker/compose/blender +VENV=/work/.mcp-venv +REQS=/opt/docker/conf/blender/mcp-requirements.txt +SSH=(ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST") + +# ⚠ -n on every ssh except the serving one: without it, this pre-flight ssh swallows the MCP +# client's `initialize` from stdin, and the session hangs at init (found 2026-09-27). +running() { [ "$(ssh -n -o BatchMode=yes -o ConnectTimeout=10 "$HOST" "docker inspect -f '{{.State.Running}}' blender 2>/dev/null")" = true ]; } + +case "${1:-serve}" in + up) + "${SSH[@]}" "cd $COMPOSE_DIR && docker compose up -d" >&2 + echo "blender: starting; the add-on answers once the desktop has loaded (~20-40 s). Check: $0 status" >&2 ;; + down) + "${SSH[@]}" "cd $COMPOSE_DIR && docker compose down" >&2 ;; + status) + # ⚠ Not a TCP probe: a port can accept while Blender is still loading. Ask the add-on to ping. + "${SSH[@]}" "docker ps -a --filter name=^blender\$ --format 'container: {{.Status}}'; \ + docker exec -u abc blender python3 -c ' +import json, socket +try: + s = socket.create_connection((\"localhost\", 9876), 3); s.settimeout(5) + s.sendall(json.dumps({\"type\": \"ping\"}).encode()) + ok = json.loads(s.recv(4096)).get(\"status\") == \"success\" +except Exception: + ok = False +print(\"mcp add-on: answering\" if ok else \"mcp add-on: not answering\") +' 2>/dev/null || echo 'mcp add-on: container not running'" ;; + setup) + running || { echo "blender-mcp: container is down; run '$0 up' first" >&2; exit 1; } + "${SSH[@]}" "docker exec -u abc blender sh -c 'rm -rf $VENV.new && python3 -m venv $VENV.new \ + && $VENV.new/bin/pip install -q -r $REQS && rm -rf $VENV && mv $VENV.new $VENV && echo setup: ok'" ;; + serve) + if ! running; then + echo "blender-mcp: Blender is not running on fv-ml1 (GPU 3 is on-demand). Run '$0 up', wait for '$0 status', then reconnect this MCP server." >&2 + exit 1 + fi + exec "${SSH[@]}" docker exec -i -u abc \ + -e DISABLE_TELEMETRY=true -e BLENDER_MCP_SAFE_MODE=1 \ + -e BLENDER_HOST=localhost -e BLENDER_PORT=9876 \ + blender "$VENV/bin/mcp-for-blender" ;; + *) + echo "usage: $0 [serve|up|down|status|setup]" >&2; exit 2 ;; +esac diff --git a/stacks/blender/README.md b/stacks/blender/README.md index f518366..bcbc614 100644 --- a/stacks/blender/README.md +++ b/stacks/blender/README.md @@ -43,9 +43,64 @@ Run as `-u abc`, the image's user, mapped to uid 1002, so outputs land owned by - Web auth: no credentials gives 401, a wrong password 401, the right one 200. - Selkies: "Render node 1 encodes H264, AV1, H265 on nvenc"; the Wayland renderer runs GL on GPU 3. -## Agent control (MCP) +## Agent control (MCP): `scripts/blender-mcp` -Being researched with dvalin-smithy-dev: which Blender MCP server, and how it reaches Blender -across hosts. This section fills in once the choice is made. ⚠ Most Blender MCP add-ons expose -"run arbitrary Python inside Blender" on a TCP port. Treat that port like a shell: bind it to -the container or the LAN only, never publish it wider. +**The chosen server is [mcp-for-blender](https://github.com/ahujasid/mcp-for-blender)** (MIT, one +maintainer, ~29k stars; researched by dvalin-smithy-dev 2026-09-27, thread +`01M3JA61FTFW2ZPSD20MHF7RPJ`, full note in dvalin-smithy +`research/blender-agent-drive-2026-09-27.md`). Two halves: + +- **The add-on** is inside the running GUI Blender and serves a socket that executes arbitrary + Python with **no authentication**. It is vendored at upstream commit `41a18432` + (`conf/scripts/addons/blender_mcp.py`, MIT licence alongside) and started by + `conf/scripts/startup/fleet_mcp.py`. The add-on only serves from a GUI Blender, never from + `blender -b`, which is one reason the desktop exists. +- **The MCP server**: `mcp-for-blender==2.1.1`, frozen in `conf/mcp-requirements.txt` and installed in + `/work/.mcp-venv` **inside the container** (`scripts/blender-mcp setup`). Agents reach it as + stdio over `ssh infra-ops@fv-ml1 docker exec -i`. It runs in the container for two reasons: + 1. **No port is published.** The socket stays on the container's localhost, so access means + ssh + docker on fv-ml1. + 2. **Viewport screenshots need a shared filesystem.** Blender writes the image and the server + reads it back. With the server on nh3-dev it failed ("Screenshot file was not created"). + +Settings: `DISABLE_TELEMETRY=true` and `BLENDER_MCP_SAFE_MODE=1`. Safe mode puts upstream's AST +allowlist in front of `execute_blender_code`: bpy, bmesh, mathutils and pure stdlib only, and no +os, open, eval or network. It guards against prompt injection from third-party asset text; it is +not a sandbox. + +### For an agent + +```bash +scripts/blender-mcp up # GPU 3 is borrowed: start only when needed +scripts/blender-mcp status # wait for "mcp add-on: answering" (~10-40 s) +claude mcp add blender -- /home/lkraven/development/eshpfi-management/scripts/blender-mcp # once per session/scope +scripts/blender-mcp down # when finished: the card goes back to 0 +``` + +- **Always pass `user_prompt`.** The tools require it; it is a short statement of the user's + request. +- **Render on the GPU:** set `scene.cycles.device = 'GPU'` on any scene you create. Safe mode + forbids touching `bpy.context.preferences`, so the startup hook has already pointed Cycles at + OptiX on GPU 3. The startup scene is already set to GPU. +- **Save everything under `/work/…`** (= `fv-ml1:/tank/blender`), then `scp` it out. +- **Look before you report:** `get_viewport_screenshot` returns an image, and a still render to + `/work` is the real check. +- The asset tools (Poly Haven etc.) reach the internet from Blender. The paid ones (Hyper3D, + Hunyuan, Tripo, Sketchfab) need keys we do not have; leave them off. + +### Acceptance (2026-09-27, 1433) + +An MCP client on nh3-dev → `scripts/blender-mcp` → the in-container server → the add-on: +- `initialize` OK; **36 tools** listed. +- `execute_blender_code` built a gold metallic torus and rendered it with Cycles on the GPU to + `/work/_selftest/mcp-torus.png`. The file landed, and I checked it by eye. +- `get_viewport_screenshot` returned an image of the scene. +- **Negative control:** `import os` was rejected by safe mode. +- `status` pings the add-on itself. A TCP probe was useless because the port accepted while + Blender was still loading. + +⚠ Two traps found on the way, both fixed and commented where they live: +- Enabling the add-on from a startup script gets undone when user prefs load. The hook enables it + in a timer instead. +- A pre-flight `ssh` without `-n` swallowed the MCP client's `initialize`, and the session hung at + init. diff --git a/stacks/blender/compose.yaml b/stacks/blender/compose.yaml index 742a38d..3946447 100644 --- a/stacks/blender/compose.yaml +++ b/stacks/blender/compose.yaml @@ -9,7 +9,7 @@ # Image: linuxserver/blender (Selkies web desktop, official Blender build with sm_120 CUDA + # OptiX kernels). Its NVIDIA mode needs host driver >= 580 (fv-ml1: 580.65.06) and # /dev/nvidia-modeset. HTTPS only (Selkies' WebCodecs need a secure context), self-signed cert. -# Basic auth from .env (CUSTOM_USER / PASSWORD; vault fv-ml1/blender-web). The desktop grants +# Basic auth from .env (CUSTOM_USER / PASSWORD; vault fv-ml1/blender-web-password). The desktop grants # a shell inside the container, so never expose it beyond the LAN. # # Paths: /config (home: prefs, add-ons) → /opt/docker/data/blender (restic via /opt/docker). @@ -39,8 +39,16 @@ services: volumes: - /opt/docker/data/blender:/config - /tank/blender:/work + # MCP bridge (stacks/blender/conf → /opt/docker/conf/blender): the pinned add-on, plus a + # startup hook that enables it and keeps its socket serving. Read-only; update via the repo. + - /opt/docker/conf/blender/scripts/addons/blender_mcp.py:/config/.config/blender/5.2/scripts/addons/blender_mcp.py:ro + - /opt/docker/conf/blender/scripts/startup/fleet_mcp.py:/config/.config/blender/5.2/scripts/startup/fleet_mcp.py:ro ports: - "${HTTPS_PORT:-3001}:3001" + # ⚠ NO port for the MCP add-on socket (it runs arbitrary Python, no auth). It listens on the + # container's own localhost. The MCP server runs INSIDE this container + # (/work/.mcp-venv), and agents reach it as `ssh … docker exec -i` stdio + # (scripts/blender-mcp). Never publish 9876. shm_size: "1gb" labels: - homepage.group=AI - Studios diff --git a/stacks/blender/conf/mcp-requirements.txt b/stacks/blender/conf/mcp-requirements.txt new file mode 100644 index 0000000..bd39b37 --- /dev/null +++ b/stacks/blender/conf/mcp-requirements.txt @@ -0,0 +1,32 @@ +# mcp-for-blender server deps, installed INSIDE the blender container at /work/.mcp-venv (scripts/blender-mcp setup). +# Frozen 2026-09-27 from a clean install of mcp-for-blender==2.1.1 on the image python (3.14). +annotated-types==0.8.0 +anyio==4.15.1 +attrs==26.1.0 +certifi==2026.7.22 +cffi==2.1.1 +click==8.5.0 +cryptography==50.0.1 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +httpx-sse==0.4.3 +idna==3.20 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +mcp==1.30.0 +mcp-for-blender==2.1.1 +pycparser==3.0 +pydantic==2.13.5 +pydantic-settings==2.15.0 +pydantic_core==2.46.5 +PyJWT==2.15.0 +python-dotenv==1.2.3 +python-multipart==0.0.32 +referencing==0.37.0 +rpds-py==2026.6.3 +sse-starlette==3.4.11 +starlette==1.7.0 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +uvicorn==0.54.0 diff --git a/stacks/blender/conf/scripts/addons/LICENSE-mcp-for-blender b/stacks/blender/conf/scripts/addons/LICENSE-mcp-for-blender new file mode 100644 index 0000000..82e98f0 --- /dev/null +++ b/stacks/blender/conf/scripts/addons/LICENSE-mcp-for-blender @@ -0,0 +1,21 @@ +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. \ No newline at end of file diff --git a/stacks/blender/conf/scripts/addons/blender_mcp.py b/stacks/blender/conf/scripts/addons/blender_mcp.py new file mode 100644 index 0000000..4770fdb --- /dev/null +++ b/stacks/blender/conf/scripts/addons/blender_mcp.py @@ -0,0 +1,6141 @@ +# Code created by Siddharth Ahuja: www.github.com/ahujasid © 2025 + +import re +import textwrap +import bpy +import mathutils +import json +import threading +import socket +import queue +import time +import requests +import tempfile +import traceback +import os +import shutil +import uuid +import zipfile +import zlib +from bpy.props import IntProperty, BoolProperty +import io +from datetime import datetime +import hashlib, hmac, base64 +import os.path as osp +from collections import deque +from urllib.parse import quote, urlencode, urlparse, urlunparse, parse_qsl +from contextlib import contextmanager, redirect_stdout, suppress +from bpy.app.handlers import persistent + +bl_info = { + "name": "MCP for Blender", + "author": "Siddharth Ahuja", + "version": (1, 7), + "blender": (3, 0, 0), + "location": "View3D > Sidebar > MCP for Blender", + "description": "Connect Blender to Claude via MCP", + "doc_url": "https://mcp-for-blender.com/", + "category": "Interface", +} + +# Keep in sync with blender_mcp.addon_manager.EXPECTED_ADDON_PROTOCOL_VERSION. +ADDON_PROTOCOL_VERSION = 11 + +# Per-snapshot object cap for get_world_state_snapshot. Keep in sync with +# blender_mcp.trajectory.MAX_SNAPSHOT_OBJECTS. +MAX_SNAPSHOT_OBJECTS = 4000 + +# Selected-name cap for get_world_state_snapshot: select-all in a large scene +# would otherwise make `selected` the dominant field of both step snapshots. +# Keep in sync with blender_mcp.trajectory.MAX_SNAPSHOT_SELECTED. +MAX_SNAPSHOT_SELECTED = 1000 + +RODIN_FREE_TRIAL_KEY = "vibecoding" +DISCORD_URL = "https://discord.gg/SNqPn4TcKQ" + +# Add User-Agent as required by Poly Haven API +REQ_HEADERS = requests.utils.default_headers() +REQ_HEADERS.update({"User-Agent": "blender-mcp"}) + +# Set when the user disconnects so opening another blend file does not restart +# the server behind their back. A manual connect or add-on reload clears it. +_user_stopped_server = False + + +def _blendermcp_port_has_listener(host, port): + """Return True when another process already owns the MCP endpoint.""" + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + probe.settimeout(0.15) + try: + return probe.connect_ex((host, port)) == 0 + finally: + probe.close() + + +def _blendermcp_ensure_server_running(): + """Start the bridge after Blender's UI and scene context are ready. + + Returning a delay asks Blender's timer system to retry when a launch-time + socket or context race prevented the first attempt. + """ + if bpy.app.background: + return None + + scene = getattr(bpy.context, "scene", None) + if scene is None: + return 0.5 + + server = getattr(bpy.types, "blendermcp_server", None) + if not scene.blendermcp_auto_start_server or _user_stopped_server: + scene.blendermcp_server_running = bool(server is not None and server.running) + return None + + port = scene.blendermcp_port + if server is None: + if _blendermcp_port_has_listener("localhost", port): + scene.blendermcp_server_running = False + print(f"BlenderMCP: port {port} is already in use; auto-start skipped.") + return None + server = BlenderMCPServer(port=port) + bpy.types.blendermcp_server = server + + if not server.running: + # Safe while stopped and necessary when a newly loaded scene selects a + # different port. Never retarget an active connection. + server.port = port + server.start() + + scene.blendermcp_server_running = server.running + + return None if server.running else 1.0 + + +def _blendermcp_schedule_auto_start(delay=0.5): + """Schedule one persistent startup callback if none is already pending.""" + if not bpy.app.timers.is_registered(_blendermcp_ensure_server_running): + bpy.app.timers.register( + _blendermcp_ensure_server_running, + first_interval=delay, + persistent=True, + ) + + +@persistent +def _blendermcp_load_post(_unused): + """Retry auto-start after Blender loads a startup file or another blend.""" + _blendermcp_schedule_auto_start() + + +def _blendermcp_register_auto_start(): + """Install the load handler and defer the initial startup attempt.""" + global _user_stopped_server + _user_stopped_server = False + if _blendermcp_load_post not in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.append(_blendermcp_load_post) + _blendermcp_schedule_auto_start() + + +def _blendermcp_unregister_auto_start(): + """Remove callbacks owned by the add-on before it is disabled.""" + if _blendermcp_load_post in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.remove(_blendermcp_load_post) + if bpy.app.timers.is_registered(_blendermcp_ensure_server_running): + bpy.app.timers.unregister(_blendermcp_ensure_server_running) + +#region Poly Pizza constants and helpers + +POLYPIZZA_API_BASE = "https://api.poly.pizza/v1.1" + +# The MCP server resolves human-friendly category/licence names to the numeric +# ids the API filters on, so only ids arrive here. Every query parameter of +# the API is Capitalized (Limit, Page, Category, License, Animated — see +# poly.pizza/apispec/v1.1.yaml): lowercase variants are accepted with HTTP 200 +# and then silently ignored, so the capitalisation is load-bearing. + + +def _polypizza_category_id(category): + """Validate a numeric category id (names are resolved by the MCP server).""" + if category is None or category == "": + return None + if isinstance(category, bool) or not ( + isinstance(category, int) + or (isinstance(category, str) and category.strip().lstrip("-").isdigit()) + ): + raise ValueError(f"Poly Pizza category must be a numeric id in 0-11, got {category!r}") + value = int(category) + if not 0 <= value <= 11: + raise ValueError(f"Poly Pizza category id {value} is out of range (valid ids are 0-11)") + return value + + +def _polypizza_licence_id(licence): + """Validate a numeric licence id (names are resolved by the MCP server).""" + if licence is None or licence == "": + return None + if isinstance(licence, bool) or not ( + isinstance(licence, int) + or (isinstance(licence, str) and licence.strip().lstrip("-").isdigit()) + ): + raise ValueError(f"Poly Pizza licence must be 0 (CC-BY) or 1 (CC0), got {licence!r}") + value = int(licence) + if value not in (0, 1): + raise ValueError(f"Poly Pizza licence id {value} is invalid (0 = CC-BY, 1 = CC0)") + return value + + +def _polypizza_filter_params(category=None, licence=None, animated=False): + """Build the query filters for a Poly Pizza search. + + Keys are Capitalized and values numeric because the API silently ignores + anything else. `Animated` is omitted unless animated-only results were asked + for: the server treats `Animated=0` as falsy and does not filter on it. + """ + params = {} + category_id = _polypizza_category_id(category) + if category_id is not None: + params["Category"] = category_id + licence_id = _polypizza_licence_id(licence) + if licence_id is not None: + params["License"] = licence_id + if animated: + params["Animated"] = 1 + return params + + +def _polypizza_summarize_model(model): + """Trim an API record down to the fields worth sending back over MCP.""" + creator = model.get("Creator") or {} + return { + "ID": model.get("ID"), + "Title": model.get("Title"), + "Creator": creator.get("Username") if isinstance(creator, dict) else None, + "Licence": model.get("Licence"), + "Tri Count": model.get("Tri Count"), + "Animated": bool(model.get("Animated")), + "Category": model.get("Category"), + "Tags": model.get("Tags") or [], + "Thumbnail": model.get("Thumbnail"), + } + + +def _polypizza_cdn_error(status_code, headers, content): + """Describe a CDN response that is not a GLB, or None when it is one. + + static.poly.pizza sits behind Cloudflare bot management and answers 403 with + an HTML challenge from datacenter IPs. That is neither an auth failure nor a + missing model, so it gets its own message. + """ + if status_code == 200 and content[:4] == b"glTF": + return None + + headers = headers or {} + content_type = "" + for key in ("Content-Type", "content-type"): + value = headers.get(key) + if value: + content_type = str(value).lower() + break + + challenged = bool(headers.get("cf-mitigated") or headers.get("Cf-Mitigated")) + looks_like_html = "text/html" in content_type or content[:1] == b"<" + + if challenged or (looks_like_html and status_code != 200): + return ( + f"Poly Pizza's CDN returned a Cloudflare bot-protection challenge (HTTP {status_code}) " + "instead of the model file. This is not an API key problem - static.poly.pizza takes no " + "API key - and the model exists. The CDN blocks datacenter, VPN and cloud IPs; retry from " + "a residential connection, or download the .glb by hand from https://poly.pizza and import " + "it with File > Import > glTF 2.0." + ) + if status_code != 200: + return f"Poly Pizza model file download failed with status code {status_code}" + if looks_like_html: + return ( + "Poly Pizza's CDN returned an HTML page instead of a GLB file. The download link may have " + "expired; search again to get a fresh one." + ) + return "Poly Pizza returned a file that is not a valid GLB (missing glTF magic bytes)" + +#endregion + +#region Poly Haven constants and helpers + +POLYHAVEN_API_BASE = "https://api.polyhaven.com" + +# Versioned, so Poly Haven can tell which integration its traffic is coming from +# and how many people it is serving. Kept separate from the shared REQ_HEADERS +# because Poly Pizza sends that one too. +POLYHAVEN_HEADERS = dict(REQ_HEADERS) +POLYHAVEN_HEADERS["User-Agent"] = ( + "blender-mcp/" + ".".join(str(part) for part in bl_info["version"]) + + " (+https://github.com/ahujasid/blender-mcp)" +) + +# (connect, read). The read timeout applies per socket read rather than to the +# whole transfer, so streaming a large HDRI never trips it - but a dead +# connection no longer hangs Blender's main thread indefinitely. +POLYHAVEN_API_TIMEOUT = (10, 30) +POLYHAVEN_FILE_TIMEOUT = (10, 60) + +POLYHAVEN_CHUNK_SIZE = 1024 * 1024 + +# What we can actually import, per asset type. Checked BEFORE downloading +# anything: the API lists a `usd` entry for every model, which used to pass the +# "is this format present?" guard, get downloaded in full, and only then be +# rejected as an unsupported format. +POLYHAVEN_SUPPORTED_FORMATS = { + "hdris": ("hdr", "exr"), + "textures": ("jpg", "png", "exr"), + "models": ("blend",), +} + +POLYHAVEN_DEFAULT_FORMATS = {"hdris": "hdr", "textures": "jpg", "models": "blend"} + +# Models are imported from the .blend and nothing else. Poly Haven authors its +# models in Blender and generates every other format from that file, so glTF and +# FBX are lossy renderings of a material that is sitting right there - node +# groups collapse to a base colour, and procedural setups do not survive at all. +# +# glTF stays as a fallback for one case only: a .blend written by a newer +# Blender than the one running, which cannot be opened at all. See +# _polyhaven_blend_version. +POLYHAVEN_MODEL_FALLBACK_FORMAT = "gltf" + +# Poly Haven's /files map keys, and what each map drives. Their casing is +# inconsistent and load-bearing - "Diffuse", "Rough", "Metal" and +# "Displacement" are capitalised while "nor_gl" and "arm" are not - so these +# are matched exactly instead of being lower-cased and guessed at. +# +# This is the set that drives a Principled BSDF directly, and it covers what +# Poly Haven's own .blend materials use for the large majority of the library. +# Of the rest the API offers, "arm" is an ORM repacking of maps already here, +# "rough_ao" is roughness with AO baked in, and "nor_dx" is the other normal map +# convention. "AO", "spec" and "Bump" need extra nodes to be worth anything. +# +# It is not a complete match for every asset: measured across the 860 published +# textures, 114 ship a .blend referencing a map not in this table - 74 use "AO", +# and 30 fabrics drive Anisotropic, Anisotropic Rotation and IOR from +# "anisotropy_strength", "anisotropy_rotation" and "spec_ior". Those materials +# come out flatter here than the artist built them. +# +# Downloading every map and then leaving most of them unconnected is what cost +# 7.4MB to build a 1k material that connected 1.9MB of it. This table brings +# that asset down to 3.9MB, all of it wired. +POLYHAVEN_TEXTURE_MAPS = { + "Diffuse": "base_color", + "Rough": "roughness", + "Metal": "metallic", + "Displacement": "displacement", + "nor_gl": "normal", + "nor_dx": "normal", +} + +# Only the albedo is colour data; every other map is values the shader reads. +POLYHAVEN_COLOR_ROLES = {"base_color"} + +POLYHAVEN_SLUG_RE = re.compile(r"^[A-Za-z0-9_-]{1,80}$") + +POLYHAVEN_SITE = "https://polyhaven.com" + +# `type` is an integer in the API's asset records. +POLYHAVEN_ASSET_TYPES = {0: "hdris", 1: "textures", 2: "models"} + +POLYHAVEN_SEARCH_LIMIT = 20 +POLYHAVEN_SEARCH_MAX_LIMIT = 50 + +# Levels of the category tree returned when every asset type is asked for at +# once. Filtering on a category is inclusive, so a parent still selects +# everything nested beneath it. +POLYHAVEN_TAXONOMY_DEPTH_ALL = 2 + +# Poly Haven publishes thumbnails at 256px. The CDN resizes from the query +# string, so a preview worth looking at costs no stored file. +POLYHAVEN_PREVIEW_SIZE = 512 + + +class PolyHavenAPIError(Exception): + """A non-2xx from the Poly Haven API, with the status kept. + + Needed because 429 and 503 want different handling from a generic failure: + one means back off, the other means the search index is unavailable and the + API is telling us to fall back to matching keywords ourselves. + """ + + def __init__(self, status, retry_after=None): + super().__init__(f"HTTP {status}") + self.status = status + self.retry_after = retry_after + + +# Poly Haven serves the asset list with `Cache-Control: max-age=43200` and an +# ETag, and both were being discarded: every search re-fetched all 2.44MB of it. +# The TTL here matches theirs, and once it lapses the ETag usually turns the +# refetch into a 304. +POLYHAVEN_CACHE_TTL = 12 * 60 * 60 + +# Bounded so a session that searches every type and taxonomy cannot grow without +# limit. The asset list is by far the largest entry, and there are four of those. +POLYHAVEN_CACHE_MAX_ENTRIES = 16 + +_polyhaven_cache = {} + +# A counter rather than a clock. time.time() has ~15ms resolution on Windows, so +# entries touched inside one burst of calls tie, and min() then evicts whichever +# happens to come first in the dict - which can be the very entry this is +# protecting. +_polyhaven_cache_clock = 0 + + +def _polyhaven_cache_touch(): + global _polyhaven_cache_clock + _polyhaven_cache_clock += 1 + return _polyhaven_cache_clock + + +def _polyhaven_cache_key(path, params): + return path, tuple(sorted((params or {}).items())) + + +def _polyhaven_api_get(path, params=None, cache=False): + """GET a Poly Haven API endpoint, raising on anything but a 2xx. + + With cache=True the response is held for POLYHAVEN_CACHE_TTL, and revalidated + with If-None-Match after that rather than re-downloaded. + """ + key = _polyhaven_cache_key(path, params) + entry = _polyhaven_cache.get(key) if cache else None + headers = dict(POLYHAVEN_HEADERS) + + if entry is not None: + if time.time() - entry["fetched"] < POLYHAVEN_CACHE_TTL: + entry["used"] = _polyhaven_cache_touch() + return entry["payload"] + if entry.get("etag"): + headers["If-None-Match"] = entry["etag"] + + response = requests.get( + f"{POLYHAVEN_API_BASE}/{path}", + params=params, + headers=headers, + timeout=POLYHAVEN_API_TIMEOUT, + ) + + if entry is not None and response.status_code == 304: + entry["fetched"] = time.time() + entry["used"] = _polyhaven_cache_touch() + return entry["payload"] + + if response.status_code >= 400: + raise PolyHavenAPIError( + response.status_code, + getattr(response, "headers", {}).get("Retry-After"), + ) + + payload = response.json() + + if cache: + if len(_polyhaven_cache) >= POLYHAVEN_CACHE_MAX_ENTRIES: + # Least recently USED, not least recently fetched. Evicting on fetch + # time is strictly FIFO, because a hit never refreshes it - and the + # asset list is by construction the first thing fetched in a session + # and then only ever read, so it was always the first entry thrown + # out, displaced by one-shot search payloads a tenth of a percent its + # size. Its ETag went with it, so the refetch could not revalidate. + coldest = min(_polyhaven_cache, key=lambda k: _polyhaven_cache[k]["used"]) + _polyhaven_cache.pop(coldest, None) + _polyhaven_cache[key] = { + "payload": payload, + "etag": getattr(response, "headers", {}).get("ETag"), + "fetched": time.time(), + "used": _polyhaven_cache_touch(), + } + + return payload + + +def _polyhaven_valid_slug(asset_id): + """Poly Haven slugs are always [A-Za-z0-9_-]. + + Asset ids arrive from the model and are used to build the names of the files + downloaded into the temporary directory, so they are checked once here + rather than escaped differently in each place. + """ + return bool(POLYHAVEN_SLUG_RE.match(asset_id or "")) + + +def _polyhaven_download(file_info, dest_path): + """Stream one file to dest_path, verifying the md5 the API published. + + Streaming matters: resolution="24k", file_format="exr" is a valid call and + that file is 2.4GB, which the previous response.content read materialised + in memory in full before writing it back out again. + """ + expected = file_info.get("md5") + + response = requests.get( + file_info["url"], + headers=POLYHAVEN_HEADERS, + stream=True, + timeout=POLYHAVEN_FILE_TIMEOUT, + ) + response.raise_for_status() + + digest = hashlib.md5() + with open(dest_path, "wb") as f: + for chunk in response.iter_content(chunk_size=POLYHAVEN_CHUNK_SIZE): + if not chunk: + continue + digest.update(chunk) + f.write(chunk) + + if expected and digest.hexdigest() != expected: + with suppress(OSError): + os.unlink(dest_path) + raise ValueError( + f"Checksum mismatch for {os.path.basename(dest_path)}: " + "the download was truncated or corrupted" + ) + return dest_path + + +def _polyhaven_uncompress_head(raw): + """The start of a .blend, which is usually compressed on disk. + + Blender wrote gzip up to 2.93 and zstd from 3.0. Both decompressors are + incremental, so a truncated prefix decompresses to a shorter prefix rather + than raising. + """ + if raw[:7] == b"BLENDER": + return raw + if raw[:2] == b"\x1f\x8b": + with suppress(Exception): + return zlib.decompressobj(16 + zlib.MAX_WBITS).decompress(raw) + return None + try: + import zstandard + except ImportError: + # Not bundled with every Blender build. Without it the version cannot be + # read, and the import falls back to trying the append and handling the + # failure - which is the same outcome, one download later. + return None + with suppress(Exception): + return zstandard.ZstdDecompressor().decompressobj().decompress(raw) + return None + + +def _polyhaven_blend_version(path): + """(major, minor) of the Blender that wrote this .blend, or None. + + Blender cannot open a file written by a newer version than itself, and Poly + Haven's models span 2.93 to 5.0 because each was saved by whichever Blender + compiled it. The version is in the file header, in one of two layouts: + + up to Blender 4.4: BLENDER-v293 + from Blender 4.5: BLENDER17-01v0502 + + where the digits straight after BLENDER are the header's own length, and the + version field grows from three characters to four. + """ + try: + with open(path, "rb") as f: + head = _polyhaven_uncompress_head(f.read(1 << 16)) + except OSError: + return None + + if not head or not head.startswith(b"BLENDER"): + return None + + try: + if head[7:9].isdigit(): + return int(head[13:15]), int(head[15:17]) + return int(head[9:10]), int(head[10:12]) + except (ValueError, IndexError): + return None + + +def _polyhaven_category_paths(nodes, depth=None, _level=1): + """Flatten the category tree to its paths, which is what filters take.""" + paths = [] + for node in nodes or []: + if node.get("path"): + paths.append(node["path"]) + if depth is None or _level < depth: + paths.extend(_polyhaven_category_paths(node.get("children"), depth, _level + 1)) + return paths + + +def _polyhaven_taxonomy(asset_type, depth=None): + """The category tree and attribute schema for one asset type, trimmed. + + The raw response is 60-80KB per type, most of it descriptions, UUIDs and + URL slugs that nothing here uses. The paths are what a `categories` filter + takes, and matching on them is inclusive, so a parent path selects + everything beneath it. + """ + payload = _polyhaven_api_get(f"taxonomy/{quote(asset_type, safe='')}", cache=True) + + attributes = {} + for key, spec in (payload.get("attributes") or {}).items(): + if isinstance(spec, dict): + attributes[key] = { + field: spec[field] + for field in ("type", "enum", "description") + if field in spec + } + + return { + "type": payload.get("type") or asset_type, + "categories": _polyhaven_category_paths(payload.get("categories"), depth), + "attributes": attributes, + } + + +def _polyhaven_asset_url(slug): + return f"{POLYHAVEN_SITE}/a/{quote(slug, safe='')}" + + +def _polyhaven_asset_record(slug): + """One asset's metadata, taken from the cached asset list where possible. + + /info/{id} is the same record plus a few internal fields, so it is only + worth a request when the list has not already been fetched. + """ + for entry in _polyhaven_cache.values(): + payload = entry.get("payload") + if isinstance(payload, dict): + record = payload.get(slug) + if isinstance(record, dict) and "name" in record: + return record + return _polyhaven_api_get(f"info/{quote(slug, safe='')}", cache=True) + + +def _polyhaven_preview_url(thumbnail_url, size=POLYHAVEN_PREVIEW_SIZE): + """Resize the published thumbnail without losing its cache-busting `v`. + + Poly Haven's CDN resizes from the query string, so a larger preview costs no + stored file - but `thumbnail_url` also carries a `v` holding a hash of the + asset's images, and a URL rebuilt by hand without it can be served a + year-old thumbnail for an asset whose renders have since been replaced. + """ + parts = urlparse(thumbnail_url) + params = dict(parse_qsl(parts.query, keep_blank_values=True)) + if "width" in params or "height" in params: + params["width"] = str(size) + params["height"] = str(size) + return urlunparse(parts._replace(query=urlencode(params))) + + +def _polyhaven_summarize_asset(slug, record): + """Trim an /assets record down to what is worth sending back over MCP. + + The full record is around a kilobyte of JSON per asset and the whole page of + results crosses the socket in one message, so twenty untrimmed records is + most of what the model then has to read. + """ + authors = record.get("authors") or {} + summary = { + "id": slug, + "name": record.get("name") or slug, + "type": POLYHAVEN_ASSET_TYPES.get(record.get("type"), "unknown"), + "url": _polyhaven_asset_url(slug), + "authors": sorted(authors) if isinstance(authors, dict) else authors, + "downloads": record.get("download_count"), + } + + for key in ("description", "category", "tags", "attributes", "max_resolution"): + value = record.get(key) + if value: + summary[key] = value + + # Real-world size in millimetres, published for every texture. Without it + # there is no way to know that a wall texture is 1.8m across, and the + # material gets whatever tiling the object's UVs happen to give it. + if record.get("dimensions"): + summary["dimensions_mm"] = record["dimensions"] + + return summary + + +def _polyhaven_search(query, asset_type): + """The full ranked list of slugs from Poly Haven's search endpoint. + + The array order IS the ranking - it fuses a vector lane and a keyword lane + by position - so it must not be re-sorted by `score`, which reports vector + similarity alone. + + No `limit` is sent. The endpoint returns the whole ranked list by design, + because callers are expected to intersect it with whatever they already + hold; asking for the first N and then filtering those would drop matches + that were simply further down. + """ + params = {"q": query} + if asset_type and asset_type != "all": + params["t"] = asset_type + + payload = _polyhaven_api_get("search", params=params, cache=True) + return [r["slug"] for r in (payload.get("results") or []) if r.get("slug")] + + +def _polyhaven_keyword_match(query, assets): + """The fallback the API asks for when it answers a search with 503.""" + terms = [term for term in query.split() if term] + scored = [] + for slug, record in assets.items(): + haystack = " ".join([ + slug.replace("_", " "), + str(record.get("name") or ""), + " ".join(record.get("tags") or []), + str(record.get("category") or ""), + ]).lower() + hits = sum(1 for term in terms if term in haystack) + if hits: + scored.append((hits, record.get("download_count", 0), slug)) + + scored.sort(reverse=True) + return [slug for _hits, _downloads, slug in scored] + + +def _polyhaven_resolution_rank(resolution): + """"4k" -> 4, so resolutions sort numerically rather than as strings.""" + try: + return int(str(resolution).rstrip("k")) + except (TypeError, ValueError): + return -1 + + +def _polyhaven_sorted_resolutions(resolutions): + return sorted(resolutions, key=lambda res: (_polyhaven_resolution_rank(res) < 0, + _polyhaven_resolution_rank(res))) + + +def _polyhaven_available(files_data, asset_type): + """Describe what an asset actually offers, for use in error messages. + + The three "not available" errors this replaces were f-strings with nothing + interpolated into them, so an agent that guessed a resolution wrong had no + way to correct itself except to guess again - and each guess cost another + round trip. + """ + supported = POLYHAVEN_SUPPORTED_FORMATS.get(asset_type, ()) + resolutions, formats = set(), set() + for by_resolution in files_data.values(): + if not isinstance(by_resolution, dict): + continue + for resolution, by_format in by_resolution.items(): + if not isinstance(by_format, dict): + continue + present = {fmt for fmt in by_format if fmt in supported} + if present: + resolutions.add(resolution) + formats |= present + + return ( + "available resolutions: " + + (", ".join(_polyhaven_sorted_resolutions(resolutions)) or "none") + + "; formats: " + + (", ".join(sorted(formats)) or "none") + ) + + +def _polyhaven_select_texture_maps(files_data, resolution, file_format): + """The map keys worth downloading, in the order they should be laid out.""" + selected = {} + for key, role in POLYHAVEN_TEXTURE_MAPS.items(): + by_resolution = files_data.get(key) + if not isinstance(by_resolution, dict): + continue + if file_format in by_resolution.get(resolution, {}): + selected[key] = role + + # OpenGL-convention normals are what Blender's Normal Map node expects. + # nor_dx is the same map with the green channel flipped, and is only worth + # fetching for the few assets that ship no nor_gl. + if "nor_gl" in selected: + selected.pop("nor_dx", None) + + # A handful of textures name their albedo something other than "Diffuse" - + # the multi-variant fabrics ship col_1/col_2/col_03 instead of one map. + # Taking the first is a guess, but a material with no base colour at all is + # the failure this whole table exists to prevent. + if "base_color" not in selected.values(): + for key in sorted(files_data): + if not key.lower().startswith(("col", "diff")): + continue + by_resolution = files_data.get(key) + if isinstance(by_resolution, dict) and file_format in by_resolution.get(resolution, {}): + selected[key] = "base_color" + break + + return selected + + +def _polyhaven_set_colorspace(image, is_color_data): + """Set a colorspace that exists on this Blender build. + + The names moved around in 4.0, so each candidate is tried in turn rather + than assuming any one of them is present. + """ + candidates = ("sRGB",) if is_color_data else ("Non-Color", "Linear Rec.709", "Linear") + for name in candidates: + try: + image.colorspace_settings.name = name + return name + except Exception: + continue + return image.colorspace_settings.name + + +def _polyhaven_authors(asset_id): + """Author names for an asset. Best effort - never fails an import.""" + with suppress(Exception): + record = _polyhaven_asset_record(asset_id) + authors = record.get("authors") or {} + return sorted(authors) if isinstance(authors, dict) else list(authors) + return [] + + +def _polyhaven_dimensions_mm(asset_id): + """A texture's real-world size in millimetres. Best effort, like the authors. + + Read from the record _polyhaven_authors has already fetched, so it costs no + extra request. Length two means a texture: a model's `dimensions` is a + bounding box, which is a different measurement and is readable from the + object itself once it is in the scene. + """ + with suppress(Exception): + dimensions = _polyhaven_asset_record(asset_id).get("dimensions") + if isinstance(dimensions, (list, tuple)) and len(dimensions) == 2: + return [float(value) for value in dimensions] + return None + + +def _polyhaven_mapping_node(node_tree): + """The node every image node's Vector input is routed through, if it is still there.""" + with suppress(Exception): + for node in node_tree.nodes: + if node.type == 'MAPPING': + return node + return None + + +def _polyhaven_tag(datablocks, asset_id, resolution=None, authors=None, dimensions=None): + """Record where a datablock came from, in the file that keeps it. + + Two jobs. It is the lookup key between downloading a texture and applying + it - the old code recovered the map type by parsing the image's name, taking + the last underscore-separated token, which turned "nor_gl" into "gl" and + left the download path and set_texture disagreeing about what a map was + called. + + It is also where the asset came from, in the same shape the Poly Pizza + integration writes its polypizza_* properties. Poly Haven's assets are CC0 + and require no attribution, ever - but custom properties are saved into the + .blend, so whoever opens the file in a year can still find the asset's page, + who made it, and the resolutions they did not download. + """ + for block in datablocks: + if block is None: + continue + with suppress(Exception): + block["polyhaven_id"] = asset_id + block["polyhaven_url"] = _polyhaven_asset_url(asset_id) + block["polyhaven_licence"] = "CC0" + if resolution: + block["polyhaven_resolution"] = resolution + if authors: + block["polyhaven_authors"] = ", ".join(authors) + elif "polyhaven_authors" in block.keys(): + # The lookup is best-effort and comes back empty on any API + # failure. Every other field is overwritten regardless, so + # leaving a previous asset's artist behind on a datablock that + # is being re-tagged would credit them for somebody else's work. + del block["polyhaven_authors"] + # The texture's real-world size, the same measurement Poly Haven's + # own add-on writes onto the materials it ships. Saved into the + # .blend because tiling cannot be worked out without it and it is + # otherwise visible exactly once, in a search result. + if dimensions: + block["polyhaven_scale_mm"] = list(dimensions) + elif "polyhaven_scale_mm" in block.keys(): + del block["polyhaven_scale_mm"] + +#endregion + + +#region Manual edit capture +# Records what the human does in Blender while an MCP session is live. + +MAX_EDIT_EVENTS = 256 + +# Operators that fire constantly during interactive work and carry no meaningful +# intent on their own. +_IGNORED_OPERATORS = frozenset({ + "view3d.rotate", + "view3d.move", + "view3d.zoom", + "view3d.dolly", + "view3d.view_axis", + "view3d.view_orbit", + "view3d.view_pan", + "view3d.smoothview", + "view3d.cursor3d", + "wm.tool_set_by_id", + "wm.context_set_value", + "screen.animation_step", +}) + +# Operator properties holding filesystem paths. Never recorded. +_PATH_PROPERTY_NAMES = frozenset({ + "filepath", + "filename", + "directory", + "filepath_raw", + "relpath", +}) +_PATH_PROPERTY_SUBSTRINGS = ("filepath", "filename", "directory", "_dir", "path") +MAX_OPERATOR_PROPERTY_CHARS = 200 + +# depsgraph_update_post fires on every scene update, many times per second +# during interactive drags. +EDIT_POLL_MIN_INTERVAL = 0.1 + + +def _is_path_property(identifier): + """True if an operator property likely holds a filesystem path.""" + lowered = identifier.lower() + if lowered in _PATH_PROPERTY_NAMES: + return True + return any(token in lowered for token in _PATH_PROPERTY_SUBSTRINGS) + + +class UserEditRecorder: + """Buffers human-originated operator and undo events for the MCP server. + + Anything that happens while an agent command is running is attributed to + the agent, not the human; `agent_command()` brackets that window. + """ + + def __init__(self): + self._events = deque(maxlen=MAX_EDIT_EVENTS) + self._agent_depth = 0 + self._last_operator_count = 0 + self._seen_baseline = False + self._last_poll_time = 0.0 + + @contextmanager + def agent_command(self): + """Suppress capture for the duration of an agent-issued command.""" + self._agent_depth += 1 + try: + yield + finally: + self._agent_depth = max(0, self._agent_depth - 1) + self._resync_operator_baseline() + + @property + def _suppressed(self): + return self._agent_depth > 0 + + def _operator_stack(self): + try: + return list(bpy.context.window_manager.operators) + except Exception: + return [] + + def _resync_operator_baseline(self): + self._last_operator_count = len(self._operator_stack()) + self._seen_baseline = True + + def poll_operators(self, now=None): + """Emit rows for operators run since the last poll. Main thread only. + + Throttled to EDIT_POLL_MIN_INTERVAL. + """ + if self._suppressed: + return + now = time.time() if now is None else now + if (now - self._last_poll_time) < EDIT_POLL_MIN_INTERVAL: + return + self._last_poll_time = now + stack = self._operator_stack() + count = len(stack) + + # First poll only establishes a baseline. + if not self._seen_baseline: + self._last_operator_count = count + self._seen_baseline = True + return + + if count <= self._last_operator_count: + # Unchanged, or shrank because of an undo. Hold the high-water + # mark so a later redo does not replay emitted operators. + return + + for op in stack[self._last_operator_count:count]: + self._record_operator(op) + self._last_operator_count = count + + def _record_operator(self, op): + try: + bl_idname = getattr(op, "bl_idname", None) + if not bl_idname: + return + # bl_idname is UPPER_CASE_OT_form; normalise to bpy.ops form. + normalized = bl_idname.lower().replace("_ot_", ".", 1) + if normalized in _IGNORED_OPERATORS: + return + self._events.append({ + "kind": "operator", + "bl_idname": normalized, + "name": getattr(op, "name", None), + "properties": self._operator_properties(op), + "timestamp": time.time(), + }) + except Exception as e: + print(f"Manual edit capture: failed to record operator: {e}") + + @staticmethod + def _operator_properties(op): + """Best-effort scalar snapshot of an operator's resolved properties.""" + props = {} + try: + rna_props = op.properties.bl_rna.properties + except Exception: + return props + for prop in rna_props: + if prop.identifier == "rna_type": + continue + if _is_path_property(prop.identifier): + continue + try: + value = getattr(op.properties, prop.identifier) + except Exception: + continue + if isinstance(value, str): + props[prop.identifier] = value[:MAX_OPERATOR_PROPERTY_CHARS] + elif isinstance(value, (bool, int, float)): + props[prop.identifier] = value + elif hasattr(value, "__len__") and not isinstance(value, (dict, bytes)): + try: + items = [ + v[:MAX_OPERATOR_PROPERTY_CHARS] if isinstance(v, str) else v + for v in value + if isinstance(v, (bool, int, float, str)) + ] + if items and len(items) <= 16: + props[prop.identifier] = items + except Exception: + continue + return props + + def record_undo(self, kind): + """Record an undo/redo. This is the strongest rejection signal we get.""" + if self._suppressed: + return + self._events.append({ + "kind": kind, + "timestamp": time.time(), + }) + # Keep the high-water mark so a redo does not re-emit consumed entries. + self._last_operator_count = max( + self._last_operator_count, len(self._operator_stack()) + ) + self._seen_baseline = True + + def drain(self): + """Hand buffered events to the MCP server and clear them.""" + events = list(self._events) + self._events.clear() + return events + + +_edit_recorder = UserEditRecorder() + + +def get_edit_recorder(): + return _edit_recorder + + +@persistent +def _blendermcp_undo_post(scene, depsgraph=None): + _edit_recorder.record_undo("undo") + + +@persistent +def _blendermcp_redo_post(scene, depsgraph=None): + _edit_recorder.record_undo("redo") + + +@persistent +def _blendermcp_depsgraph_post(scene, depsgraph=None): + _edit_recorder.poll_operators() + + +def _telemetry_consent_enabled(): + """Read the consent preference directly. Fails closed.""" + try: + addon_prefs = bpy.context.preferences.addons.get(__name__) + if not addon_prefs: + return False + return bool(addon_prefs.preferences.telemetry_consent) + except Exception: + return False + + +def _register_edit_capture_handlers(): + """Attach manual-edit handlers, but only with telemetry consent.""" + if not _telemetry_consent_enabled(): + _unregister_edit_capture_handlers() + return False + + handlers = [ + (bpy.app.handlers.undo_post, _blendermcp_undo_post), + (bpy.app.handlers.redo_post, _blendermcp_redo_post), + (bpy.app.handlers.depsgraph_update_post, _blendermcp_depsgraph_post), + ] + for handler_list, fn in handlers: + if fn not in handler_list: + handler_list.append(fn) + return True + + +def sync_edit_capture_handlers(): + """Re-apply the consent gate. Safe to call when consent or server state changes.""" + try: + server_running = bool( + getattr(bpy.types, "blendermcp_server", None) + and bpy.types.blendermcp_server.running + ) + except Exception: + server_running = False + + if not server_running: + _unregister_edit_capture_handlers() + return False + return _register_edit_capture_handlers() + + +def _unregister_edit_capture_handlers(): + handlers = [ + (bpy.app.handlers.undo_post, _blendermcp_undo_post), + (bpy.app.handlers.redo_post, _blendermcp_redo_post), + (bpy.app.handlers.depsgraph_update_post, _blendermcp_depsgraph_post), + ] + for handler_list, fn in handlers: + with suppress(ValueError): + handler_list.remove(fn) +#endregion + + +def get_blendermcp_addon_preferences(context=None): + """Get add-on preferences object if available.""" + if context is None: + context = bpy.context + addon = context.preferences.addons.get(__name__) + return addon.preferences if addon else None + +# Tencent Cloud exposes Hunyuan-to-3D through two different services depending on where the +# account was created. Mainland accounts (cloud.tencent.com) use the AI3D 3.0 API. Tencent Cloud +# International accounts (tencentcloud.com) use the "Hunyuan-to-3D (Professional)" service on the +# older hunyuan API in ap-singapore; it rejects the mainland body fields and expects EnablePBR. +# Sending International credentials to the mainland endpoint fails with +# AuthFailure.SignatureFailure / ResourceUnavailable. +HUNYUAN_API_PROFILES = { + "mainland": { + "service": "ai3d", + "version": "2025-05-13", + "region": "ap-guangzhou", + "submit_action": "SubmitHunyuanTo3DProJob", + "query_action": "QueryHunyuanTo3DProJob", + "submit_body": {}, + }, + "international_pro": { + "service": "hunyuan", + "version": "2023-09-01", + "region": "ap-singapore", + "submit_action": "SubmitHunyuanTo3DProJob", + "query_action": "QueryHunyuanTo3DProJob", + "submit_body": {"EnablePBR": True}, + }, +} + + +def hunyuan_api_profile(international_pro: bool) -> dict: + """Return a copy of the Tencent Cloud API profile for the selected account type.""" + profile = HUNYUAN_API_PROFILES["international_pro" if international_pro else "mainland"] + return {**profile, "submit_body": dict(profile["submit_body"])} + + +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 + # Commands are pushed here by client threads and drained by a single + # timer running on Blender's main thread. bpy.app.timers is not + # thread-safe, so registering a timer per command (the previous + # approach) could silently drop the callback - on Windows especially - + # leaving the client blocked in recv() until its socket timeout. + self.command_queue = queue.Queue() + # Live client sockets, so stop() can unblock threads parked in recv(). + self._clients = set() + self._clients_lock = threading.Lock() + + def _get_config_value(self, scene_attr, pref_attr=None, env_var=None): + """Read config in order: addon preferences -> scene -> env var.""" + prefs = get_blendermcp_addon_preferences() + if prefs and pref_attr: + pref_value = getattr(prefs, pref_attr, "") + if pref_value: + return pref_value + + scene_value = getattr(bpy.context.scene, scene_attr, "") + if scene_value: + return scene_value + + if env_var: + env_value = os.getenv(env_var, "") + if env_value: + return env_value + return "" + + def _get_hyper3d_api_key(self): + # Let the free-trial button temporarily override persistent keys + # without overwriting user-saved private keys. + scene_value = getattr(bpy.context.scene, "blendermcp_hyper3d_api_key", "") + if scene_value == RODIN_FREE_TRIAL_KEY: + return scene_value + return self._get_config_value( + "blendermcp_hyper3d_api_key", + "hyper3d_api_key", + "BLENDERMCP_HYPER3D_API_KEY", + ) + + def _get_sketchfab_api_key(self): + return self._get_config_value( + "blendermcp_sketchfab_api_key", + "sketchfab_api_key", + "BLENDERMCP_SKETCHFAB_API_KEY", + ) + + def _get_polypizza_api_key(self): + return self._get_config_value( + "blendermcp_polypizza_api_key", + "polypizza_api_key", + "BLENDERMCP_POLYPIZZA_API_KEY", + ) + + def _get_hunyuan3d_secret_id(self): + return self._get_config_value( + "blendermcp_hunyuan3d_secret_id", + "hunyuan3d_secret_id", + "BLENDERMCP_HUNYUAN3D_SECRET_ID", + ) + + def _get_hunyuan3d_secret_key(self): + return self._get_config_value( + "blendermcp_hunyuan3d_secret_key", + "hunyuan3d_secret_key", + "BLENDERMCP_HUNYUAN3D_SECRET_KEY", + ) + + def _get_hunyuan3d_api_url(self): + return self._get_config_value( + "blendermcp_hunyuan3d_api_url", + "hunyuan3d_api_url", + "BLENDERMCP_HUNYUAN3D_API_URL", + ) or "http://localhost:8081" + + def start(self): + if bpy.app.background: + print("BlenderMCP: cannot start server in background mode (blender -b) - commands would never execute\n" + "BlenderMCP: run Blender with a GUI, or use a virtual display: xvfb-run -a blender") + return + + 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)) + # Backlog of 1 meant a reconnecting client could complete the TCP + # handshake and then never be accept()ed - a connection that looks + # established but is never serviced. + self.socket.listen(5) + + # Start server thread + self.server_thread = threading.Thread(target=self._server_loop) + self.server_thread.daemon = True + self.server_thread.start() + + _register_edit_capture_handlers() + + # start() is called from the operator, i.e. the main thread, so + # this is the only safe place to touch bpy.app.timers. + if not bpy.app.timers.is_registered(self._drain_command_queue): + bpy.app.timers.register(self._drain_command_queue, persistent=True) + + 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 + + _unregister_edit_capture_handlers() + get_edit_recorder().drain() + + try: + if bpy.app.timers.is_registered(self._drain_command_queue): + bpy.app.timers.unregister(self._drain_command_queue) + except Exception: + pass + + # Close socket + if self.socket: + try: + self.socket.close() + except: + pass + self.socket = None + + # Shut down live client sockets. Without this, handler threads stay + # parked in a blocking recv() forever; being daemon threads they then + # outlive the restart and close connections the new server owns + # (the WinError 10054 seen after toggling the addon). + with self._clients_lock: + clients = list(self._clients) + self._clients.clear() + for client in clients: + try: + client.shutdown(socket.SHUT_RDWR) + except Exception: + pass + try: + client.close() + except Exception: + pass + + # Drop any commands that will never be serviced now. + while True: + try: + self.command_queue.get_nowait() + except queue.Empty: + break + + # 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 _drain_command_queue(self): + """Run queued commands on Blender's main thread. + + Registered once by start(); returns the poll interval so Blender keeps + calling it. All bpy access happens here, on the main thread. + """ + if not self.running: + return None + + while True: + try: + command, client = self.command_queue.get_nowait() + except queue.Empty: + break + + try: + response = self.execute_command(command) + response_json = json.dumps(response) + except Exception as e: + print(f"Error executing command: {str(e)}") + traceback.print_exc() + response_json = json.dumps({"status": "error", "message": str(e)}) + + try: + client.sendall(response_json.encode('utf-8')) + except Exception: + print("Failed to send response - client disconnected") + + return 0.05 + + def _handle_client(self, client): + """Handle connected client""" + print("Client handler started") + # A finite timeout keeps this loop responsive to self.running instead + # of parking in recv() forever. + client.settimeout(1.0) + with self._clients_lock: + self._clients.add(client) + 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'' + + # Hand off to the main thread. Never call + # bpy.app.timers.register() from here - it is not + # thread-safe and the callback can be silently lost. + print(f"Queued command: {command.get('type')}") + self.command_queue.put((command, client)) + except (json.JSONDecodeError, UnicodeDecodeError): + # Incomplete data, wait for more. A multi-byte UTF-8 + # character can land split across a recv() chunk + # boundary, which fails decode() before json.loads() + # ever runs - that's incomplete data too, not garbage. + pass + except socket.timeout: + # Expected; loop round and re-check self.running. + continue + 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: + with self._clients_lock: + self._clients.discard(client) + try: + client.close() + except: + pass + print("Client handler stopped") + + def execute_command(self, command): + """Execute a command in the main Blender thread""" + try: + with get_edit_recorder().agent_command(): + 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", {}) + + # Trivial liveness check. Touches no bpy data, so a successful ping + # alongside a failing command isolates data access from transport. + if cmd_type == "ping": + return {"status": "success", "result": {"pong": True}} + + # 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_world_state_snapshot": self.get_world_state_snapshot, + "get_addon_info": self.get_addon_info, + "get_object_info": self.get_object_info, + "get_viewport_screenshot": self.get_viewport_screenshot, + "execute_code": self.execute_code, + "describe_node_type": self.describe_node_type, + "bpy_api_lookup": self.bpy_api_lookup, + "drain_human_activity": self.drain_human_activity, + "get_telemetry_consent": self.get_telemetry_consent, + "set_telemetry_consent": self.set_telemetry_consent, + "get_polyhaven_status": self.get_polyhaven_status, + "get_hyper3d_status": self.get_hyper3d_status, + "get_sketchfab_status": self.get_sketchfab_status, + "get_polypizza_status": self.get_polypizza_status, + "get_hunyuan3d_status": self.get_hunyuan3d_status, + "get_tripo_status": premium_tripo_status, + "export_scene": self.export_scene, + } + + # 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, + "get_polyhaven_asset_preview": self.get_polyhaven_asset_preview, + "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 Poly Pizza handlers only if enabled + if bpy.context.scene.blendermcp_use_polypizza: + polypizza_handlers = { + "search_polypizza_models": self.search_polypizza_models, + "download_polypizza_model": self.download_polypizza_model, + } + handlers.update(polypizza_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) + + # Tripo is only offered through Premium + handlers.update(premium_tripo_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_addon_info(self): + """Version/capability handshake for the MCP server (and install tooling).""" + return { + "name": bl_info.get("name", "MCP for Blender"), + "addon_version": list(bl_info.get("version", (0, 0))), + "protocol_version": ADDON_PROTOCOL_VERSION, + "capabilities": sorted([ + "get_scene_info", + "get_world_state_snapshot", + "get_addon_info", + "get_object_info", + "get_viewport_screenshot", + "execute_code", + "describe_node_type", + "bpy_api_lookup", + "drain_human_activity", + "get_telemetry_consent", + "set_telemetry_consent", + ]), + "blender_version": bpy.app.version_string, + "premium_generators": premium_enabled_generators(), + } + + 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)} + + def drain_human_activity(self): + """Return human-originated events buffered since the last drain. + + Consent is enforced MCP-side (the server only drains and uploads when + the user has opted in), but we also refuse here so a buffer does not + accumulate for a user who has said no. + """ + try: + if not self.get_telemetry_consent().get("consent"): + get_edit_recorder().drain() + return {"events": []} + return {"events": get_edit_recorder().drain()} + except Exception as e: + print(f"Error draining manual edits: {str(e)}") + return {"error": str(e)} + + @staticmethod + def _snapshot_geometry(obj): + """World-space AABB + dimensions for one object, or None. + + Without these, downstream analysis cannot compute contact, containment + or collision: `scale` alone is a multiplier on unknown base geometry. + Uses obj.bound_box (8 cached local corners) rather than mesh vertices, + so cost is constant per object regardless of poly count. + """ + bound_box = getattr(obj, "bound_box", None) + if not bound_box: + return None + try: + matrix_world = obj.matrix_world + xs, ys, zs = [], [], [] + for corner in bound_box: + world = matrix_world @ mathutils.Vector(corner) + xs.append(world.x) + ys.append(world.y) + zs.append(world.z) + return { + "aabb_min": [round(min(xs), 3), round(min(ys), 3), round(min(zs), 3)], + "aabb_max": [round(max(xs), 3), round(max(ys), 3), round(max(zs), 3)], + "dimensions": [ + round(float(obj.dimensions.x), 3), + round(float(obj.dimensions.y), 3), + round(float(obj.dimensions.z), 3), + ], + } + except Exception: + return None + + @staticmethod + def _snapshot_relations(obj): + """Parent and constraint targets, so hierarchies read correctly. + + World `location` alone misreports parented objects, whose authored + values are parent-relative. + """ + relations = {} + parent = getattr(obj, "parent", None) + if parent: + relations["parent"] = parent.name + relations["parent_type"] = obj.parent_type + loc = obj.matrix_local.translation + relations["local_location"] = [ + round(float(loc.x), 3), + round(float(loc.y), 3), + round(float(loc.z), 3), + ] + constraints = [] + for constraint in getattr(obj, "constraints", None) or []: + entry = {"type": constraint.type} + target = getattr(constraint, "target", None) + if target: + entry["target"] = target.name + constraints.append(entry) + if len(constraints) >= 8: + break + if constraints: + relations["constraints"] = constraints + modifiers = [m.type for m in (getattr(obj, "modifiers", None) or [])[:8]] + if modifiers: + relations["modifiers"] = modifiers + return relations + + @staticmethod + def _snapshot_animation(obj): + """Action name and per-channel keyframe summary for one object, or {}. + + Static transforms alone cannot distinguish an authored edit from + playback landing on a different frame. Reads F-curve metadata + (`data_path`, `array_index`, `len(keyframe_points)`) rather than + individual keyframes, so cost stays proportional to channel count + rather than to animation length. + """ + try: + anim_data = getattr(obj, "animation_data", None) + if not anim_data: + return {} + + animation = {} + action = getattr(anim_data, "action", None) + if action: + animation["action"] = action.name + channels = [] + total_keyframes = 0 + frame_min, frame_max = None, None + for fcurve in action.fcurves: + keyframe_points = fcurve.keyframe_points + count = len(keyframe_points) + total_keyframes += count + if count and len(channels) < 16: + channels.append({ + "data_path": fcurve.data_path, + "array_index": fcurve.array_index, + "keyframes": count, + }) + if count: + first = keyframe_points[0].co.x + last = keyframe_points[-1].co.x + frame_min = first if frame_min is None else min(frame_min, first) + frame_max = last if frame_max is None else max(frame_max, last) + if channels: + animation["channels"] = channels + animation["keyframe_count"] = total_keyframes + if frame_min is not None: + animation["frame_range"] = [round(float(frame_min), 3), + round(float(frame_max), 3)] + + drivers = getattr(anim_data, "drivers", None) + if drivers and len(drivers): + animation["driver_count"] = len(drivers) + + nla_tracks = [ + track.name + for track in (getattr(anim_data, "nla_tracks", None) or [])[:8] + ] + if nla_tracks: + animation["nla_tracks"] = nla_tracks + + return {"animation": animation} if animation else {} + except Exception: + return {} + + @staticmethod + def _shader_fingerprint(id_block): + """Stable short hash of a node tree (material or world), or None. + + Node identities plus rounded input values, so tweaking a color or + rewiring a link changes the fingerprint. Lets downstream deltas see + shader edits that leave every object transform untouched. + """ + try: + if id_block is None: + return None + tree = id_block.node_tree if getattr(id_block, "use_nodes", False) else None + if tree is None: + color = getattr(id_block, "diffuse_color", None) or getattr(id_block, "color", None) + basis = str([round(float(v), 3) for v in color]) if color is not None else "" + else: + parts = [] + for node in tree.nodes: + values = [] + for sock in node.inputs: + dv = getattr(sock, "default_value", None) + if isinstance(dv, (int, float)): + values.append(round(float(dv), 3)) + elif dv is not None: + with suppress(TypeError, ValueError): + values.extend(round(float(v), 3) for v in dv) + parts.append(f"{node.bl_idname}{values}") + parts.sort() + parts.append(str(len(tree.links))) + basis = "|".join(parts) + return format(zlib.crc32(basis.encode("utf-8")), "08x") + except Exception: + return None + + @staticmethod + def _project_id(): + """Salted hash linking sessions on the same .blend without storing its path.""" + try: + filepath = bpy.data.filepath + if not filepath: + return None + return hashlib.sha256(f"{uuid.getnode()}:{filepath}".encode("utf-8")).hexdigest()[:16] + except Exception: + return None + + def get_world_state_snapshot(self): + """Compact world-state snapshot for trajectory capture (no mesh/shader detail).""" + try: + scene = bpy.context.scene + selected = [obj.name for obj in bpy.context.selected_objects] + selected_count = len(selected) + selected_truncated = selected_count > MAX_SNAPSHOT_SELECTED + if selected_truncated: + # Sorted so before/after snapshots keep the same subset. + selected = sorted(selected)[:MAX_SNAPSHOT_SELECTED] + objects = [] + + all_objects = list(scene.objects) + truncated = len(all_objects) > MAX_SNAPSHOT_OBJECTS + if truncated: + # scene.objects iterates in an order that shifts as objects are + # created, so an arbitrary prefix would leave the before/after + # snapshots of one step holding different subsets and the delta + # reporting phantom adds/removes. Sorting keeps them aligned. + all_objects = sorted(all_objects, key=lambda o: o.name)[:MAX_SNAPSHOT_OBJECTS] + + for obj in all_objects: + materials = [] + if getattr(obj, "material_slots", None): + materials = [ + slot.material.name + for slot in obj.material_slots + if slot.material + ] + + entry = { + "name": obj.name, + "type": obj.type, + "location": [ + round(float(obj.location.x), 3), + round(float(obj.location.y), 3), + round(float(obj.location.z), 3), + ], + "rotation": [ + round(float(obj.rotation_euler.x), 3), + round(float(obj.rotation_euler.y), 3), + round(float(obj.rotation_euler.z), 3), + ], + "scale": [ + round(float(obj.scale.x), 3), + round(float(obj.scale.y), 3), + round(float(obj.scale.z), 3), + ], + "visible": bool(obj.visible_get()), + "materials": materials, + } + geometry = self._snapshot_geometry(obj) + if geometry: + entry.update(geometry) + entry.update(self._snapshot_relations(obj)) + entry.update(self._snapshot_animation(obj)) + data = getattr(obj, "data", None) + if obj.type == "MESH" and data is not None: + entry["mesh"] = { + "vertices": len(data.vertices), + "polygons": len(data.polygons), + } + objects.append(entry) + + camera = scene.camera + camera_info = None + if camera: + camera_info = { + "name": camera.name, + "location": [ + round(float(camera.location.x), 3), + round(float(camera.location.y), 3), + round(float(camera.location.z), 3), + ], + "rotation": [ + round(float(camera.rotation_euler.x), 3), + round(float(camera.rotation_euler.y), 3), + round(float(camera.rotation_euler.z), 3), + ], + } + if camera.type == "CAMERA" and camera.data: + camera_info["lens"] = round(float(camera.data.lens), 3) + camera_info["sensor_width"] = round(float(camera.data.sensor_width), 3) + + lights = [] + for obj in scene.objects: + if obj.type != "LIGHT": + continue + light_entry = { + "name": obj.name, + "location": [ + round(float(obj.location.x), 3), + round(float(obj.location.y), 3), + round(float(obj.location.z), 3), + ], + } + if obj.data: + light_entry["light_type"] = obj.data.type + light_entry["energy"] = round(float(obj.data.energy), 3) + lights.append(light_entry) + if len(lights) >= 20: + break + + return { + "name": scene.name, + "object_count": len(scene.objects), + # Explicit, so consumers never have to infer truncation from a + # hardcoded cap they might disagree with. + "objects_listed": len(objects), + "objects_truncated": truncated, + "selected": selected, + "selected_count": selected_count, + "selected_truncated": selected_truncated, + "frame_current": scene.frame_current, + "frame_start": scene.frame_start, + "frame_end": scene.frame_end, + "fps": round(float(scene.render.fps) / scene.render.fps_base, 3), + "objects": objects, + "active_camera": camera.name if camera else None, + "camera": camera_info, + "lights": lights, + "materials_count": len(bpy.data.materials), + "material_fps": { + m.name: self._shader_fingerprint(m) + for m in list(bpy.data.materials)[:200] + }, + "world_fp": self._shader_fingerprint(scene.world), + "project_id": self._project_id(), + "blender_version": bpy.app.version_string, + "snapshot_source": "native", + } + except Exception as e: + print(f"Error in get_world_state_snapshot: {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 + """ + # screen.screenshot_area captures the OS window framebuffer, which is + # all-black whenever the Blender window is not composited in the + # foreground (the normal case when Blender is driven headless-style via + # MCP). Render the viewport with gpu.types.GPUOffScreen.draw_view3d + # instead, which is independent of window compositing state, and fall + # back to the window grab if offscreen rendering is unavailable (e.g. no + # GPU context). The response reports which path produced the image. + try: + if not filepath: + return {"error": "No filepath provided"} + + area = region = space = None + for a in bpy.context.screen.areas: + if a.type == 'VIEW_3D': + area = a + space = a.spaces.active + region = next((r for r in a.regions if r.type == 'WINDOW'), None) + break + + if not area or region is None or space is None: + return {"error": "No 3D viewport found"} + + method = "offscreen" + try: + import gpu + import numpy as np + + r3d = space.region_3d + src_w, src_h = region.width, region.height + if max(src_w, src_h) > max_size: + s = max_size / max(src_w, src_h) + width, height = max(1, int(src_w * s)), max(1, int(src_h * s)) + else: + width, height = src_w, src_h + + offscreen = gpu.types.GPUOffScreen(width, height) + try: + offscreen.draw_view3d( + bpy.context.scene, bpy.context.view_layer, space, region, + r3d.view_matrix, r3d.window_matrix, do_color_management=True, + ) + buf = offscreen.texture_color.read() + finally: + offscreen.free() + + buf.dimensions = width * height * 4 + pixels = np.asarray(buf, dtype=np.float32) / 255.0 # GPU buffer is 0..255 + + image = bpy.data.images.new("mcp_viewport", width, height, alpha=True) + image.pixels.foreach_set(pixels.ravel()) + image.filepath_raw = filepath + image.file_format = format.upper() + image.save() + bpy.data.images.remove(image) + + except Exception as offscreen_err: + print(f"[BlenderMCP] offscreen capture failed ({offscreen_err}); " + "falling back to window grab", flush=True) + method = "window_grab" + with bpy.context.temp_override(area=area): + bpy.ops.screen.screenshot_area(filepath=filepath) + img = bpy.data.images.load(filepath) + width, height = img.size + if max(width, height) > max_size: + s = max_size / max(width, height) + width, height = int(width * s), int(height * s) + img.scale(width, height) + img.file_format = format.upper() + img.save() + bpy.data.images.remove(img) + + return { + "success": True, + "width": width, + "height": height, + "filepath": filepath, + "method": method, + } + + 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: + # Give the caller the same detail we have: exception type, message, + # and a full traceback (with line numbers into the submitted code), + # instead of collapsing everything into one string. Callers that ran + # a multi-line script otherwise cannot tell which line failed. + tb = traceback.format_exc() + raise Exception( + json.dumps({ + "exception_type": type(e).__name__, + "message": str(e), + "traceback": tb, + }) + ) + + # ------------------------------------------------------------------ + # Documentation / introspection helpers. + # + # These never touch the current scene or node tree - they exist purely + # to answer "what does this thing look like" questions (property names, + # types, enum values, socket order, function/operator signatures) so an + # LLM can get a structured answer in one call instead of guessing and + # discovering the shape of things via a chain of failed execute_code + # attempts. + # ------------------------------------------------------------------ + + @staticmethod + def _describe_property(prop): + """Structured description of a single bpy RNA property.""" + entry = { + "identifier": prop.identifier, + "name": prop.name, + "type": prop.type, # FLOAT, INT, BOOLEAN, STRING, ENUM, POINTER, COLLECTION + "description": prop.description, + } + for attr in ("is_required", "is_readonly", "is_argument_optional", "array_length"): + value = getattr(prop, attr, None) + if value is not None: + entry[attr] = value + + if prop.type == 'ENUM': + try: + entry["enum_items"] = [item.identifier for item in prop.enum_items] + except Exception: + pass + try: + entry["default"] = prop.default + except Exception: + pass + elif prop.type in ('FLOAT', 'INT'): + try: + entry["default"] = ( + list(prop.default_array) if getattr(prop, "array_length", 0) else prop.default + ) + except Exception: + pass + for attr in ("hard_min", "hard_max", "soft_min", "soft_max", "subtype", "unit", "step"): + value = getattr(prop, attr, None) + if value is not None: + entry[attr] = value + elif prop.type == 'BOOLEAN': + try: + entry["default"] = prop.default + except Exception: + pass + elif prop.type == 'STRING': + try: + entry["default"] = prop.default + except Exception: + pass + max_length = getattr(prop, "max_length", None) + if max_length: + entry["max_length"] = max_length + elif prop.type == 'POINTER': + fixed_type = getattr(prop, "fixed_type", None) + if fixed_type is not None: + entry["pointer_type"] = fixed_type.identifier + elif prop.type == 'COLLECTION': + fixed_type = getattr(prop, "fixed_type", None) + if fixed_type is not None: + entry["collection_type"] = fixed_type.identifier + return entry + + def describe_node_type(self, bl_idname, property_overrides=None): + """Describe a node type's properties and socket schema. + + This is the fix for the single most common failure mode: guessing + socket names/indices and enum values instead of looking them up. + Since a node's sockets are only known once instantiated (and can + depend on mode-like properties, e.g. Mix's `data_type`), this + creates a throwaway node in a scratch node tree, optionally applies + `property_overrides` first (e.g. {"data_type": "RGBA"}) so the + caller can see the exact socket layout for the mode they intend to + use, then reports its properties/inputs/outputs, and finally + deletes the scratch tree. Nothing in the user's actual scene is + touched. + """ + node_cls = getattr(bpy.types, bl_idname, None) + if node_cls is None or not (isinstance(node_cls, type) and issubclass(node_cls, bpy.types.Node)): + candidates = [ + name for name in dir(bpy.types) + if "Node" in name and bl_idname.lower() in name.lower() + ] + return { + "error": f"Unknown node type: {bl_idname}", + "did_you_mean": sorted(candidates)[:15], + } + + tree_type_candidates = [ + "ShaderNodeTree", "GeometryNodeTree", "CompositorNodeTree", "TextureNodeTree", + ] + node = None + tree = None + used_tree_type = None + attempts = [] + for tree_type in tree_type_candidates: + tmp_tree = None + try: + tmp_tree = bpy.data.node_groups.new(name="__mcp_introspect_tmp__", type=tree_type) + node = tmp_tree.nodes.new(type=bl_idname) + tree = tmp_tree + used_tree_type = tree_type + break + except Exception as e: + attempts.append(f"{tree_type}: {e}") + if tmp_tree is not None: + try: + bpy.data.node_groups.remove(tmp_tree) + except Exception: + pass + + if node is None: + return { + "error": f"Could not instantiate node '{bl_idname}' in any node tree type", + "attempts": attempts, + } + + try: + warnings = [] + if property_overrides: + for key, value in property_overrides.items(): + try: + setattr(node, key, value) + except Exception as e: + warnings.append(f"Could not set property '{key}' = {value!r}: {e}") + + base_props = set(bpy.types.Node.bl_rna.properties.keys()) + properties = [ + self._describe_property(prop) + for prop in node.bl_rna.properties + if prop.identifier not in base_props + ] + + def describe_sockets(sockets): + out = [] + for index, socket in enumerate(sockets): + entry = { + "index": index, + "identifier": socket.identifier, + "name": socket.name, + "type": socket.type, + "is_multi_input": getattr(socket, "is_multi_input", False), + "hide_value": getattr(socket, "hide_value", False), + "is_linked": socket.is_linked, + } + if hasattr(socket, "default_value"): + try: + default_value = socket.default_value + if hasattr(default_value, "__len__") and not isinstance(default_value, str): + entry["default_value"] = list(default_value) + else: + entry["default_value"] = default_value + except Exception: + pass + out.append(entry) + return out + + result = { + "bl_idname": bl_idname, + "label": node.bl_label, + "instantiated_in": used_tree_type, + "properties": properties, + "inputs": describe_sockets(node.inputs), + "outputs": describe_sockets(node.outputs), + "applied_property_overrides": property_overrides or {}, + "note": ( + "Sockets reflect the node's current property values (after any " + "property_overrides applied above). Enum/mode-like properties " + "(e.g. data_type, blend_type) can add, remove or reorder sockets - " + "pass the mode you intend to use via property_overrides to see the " + "real layout before writing code that indexes these sockets." + ), + } + if warnings: + result["warnings"] = warnings + return result + finally: + try: + bpy.data.node_groups.remove(tree) + except Exception: + pass + + def bpy_api_lookup(self, query): + """Structured RNA reference lookup: types, properties, functions, operators. + + Accepts things like: + - "ShaderNodeTexSky" or "bpy.types.ShaderNodeTexSky" -> full type schema + - "ShaderNodeTexSky.sky_type" -> one property, with enum items + - "Object.ray_cast" -> one method's parameters/returns + - "bpy.ops.mesh.primitive_cube_add" -> operator parameters + This replaces scraping `help()` text: every answer is structured + JSON with real type names, enum identifiers, and required/optional + flags, not something that has to be re-parsed out of a text blob. + """ + query = (query or "").strip() + if not query: + return {"error": "Empty query"} + + q = query[4:] if query.startswith("bpy.") else query + + # bpy.ops.. + if q.startswith("ops."): + op_parts = q[len("ops."):].split(".") + op_parts = [p.split("(")[0] for p in op_parts if p] + if len(op_parts) < 2: + return {"error": f"Incomplete operator path: bpy.{q}. Expected bpy.ops.."} + category, op_name = op_parts[0], op_parts[1] + op_group = getattr(bpy.ops, category, None) + op = getattr(op_group, op_name, None) if op_group is not None else None + if op is None: + return {"error": f"Unknown operator: bpy.ops.{category}.{op_name}"} + try: + rna = op.get_rna_type() + except Exception as e: + return {"error": f"Could not introspect operator bpy.ops.{category}.{op_name}: {e}"} + parameters = [ + self._describe_property(prop) + for prop in rna.properties + if prop.identifier != "rna_type" + ] + return { + "kind": "operator", + "idname": f"bpy.ops.{category}.{op_name}", + "label": rna.name, + "description": rna.description, + "parameters": parameters, + } + + parts = [p for p in q.split(".") if p and p != "types"] + if not parts: + return {"error": "Empty query"} + + type_name = parts[0] + node_cls = getattr(bpy.types, type_name, None) + if node_cls is None: + matches = sorted( + name for name in dir(bpy.types) + if type_name.lower() in name.lower() + ) + return { + "error": f"Unknown type: {type_name}", + "did_you_mean": matches[:15], + } + + if len(parts) == 1: + properties = [ + self._describe_property(prop) + for prop in node_cls.bl_rna.properties + if prop.identifier != "rna_type" + ] + functions = [] + for func in node_cls.bl_rna.functions: + functions.append({ + "identifier": func.identifier, + "description": func.description, + "parameters": [ + self._describe_property(p) for p in func.parameters if not p.is_output + ], + "returns": [ + self._describe_property(p) for p in func.parameters if p.is_output + ], + }) + return { + "kind": "type", + "bl_idname": type_name, + "description": node_cls.bl_rna.description, + "properties": properties, + "functions": functions, + } + + # Type.member - could be a property or a function/method + member_name = parts[1] + prop = node_cls.bl_rna.properties.get(member_name) + if prop is not None: + entry = self._describe_property(prop) + entry["kind"] = "property" + entry["owner_type"] = type_name + return entry + + func = node_cls.bl_rna.functions.get(member_name) + if func is not None: + return { + "kind": "function", + "owner_type": type_name, + "identifier": func.identifier, + "description": func.description, + "parameters": [self._describe_property(p) for p in func.parameters if not p.is_output], + "returns": [self._describe_property(p) for p in func.parameters if p.is_output], + } + + available = sorted( + list(node_cls.bl_rna.properties.keys()) + list(node_cls.bl_rna.functions.keys()) + ) + return { + "error": f"'{type_name}' has no property or function named '{member_name}'", + "did_you_mean": [name for name in available if member_name.lower() in name.lower()][:15], + } + + def export_scene(self, filepath, format="glb", object_names=None, selection_only=False, apply_modifiers=True): + """Export the whole scene, the current selection, or the named objects to a GLB or FBX file. + + Named objects are exported together with their children. GLB carries PBR + materials, emission, skins, shape keys and animation; FBX is the fallback for + tools that need Unity's built-in importer. apply_modifiers=False keeps rigs + and shape keys intact. The file is written where the caller asked, so other + applications (game engines, viewers) can pick it up without going through + execute_code. + """ + if not filepath: + return {"error": "filepath is required"} + fmt = (format or "glb").lower() + if fmt not in ("glb", "fbx"): + return {"error": f"format must be glb or fbx, got '{format}'"} + + names = [n for n in (object_names or []) if n] + use_selection = False + exported = [] + if names: + missing = [n for n in names if bpy.data.objects.get(n) is None] + if missing: + return {"error": "Objects not found in Blender: " + ", ".join(missing)} + bpy.ops.object.select_all(action='DESELECT') + for n in names: + obj = bpy.data.objects[n] + for o in [obj, *obj.children_recursive]: + o.select_set(True) + if o.name not in exported: + exported.append(o.name) + bpy.context.view_layer.objects.active = bpy.data.objects[names[0]] + use_selection = True + elif selection_only: + if not bpy.context.selected_objects: + return {"error": "Nothing is selected in Blender and no object_names were given"} + exported = [o.name for o in bpy.context.selected_objects] + use_selection = True + else: + exported = [o.name for o in bpy.context.scene.objects] + + try: + if bpy.context.object and getattr(bpy.context.object, "mode", 'OBJECT') != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') + except Exception: + pass + + directory = os.path.dirname(filepath) + if directory: + os.makedirs(directory, exist_ok=True) + + if fmt == "glb": + bpy.ops.export_scene.gltf( + filepath=filepath, export_format='GLB', use_selection=use_selection, + use_active_scene=True, export_apply=apply_modifiers, + export_animations=True, export_skins=True, export_morph=True, export_yup=True) + else: + bpy.ops.export_scene.fbx( + filepath=filepath, use_selection=use_selection, apply_unit_scale=True, + bake_space_transform=apply_modifiers, use_mesh_modifiers=apply_modifiers, + path_mode='COPY', embed_textures=True) + + return { + "path": filepath, + "bytes": os.path.getsize(filepath), + "selection_only": use_selection, + "exported": exported, + } + + def get_polyhaven_categories(self, asset_type): + """Get the category taxonomy and attribute schema for an asset type.""" + 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"} + + if asset_type == "all": + # Three full trees at once is 30KB of paths, so this one is cut + # to the top two levels. Filtering is inclusive, so those still + # select everything beneath them. + return { + "taxonomy": [ + _polyhaven_taxonomy(one, depth=POLYHAVEN_TAXONOMY_DEPTH_ALL) + for one in ("hdris", "textures", "models") + ], + "truncated": True, + } + + return {"taxonomy": [_polyhaven_taxonomy(asset_type)], "truncated": False} + except Exception as e: + return {"error": str(e)} + + def search_polyhaven_assets(self, asset_type=None, category=None, attributes=None, + query=None, limit=None, min_size_m=None): + """Search for assets from Polyhaven with optional filtering""" + try: + 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 + + # `category`, not `categories`. The two are different filters over + # different vocabularies: `categories` is the legacy flat tag list + # ("outdoor", "man made", "floor"), while `category` takes the + # single-path taxonomy that get_polyhaven_categories now returns + # ("Metal/Sheet & Corrugated") and matches it inclusively, so a + # parent selects everything beneath it. Sending a path to the legacy + # parameter is answered with 200 and an empty object rather than an + # error, so every filtered search came back silently empty. + if category: + params["category"] = category + + for key, value in (attributes or {}).items(): + if value is None or value == "": + continue + if isinstance(value, bool): + value = "true" if value else "false" + elif isinstance(value, (list, tuple)): + # Comma-separated values are OR'd together by the API. + value = ",".join(str(v) for v in value) + params[str(key)] = str(value) + + try: + limit = int(limit) if limit else POLYHAVEN_SEARCH_LIMIT + except (TypeError, ValueError): + limit = POLYHAVEN_SEARCH_LIMIT + limit = max(1, min(limit, POLYHAVEN_SEARCH_MAX_LIMIT)) + + try: + assets = _polyhaven_api_get("assets", params=params, cache=True) + except PolyHavenAPIError as e: + if e.status == 400: + # The category and attribute filters answer an unrecognised + # value with 400 precisely so it is not a silent empty page. + return {"error": "Poly Haven did not recognise that category or attribute " + "filter. Call get_polyhaven_categories for the values each " + "asset type accepts."} + raise + + # Trimmed and lower-cased so equivalent queries share a cache entry, + # both here and at Poly Haven's edge. + query = (query or "").strip().lower() + note = None + + # Filtered here rather than at the API, which publishes a real-world + # size for every texture but takes no filter on it. Free: the records + # are already in hand. Anything that publishes no size cannot satisfy + # a floor on it and drops out - HDRIs have none. + if min_size_m: + try: + floor_mm = float(min_size_m) * 1000 + except (TypeError, ValueError): + return {"error": f"min_size_m must be a number, got {min_size_m!r}"} + before = len(assets) + assets = { + slug: record for slug, record in assets.items() + if max(record.get("dimensions") or [0]) >= floor_mm + } + if before and not assets: + # An empty page reads as "Poly Haven does not have this", + # which is a different and much worse statement than "the + # size floor is above everything that matched". + note = (f"Nothing matching the other filters is {floor_mm / 1000:g}m or " + "larger. Most textures are 1-4m, and HDRIs have no real-world " + "size at all. Lower min_size_m or leave it out.") + + if query: + try: + ranked = _polyhaven_search(query, asset_type) + except PolyHavenAPIError as e: + if e.status == 429: + wait = f" Retry in {e.retry_after}s." if e.retry_after else "" + return {"error": f"Poly Haven is rate limiting searches from this " + f"address.{wait}"} + if e.status != 503: + raise + # The API documents a 503 as "the query could not be + # embedded, fall back to your own keyword matching". + ranked = _polyhaven_keyword_match(query, assets) + note = ("Poly Haven's semantic search was unavailable, so these are plain " + "keyword matches and the ranking is weaker than usual.") + + # /search knows nothing about the category and attribute filters, + # so its ranking is intersected with the filtered list here. That + # is why the whole ranked list is asked for rather than the first + # `limit` of it: filtering a page that the server already cut can + # only shrink it, and the matches would be the ones further down. + ordered = [slug for slug in ranked if slug in assets] + else: + # Rank before truncating. The previous order was whatever the API + # happened to return, which is sorted by slug - and because models + # are the only assets with capitalised slugs, the first 20 of an + # unfiltered list were 20 models. asset_type="all" could not return + # a single HDRI or texture, and the library's most downloaded assets + # were unreachable by any call. + ordered = sorted( + assets, key=lambda slug: assets[slug].get("download_count", 0), reverse=True) + + selected = ordered[:limit] + + return { + "assets": [_polyhaven_summarize_asset(slug, assets[slug]) for slug in selected], + # Everything matching every filter, so the count and the page it + # heads describe the same population. + "total_count": len(ordered), + "returned_count": len(selected), + "query": query or None, + "note": note, + } + except Exception as e: + return {"error": str(e)} + def get_polyhaven_asset_preview(self, asset_id): + """Fetch an asset's thumbnail, so it can be looked at before downloading. + + A thumbnail is a few hundred kilobytes against a 4k texture's 24MB, so + checking one first is cheaper for everybody than importing the wrong rock. + """ + try: + if not _polyhaven_valid_slug(asset_id): + return {"error": f"Invalid asset id: {asset_id!r}. Poly Haven slugs are " + "letters, digits, underscores and hyphens."} + + record = _polyhaven_asset_record(asset_id) + thumbnail_url = record.get("thumbnail_url") + if not thumbnail_url: + return {"error": f"No thumbnail is published for '{asset_id}'"} + + response = requests.get( + _polyhaven_preview_url(thumbnail_url), + headers=POLYHAVEN_HEADERS, + timeout=POLYHAVEN_API_TIMEOUT, + ) + if response.status_code >= 400: + return {"error": f"Failed to fetch the thumbnail: HTTP {response.status_code}"} + + content_type = getattr(response, "headers", {}).get("Content-Type", "") + image_format = "png" if "png" in content_type or ".png" in thumbnail_url else "jpeg" + + authors = record.get("authors") or {} + return { + "success": True, + "image_data": base64.b64encode(response.content).decode("ascii"), + "format": image_format, + "asset_id": asset_id, + "name": record.get("name") or asset_id, + "authors": sorted(authors) if isinstance(authors, dict) else authors, + "url": _polyhaven_asset_url(asset_id), + } + except Exception as e: + traceback.print_exc() + return {"error": f"Failed to get asset preview: {str(e)}"} + + def download_polyhaven_asset(self, asset_id, asset_type, resolution="1k", file_format=None): + try: + if asset_type not in POLYHAVEN_SUPPORTED_FORMATS: + return {"error": f"Unsupported asset type: {asset_type}. Must be one of: hdris, textures, models"} + + if not _polyhaven_valid_slug(asset_id): + return {"error": f"Invalid asset id: {asset_id!r}. Poly Haven slugs are " + "letters, digits, underscores and hyphens."} + + supported = POLYHAVEN_SUPPORTED_FORMATS[asset_type] + file_format = (file_format or POLYHAVEN_DEFAULT_FORMATS[asset_type]).lower() + if file_format not in supported: + # Rejected before any transfer. `usd` is listed for every model + # and used to be downloaded in full before reaching the + # "unsupported format" branch at the end of the import. + return { + "error": f"Unsupported {asset_type} format: {file_format}. " + f"Supported formats: {', '.join(supported)}" + } + + try: + files_data = _polyhaven_api_get(f"files/{quote(asset_id, safe='')}") + except Exception as e: + return {"error": f"Failed to get asset files for '{asset_id}': {str(e)}"} + + if asset_type == "hdris": + return self._polyhaven_import_hdri(asset_id, files_data, resolution, file_format) + if asset_type == "textures": + return self._polyhaven_import_texture(asset_id, files_data, resolution, file_format) + return self._polyhaven_import_model(asset_id, files_data, resolution, file_format) + + except Exception as e: + traceback.print_exc() + return {"error": f"Failed to download asset: {str(e)}"} + + def _polyhaven_import_hdri(self, asset_id, files_data, resolution, file_format): + """Download an HDRI and set it up as the scene's world.""" + file_info = files_data.get("hdri", {}).get(resolution, {}).get(file_format) + if not file_info: + return { + "error": f"HDRI '{asset_id}' has no {resolution} {file_format} - " + f"{_polyhaven_available(files_data, 'hdris')}" + } + + dest_dir = tempfile.mkdtemp(prefix="blender_mcp_polyhaven_") + dest_path = os.path.join(dest_dir, f"{asset_id}_{resolution}.{file_format}") + + try: + _polyhaven_download(file_info, dest_path) + except Exception as e: + shutil.rmtree(dest_dir, ignore_errors=True) + return {"error": f"Failed to download HDRI: {str(e)}"} + + try: + # A new world every time, rather than clearing the nodes of whatever + # world is already there. The old code took bpy.data.worlds[0] - the + # alphabetically first world datablock, very often somebody else's - + # wiped its nodes and made it active, destroying hand-built setups + # with no undo step to recover them. Using the scene's own world + # instead would still have wiped it. This leaves the previous world + # intact and simply unused; without a fake user Blender clears it up + # on save if nothing else references it, and it is recoverable from + # the outliner's orphan data until then. + world = bpy.data.worlds.new(f"PolyHaven {asset_id}") + bpy.context.scene.world = world + + world.use_nodes = True + node_tree = world.node_tree + node_tree.nodes.clear() + + tex_coord = node_tree.nodes.new(type='ShaderNodeTexCoord') + tex_coord.location = (-800, 0) + + mapping = node_tree.nodes.new(type='ShaderNodeMapping') + mapping.location = (-600, 0) + + env_tex = node_tree.nodes.new(type='ShaderNodeTexEnvironment') + env_tex.location = (-400, 0) + env_tex.image = bpy.data.images.load(dest_path, check_existing=True) + env_tex.image.name = f"{asset_id}_{resolution}" + # Colorspace is deliberately left as Blender's loader set it. It + # already tags .hdr/.exr as scene-linear, and forcing "Non-Color" + # here would mark radiance data as raw - identical under the stock + # OCIO config, a colour shift under any config whose working space + # is not Linear Rec.709. + + # Pack before anything can remove the file underneath it. Without + # this the world points at a path in the OS temp directory for the + # life of the .blend: it renders now, and is a missing image the + # next time the file is opened here - or the first time it is opened + # anywhere else. + env_tex.image.pack() + + background = node_tree.nodes.new(type='ShaderNodeBackground') + background.location = (-200, 0) + + output = node_tree.nodes.new(type='ShaderNodeOutputWorld') + output.location = (0, 0) + + 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']) + + bpy.context.scene.world = world + + authors = _polyhaven_authors(asset_id) + _polyhaven_tag([world, env_tex.image], asset_id, resolution, authors) + + return { + "success": True, + "message": f"HDRI {asset_id} imported successfully", + "image_name": env_tex.image.name, + "world": world.name, + "authors": authors, + "url": _polyhaven_asset_url(asset_id), + } + except Exception as e: + traceback.print_exc() + return {"error": f"Failed to set up HDRI in Blender: {str(e)}"} + finally: + # The image is packed, so nothing needs the file any more. + shutil.rmtree(dest_dir, ignore_errors=True) + + def _polyhaven_build_material(self, asset_id, maps): + """Build a Principled material from {map_key: (role, image)}. + + Shared by download_polyhaven_asset and set_texture so there is exactly + one place that decides which map drives which input - set_texture used + to build its own tree in two passes over the same maps, silently + replacing every link it had just made and leaving the first pass's + Normal Map and Displacement nodes orphaned in the tree. + """ + mat = bpy.data.materials.new(name=asset_id) + mat.use_nodes = True + nodes = mat.node_tree.nodes + links = mat.node_tree.links + nodes.clear() + + output = nodes.new(type='ShaderNodeOutputMaterial') + output.location = (600, 0) + + principled = nodes.new(type='ShaderNodeBsdfPrincipled') + principled.location = (300, 0) + links.new(principled.outputs[0], output.inputs['Surface']) + + tex_coord = nodes.new(type='ShaderNodeTexCoord') + tex_coord.location = (-1000, 0) + + mapping = nodes.new(type='ShaderNodeMapping') + mapping.location = (-800, 0) + # POINT is Blender's default and the mode Poly Haven authors its own + # materials in - the Mapping node published inside every texture .blend + # is left at POINT, and the add-on's real-world-scale operator solves for + # a Scale that grows as the surface grows. TEXTURE is its exact inverse + # ("transform a texture by inverse mapping the texture coordinate"), so + # the natural arithmetic - Scale = surface size / texture size - came out + # upside down, and a 2m texture asked to repeat twice repeated half a + # time instead. At Scale 1.0 the two modes are identical, so this moves + # nothing that was not already inverted. + mapping.vector_type = 'POINT' + links.new(tex_coord.outputs['UV'], mapping.inputs['Vector']) + + y_pos = 300 + wired = [] + + for map_key, (role, image) in maps.items(): + tex_node = nodes.new(type='ShaderNodeTexImage') + tex_node.location = (-500, y_pos) + tex_node.image = image + _polyhaven_set_colorspace(image, is_color_data=role in POLYHAVEN_COLOR_ROLES) + links.new(mapping.outputs['Vector'], tex_node.inputs['Vector']) + y_pos -= 300 + + if role == "base_color": + links.new(tex_node.outputs['Color'], principled.inputs['Base Color']) + elif role == "roughness": + links.new(tex_node.outputs['Color'], principled.inputs['Roughness']) + elif role == "metallic": + links.new(tex_node.outputs['Color'], principled.inputs['Metallic']) + elif role == "normal": + normal_map = nodes.new(type='ShaderNodeNormalMap') + normal_map.location = (-200, tex_node.location[1]) + links.new(tex_node.outputs['Color'], normal_map.inputs['Color']) + links.new(normal_map.outputs['Normal'], principled.inputs['Normal']) + elif role == "displacement": + disp_node = nodes.new(type='ShaderNodeDisplacement') + disp_node.location = (300, tex_node.location[1]) + # Poly Haven's displacement maps are centred on 0.5, and the + # output is only used at all once the material is told to + # displace - otherwise the node sits there connected and inert. + disp_node.inputs['Midlevel'].default_value = 0.5 + disp_node.inputs['Scale'].default_value = 0.1 + links.new(tex_node.outputs['Color'], disp_node.inputs['Height']) + links.new(disp_node.outputs['Displacement'], output.inputs['Displacement']) + # Moved off material.cycles in Blender 4.1; try both so the + # node is not left connected but inert on older versions. + if hasattr(mat, "displacement_method"): + mat.displacement_method = 'BOTH' + else: + with suppress(Exception): + mat.cycles.displacement_method = 'BOTH' + else: + continue + + wired.append(map_key) + + return mat, wired + + def _polyhaven_import_texture(self, asset_id, files_data, resolution, file_format): + """Download a texture's maps and build a material from them.""" + wanted = _polyhaven_select_texture_maps(files_data, resolution, file_format) + if not wanted: + return { + "error": f"Texture '{asset_id}' has no maps at {resolution} {file_format} - " + f"{_polyhaven_available(files_data, 'textures')}" + } + + dest_dir = tempfile.mkdtemp(prefix="blender_mcp_polyhaven_") + maps = {} + + try: + for map_key, role in wanted.items(): + file_info = files_data[map_key][resolution][file_format] + dest_path = os.path.join( + dest_dir, f"{asset_id}_{map_key}_{resolution}.{file_format}" + ) + _polyhaven_download(file_info, dest_path) + + image = bpy.data.images.load(dest_path, check_existing=True) + image.name = f"{asset_id}_{map_key}" + _polyhaven_set_colorspace(image, is_color_data=role in POLYHAVEN_COLOR_ROLES) + image.pack() + maps[map_key] = (role, image) + except Exception as e: + traceback.print_exc() + return {"error": f"Failed to download texture maps: {str(e)}"} + finally: + # Every image is packed, so nothing needs the files any more. + shutil.rmtree(dest_dir, ignore_errors=True) + + try: + mat, wired = self._polyhaven_build_material(asset_id, maps) + + # Deliberately no fake user. A material nothing has been applied to + # is not being used, and Blender discarding it on save is the + # correct outcome rather than a leak to guard against - the same + # reasoning as the world this no longer keeps alive either. Call + # set_texture to give it a real user. + + authors = _polyhaven_authors(asset_id) + dimensions = _polyhaven_dimensions_mm(asset_id) + _polyhaven_tag( + [mat] + [image for _role, image in maps.values()], + asset_id, + resolution=resolution, + authors=authors, + dimensions=dimensions, + ) + for map_key, (role, image) in maps.items(): + with suppress(Exception): + image["polyhaven_map"] = map_key + image["polyhaven_role"] = role + + mapping = _polyhaven_mapping_node(mat.node_tree) + return { + "success": True, + "message": f"Texture {asset_id} imported as material", + "material": mat.name, + "maps": wired, + "authors": authors, + "url": _polyhaven_asset_url(asset_id), + # What the material has to be told before it is applied to + # anything, reported next to the material itself rather than + # left in a search result several steps back. + "scale_mm": dimensions, + "mapping_node": None if mapping is None else mapping.name, + } + except Exception as e: + traceback.print_exc() + return {"error": f"Failed to build material: {str(e)}"} + + def _polyhaven_fetch_model_files(self, files_data, resolution, file_format, dest_dir): + """Download a model's main file and its sidecar textures into dest_dir.""" + file_info = files_data.get(file_format, {}).get(resolution, {}).get(file_format) + if not file_info: + return None + + main_file_path = os.path.join(dest_dir, os.path.basename(file_info["url"].split("?")[0])) + _polyhaven_download(file_info, main_file_path) + + for include_path, include_info in (file_info.get("include") or {}).items(): + # Validate include_path - the API response controls these + # dict keys; a malicious or MITM'd response could request an + # absolute path or one containing ".." to escape dest_dir + # and write arbitrary files (e.g. ~/.bashrc, authorized_keys). + # Mirrors the zip-slip check in download_sketchfab_model. + target_path = os.path.join(dest_dir, os.path.normpath(include_path)) + abs_dest_dir = os.path.abspath(dest_dir) + abs_target_path = os.path.abspath(target_path) + if (os.path.isabs(include_path) + or ".." in include_path + or not abs_target_path.startswith(abs_dest_dir + os.sep)): + print(f"Skipping include with unsafe path: {include_path}") + continue + + os.makedirs(os.path.dirname(target_path), exist_ok=True) + _polyhaven_download(include_info, target_path) + + return main_file_path + + def _polyhaven_append_blend(self, blend_path, asset_id): + """Append the asset's own collection out of a Poly Haven model .blend. + + Every published model holds a collection named exactly the slug - it is + an error in Poly Haven's own asset checker if it does not - and models + with levels of detail carry them as `_LOD0`, `_LOD1` and so on + beneath it. Appending `data_from.objects` wholesale, as this used to, + linked every LOD on top of each other plus whatever else the file + happened to hold, which for some assets is a second model. + """ + with bpy.data.libraries.load(blend_path, link=False) as (data_from, data_to): + available = list(data_from.collections) + # LOD0 is the full-detail version. Taking it directly leaves the + # coarser ones in the file rather than in the scene. + wanted = next( + (name for name in (f"{asset_id}_LOD0", asset_id) if name in available), None) + if wanted: + data_to.collections = [wanted] + else: + # Nothing to key off. Fall back to the old behaviour rather than + # importing nothing at all. + data_to.objects = data_from.objects + + linked = [] + for collection in data_to.collections: + if collection is not None: + bpy.context.scene.collection.children.link(collection) + linked.append(collection) + if not linked: + for obj in data_to.objects: + if obj is not None: + bpy.context.collection.objects.link(obj) + return linked + + def _polyhaven_import_model(self, asset_id, files_data, resolution, file_format): + """Download a model and its textures, then import it.""" + if not files_data.get(file_format, {}).get(resolution, {}).get(file_format): + return { + "error": f"Model {asset_id!r} has no {resolution} {file_format} - " + f"{_polyhaven_available(files_data, 'models')}" + } + + dest_dir = tempfile.mkdtemp(prefix="blender_mcp_polyhaven_") + fallback_note = "" + collections = [] + + try: + main_file_path = self._polyhaven_fetch_model_files( + files_data, resolution, file_format, dest_dir) + except Exception as e: + traceback.print_exc() + shutil.rmtree(dest_dir, ignore_errors=True) + return {"error": f"Failed to download model: {str(e)}"} + + # By name: bpy hands out a fresh Python wrapper per access, so holding on + # to the datablocks themselves invites identity bugs. + before = {obj.name for obj in bpy.data.objects} + + try: + if file_format == "blend": + written_by = _polyhaven_blend_version(main_file_path) + if written_by and written_by > bpy.app.version[:2]: + raise RuntimeError("written by Blender %d.%d" % written_by) + collections = self._polyhaven_append_blend(main_file_path, asset_id) + else: + bpy.ops.import_scene.gltf(filepath=main_file_path) + except Exception as blend_error: + if file_format != "blend": + traceback.print_exc() + shutil.rmtree(dest_dir, ignore_errors=True) + return {"error": f"Failed to import model: {str(blend_error)}"} + + # A .blend written by a newer Blender than this one cannot be opened + # at all, and Poly Haven's oldest models were saved in 2.93 while its + # newest were saved in 5.0. glTF is a poorer record of the material, + # but it is the difference between a worse model and no model. + print(f"Poly Haven: .blend import failed ({blend_error}), falling back to glTF") + shutil.rmtree(dest_dir, ignore_errors=True) + dest_dir = tempfile.mkdtemp(prefix="blender_mcp_polyhaven_") + fallback_note = ( + f" Imported from glTF rather than .blend, because the .blend was {blend_error}" + f" and this is Blender {bpy.app.version_string.split()[0]}. Its materials are a" + " conversion rather than the ones the artist built." + ) + try: + before = {obj.name for obj in bpy.data.objects} + fallback_path = self._polyhaven_fetch_model_files( + files_data, resolution, POLYHAVEN_MODEL_FALLBACK_FORMAT, dest_dir) + if not fallback_path: + raise RuntimeError(f"no {resolution} glTF is published for it") + bpy.ops.import_scene.gltf(filepath=fallback_path) + except Exception as e: + traceback.print_exc() + shutil.rmtree(dest_dir, ignore_errors=True) + return { + "error": f"Model {asset_id!r} is {blend_error}, which this Blender cannot " + f"open, and the glTF fallback failed too: {str(e)}" + } + + try: + imported = [obj for obj in bpy.data.objects if obj.name not in before] + imported_objects = [obj.name for obj in imported] + if not imported_objects: + return {"error": f"Imported {asset_id} but nothing arrived in the scene. " + "The .blend may not hold the collection this expects."} + + # Appended and glTF-imported images still reference the files in the + # temporary directory this deletes on the way out. A .glb carries its + # textures inside it, but a .gltf with sidecar files does not, and an + # appended .blend never does. + materials = [] + for obj in imported: + for slot in getattr(obj, "material_slots", []): + if slot.material is None: + continue + if slot.material not in materials: + materials.append(slot.material) + if not slot.material.use_nodes: + continue + for node in slot.material.node_tree.nodes: + if node.type == 'TEX_IMAGE' and node.image and not node.image.packed_file: + with suppress(Exception): + node.image.pack() + + authors = _polyhaven_authors(asset_id) + _polyhaven_tag(imported + collections + materials, asset_id, resolution, authors) + + return { + "success": True, + "message": f"Model {asset_id} imported successfully.{fallback_note}", + "imported_objects": imported_objects, + "authors": authors, + "url": _polyhaven_asset_url(asset_id), + } + except Exception as e: + traceback.print_exc() + return {"error": f"Failed to import model: {str(e)}"} + finally: + shutil.rmtree(dest_dir, ignore_errors=True) + def _polyhaven_material_info(self, mat): + """Summarise a material's node tree for the caller.""" + texture_nodes = [] + for node in mat.node_tree.nodes: + if node.type != 'TEX_IMAGE' or node.image is None: + continue + connections = [] + for link in mat.node_tree.links: + if link.from_node == node: + connections.append( + f"{link.from_socket.name} -> {link.to_node.name}.{link.to_socket.name}" + ) + texture_nodes.append({ + "name": node.name, + "image": node.image.name, + "colorspace": node.image.colorspace_settings.name, + "connections": connections, + }) + + # Every image node's Vector input comes from here, so this is the one + # node that decides the tiling - and it could not appear in this report, + # which described TEX_IMAGE nodes and nothing else. + mapping = _polyhaven_mapping_node(mat.node_tree) + + return { + "has_nodes": mat.use_nodes, + "node_count": len(mat.node_tree.nodes), + "texture_nodes": texture_nodes, + "mapping_node": None if mapping is None else { + "name": mapping.name, + "vector_type": mapping.vector_type, + "scale": list(mapping.inputs['Scale'].default_value), + }, + } + + def set_texture(self, object_name, texture_id): + """Apply a previously downloaded Polyhaven texture to an object by creating a new material""" + try: + obj = bpy.data.objects.get(object_name) + if not obj: + return {"error": f"Object not found: {object_name}"} + + if not hasattr(obj, 'data') or not hasattr(obj.data, 'materials'): + return {"error": f"Object {object_name} cannot accept materials"} + + if not _polyhaven_valid_slug(texture_id): + return {"error": f"Invalid texture id: {texture_id!r}"} + + # Identified by the custom property stamped at download time rather + # than by parsing the image's name. The old parser took the last + # underscore-separated token, which turned "nor_gl" into "gl" and + # left the two functions disagreeing about what a map was called. + maps = {} + for img in bpy.data.images: + if img.get("polyhaven_id") != texture_id: + continue + map_key = img.get("polyhaven_map") + # Role first: assets whose albedo is not called "Diffuse" are + # not in the table, but were resolved at download time. + role = img.get("polyhaven_role") or POLYHAVEN_TEXTURE_MAPS.get(map_key) + if not role: + continue + if not img.packed_file: + img.pack() + + # An asset downloaded at more than one resolution leaves several + # images per map, all carrying the same id. Take the largest + # rather than whichever happened to come last. + existing = maps.get(map_key) + if existing and _polyhaven_resolution_rank( + existing[1].get("polyhaven_resolution")) >= _polyhaven_resolution_rank( + img.get("polyhaven_resolution")): + continue + maps[map_key] = (role, img) + + if not maps: + return { + "error": f"No texture images found for: {texture_id}. " + "Download it first with download_polyhaven_asset." + } + + new_mat_name = f"{texture_id}_material_{object_name}" + existing_mat = bpy.data.materials.get(new_mat_name) + if existing_mat: + bpy.data.materials.remove(existing_mat) + + new_mat, wired = self._polyhaven_build_material(texture_id, maps) + new_mat.name = new_mat_name + + authors = _polyhaven_authors(texture_id) + _polyhaven_tag([new_mat], texture_id, authors=authors, + dimensions=_polyhaven_dimensions_mm(texture_id)) + + # Note: this replaces every material slot on the object. + replaced = len(obj.data.materials) + while len(obj.data.materials) > 0: + obj.data.materials.pop(index=0) + obj.data.materials.append(new_mat) + + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + bpy.context.view_layer.update() + + message = f"Created new material and applied texture {texture_id} to {object_name}" + if replaced: + message += f" (replaced {replaced} existing material slot{'s' if replaced != 1 else ''})" + + return { + "success": True, + "message": message, + "material": new_mat.name, + "maps": wired, + "material_info": self._polyhaven_material_info(new_mat), + "authors": authors, + "url": _polyhaven_asset_url(texture_id), + } + + 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. + + Fails closed: if preferences cannot be read we report no consent. Not + being able to read the preference means we do not know the user's + answer, which is not the same as them having said yes. + """ + try: + # Get addon preferences - use the module name + addon_prefs = bpy.context.preferences.addons.get(__name__) + if addon_prefs: + consent = bool(addon_prefs.preferences.telemetry_consent) + else: + consent = False + except (AttributeError, KeyError): + consent = False + return {"consent": consent} + + def set_telemetry_consent(self, consent=False): + """Write the telemetry consent preference. + + Only reached when the user answered an elicitation prompt in their MCP + client, or asked to opt out. Assigning the property in code skips the + BoolProperty update= callback, so the manual-edit handlers are + re-synced explicitly. + """ + try: + addon_prefs = bpy.context.preferences.addons.get(__name__) + if not addon_prefs: + return {"error": "Could not read addon preferences"} + addon_prefs.preferences.telemetry_consent = bool(consent) + except (AttributeError, KeyError) as e: + return {"error": f"Could not set telemetry consent: {e}"} + + try: + sync_edit_capture_handlers() + except Exception as e: + print(f"BlenderMCP: could not sync manual edit handlers: {e}") + + return {"consent": bool(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 MCP for Blender 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""" + if premium_active(): + return premium_integration_status("hyper3d", bpy.context.scene.blendermcp_use_hyper3d) + enabled = bpy.context.scene.blendermcp_use_hyper3d + hyper3d_api_key = self._get_hyper3d_api_key() + if enabled: + if not 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 MCP for Blender 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 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 MCP for Blender 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): + if premium_active(): + return premium_create_rodin_job(*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: + api_key = self._get_hyper3d_api_key() + if not api_key: + return {"error": "Hyper3D API key is not given"} + if images is None: + images = [] + """Call Rodin API, get the job uuid and subscription key""" + files = [ + *[("images", (f"{i:04d}{img_suffix}", base64.b64decode(img) if isinstance(img, str) else img)) for i, (img_suffix, img) in enumerate(images)], + ("tier", (None, "Sketch")), + ("mesh_mode", (None, "Raw")), + ("texture_mode", (None, "high")), + ] + 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 {api_key}", + }, + files=files, + timeout=60, + ) + 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: + api_key = self._get_hyper3d_api_key() + if not api_key: + return {"error": "Hyper3D API key is not given"} + 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 {api_key}", + "Content-Type": "application/json", + }, + json=req_data, + timeout=60, + ) + data = response.json() + return data + except Exception as e: + return {"error": str(e)} + + def poll_rodin_job_status(self, *args, **kwargs): + if premium_active(): + return premium_poll_fal_job(kwargs.get("request_id") or kwargs.get("subscription_key")) + 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""" + api_key = self._get_hyper3d_api_key() + if not api_key: + return {"error": "Hyper3D API key is not given"} + response = requests.post( + "https://hyperhuman.deemos.com/api/v2/status", + headers={ + "Authorization": f"Bearer {api_key}", + }, + json={ + "subscription_key": subscription_key, + }, + timeout=30, + ) + 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""" + api_key = self._get_hyper3d_api_key() + if not api_key: + return {"error": "Hyper3D API key is not given"} + response = requests.get( + f"https://queue.fal.run/fal-ai/hyper3d/requests/{request_id}/status", + headers={ + "Authorization": f"KEY {api_key}", + }, + timeout=30, + ) + 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): + if premium_active(): + return premium_import_job(kwargs.get("request_id") or kwargs.get("task_uuid"), kwargs.get("name")) + 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""" + api_key = self._get_hyper3d_api_key() + if not api_key: + return {"succeed": False, "error": "Hyper3D API key is not given"} + response = requests.post( + "https://hyperhuman.deemos.com/api/v2/download", + headers={ + "Authorization": f"Bearer {api_key}", + }, + json={ + 'task_uuid': task_uuid + }, + timeout=30, + ) + 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, timeout=120) + 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""" + api_key = self._get_hyper3d_api_key() + if not api_key: + return {"succeed": False, "error": "Hyper3D API key is not given"} + response = requests.get( + f"https://queue.fal.run/fal-ai/hyper3d/requests/{request_id}", + headers={ + "Authorization": f"Key {api_key}", + }, + timeout=30, + ) + 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, timeout=120) + 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 = self._get_sketchfab_api_key() + + # Test the API key if present + if api_key and enabled: + 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 MCP for Blender 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 MCP for Blender 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 = self._get_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 = self._get_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 = self._get_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 Poly Pizza API + def get_polypizza_status(self): + """Get the current status of Poly Pizza integration""" + enabled = bpy.context.scene.blendermcp_use_polypizza + api_key = self._get_polypizza_api_key() + + if enabled and api_key: + return { + "enabled": True, + "message": "Poly Pizza integration is enabled and ready to use." + } + elif enabled and not api_key: + return { + "enabled": False, + "message": """Poly Pizza integration is currently enabled, but API key is not given. To enable it: + 1. Get a free API key at https://poly.pizza/settings/api + 2. In the 3D Viewport, find the MCP for Blender panel in the sidebar (press N if hidden) + 3. Keep the 'Use Poly Pizza' checkbox checked + 4. Enter your Poly Pizza API Key + 5. Restart the connection to Claude""" + } + else: + return { + "enabled": False, + "message": """Poly Pizza integration is currently disabled. To enable it: + 1. Get a free API key at https://poly.pizza/settings/api + 2. In the 3D Viewport, find the MCP for Blender panel in the sidebar (press N if hidden) + 3. Check the 'Use assets from Poly Pizza' checkbox + 4. Enter your Poly Pizza API Key + 5. Restart the connection to Claude""" + } + + def search_polypizza_models(self, query=None, category=None, licence=None, + animated=False, limit=20, page=None): + """Search for models on Poly Pizza by keyword and/or filters + + Parameters: + - query: Keyword to search for. When omitted, at least one filter is + required: the bare /search endpoint answers 400 without one. + - category: Numeric category id (0-11); the MCP server resolves names + - licence: Numeric licence id (0 = CC-BY, 1 = CC0); the MCP server resolves names + - animated: When True, return only animated models + - limit: Maximum number of results to return (the API caps a page at 32) + - page: Optional 0-based page number + """ + try: + api_key = self._get_polypizza_api_key() + if not api_key: + return {"error": "Poly Pizza API key is not configured"} + + try: + filters = _polypizza_filter_params(category, licence, animated) + except ValueError as e: + return {"error": str(e)} + + keyword = (query or "").strip() + if not keyword and not filters: + return {"error": ( + "Poly Pizza needs a search keyword or at least one filter " + "(category, licence, or animated=True). An unfiltered listing of the " + "whole catalogue is rejected by the API with HTTP 400." + )} + + # Limit and Page are Capitalized like the filters: lowercase + # variants are silently ignored and the API then serves its + # default page of 32. + params = dict(filters) + params["Limit"] = max(1, min(int(limit), 32)) + if page is not None: + params["Page"] = page + + headers = dict(REQ_HEADERS) + headers["x-auth-token"] = api_key + + if keyword: + url = f"{POLYPIZZA_API_BASE}/search/{quote(keyword, safe='')}" + else: + url = f"{POLYPIZZA_API_BASE}/search" + + response = requests.get(url, headers=headers, params=params, timeout=30) + + if response.status_code in (401, 403): + return {"error": f"Poly Pizza authentication failed ({response.status_code}). Check your API key."} + + if response.status_code == 400: + return {"error": ( + "Poly Pizza rejected the search parameters (400). Category must be an id in " + "0-11 and licence 0 (CC-BY) or 1 (CC0)." + )} + + if response.status_code == 429: + return {"error": "Poly Pizza rate limit exceeded (100 requests/second). Try again in a moment."} + + if response.status_code != 200: + return {"error": f"Poly Pizza API request failed with status code {response.status_code}"} + + response_data = response.json() + + if response_data is None: + return {"error": "Received empty response from Poly Pizza API"} + + results = response_data.get("results", []) + if not isinstance(results, list): + return {"error": f"Unexpected response format from Poly Pizza API: {response_data}"} + + return { + "total": response_data.get("total", len(results)), + "results": [_polypizza_summarize_model(m) for m in results if isinstance(m, dict)], + "filters_applied": filters, + } + + 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 Poly Pizza API: {str(e)}"} + except Exception as e: + import traceback + traceback.print_exc() + return {"error": str(e)} + + def download_polypizza_model(self, model_id, normalize_size=False, target_size=1.0): + """Download a model from Poly Pizza by its ID + + Parameters: + - model_id: The Poly Pizza model ID (from search_polypizza_models) + - 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 + """ + temp_dir = None + try: + api_key = self._get_polypizza_api_key() + if not api_key: + return {"error": "Poly Pizza API key is not configured"} + + headers = dict(REQ_HEADERS) + headers["x-auth-token"] = api_key + + response = requests.get( + f"{POLYPIZZA_API_BASE}/model/{quote(str(model_id), safe='')}", + headers=headers, + timeout=30 + ) + + if response.status_code in (401, 403): + return {"error": f"Poly Pizza authentication failed ({response.status_code}). Check your API key."} + + if response.status_code == 404: + return {"error": f"No Poly Pizza model found with ID '{model_id}'"} + + if response.status_code != 200: + return {"error": f"Poly Pizza model lookup failed with status code {response.status_code}"} + + model = response.json() + + if not isinstance(model, dict): + return {"error": f"Unexpected response format from Poly Pizza API: {model}"} + + download_url = model.get("Download") + if not download_url: + return {"error": f"Poly Pizza model '{model_id}' has no downloadable GLB file"} + + # The CDN takes no API key and must never be sent one: it is a + # separate host from the API. + file_response = requests.get(download_url, headers=dict(REQ_HEADERS), timeout=60) + + cdn_error = _polypizza_cdn_error( + file_response.status_code, + getattr(file_response, "headers", None), + file_response.content or b"", + ) + if cdn_error: + return {"error": cdn_error} + + # Every Poly Pizza model is a single self-contained .glb - no zip, + # no sidecar textures - so it goes straight to disk and into glTF import. + safe_id = re.sub(r"[^A-Za-z0-9_-]", "_", str(model_id)) or "model" + temp_dir = tempfile.mkdtemp() + glb_path = os.path.join(temp_dir, f"{safe_id}.glb") + + with open(glb_path, "wb") as f: + f.write(file_response.content) + + bpy.ops.import_scene.gltf(filepath=glb_path) + + # 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) + temp_dir = None + + # Find root objects (objects without parents in the imported set) + root_objects = [obj for obj in imported_objects if obj.parent is None] + + # 69% of the catalogue is CC-BY, so the credit line has to outlive + # the session. Custom properties are saved into the .blend. + attribution = model.get("Attribution") or "" + licence = model.get("Licence") or "" + for root in root_objects: + root["polypizza_attribution"] = attribution + root["polypizza_id"] = model.get("ID") or str(model_id) + root["polypizza_licence"] = licence + + # 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, + "model_id": model.get("ID") or str(model_id), + "title": model.get("Title"), + "licence": licence, + "attribution": attribution, + "tri_count": model.get("Tri Count"), + } + + 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."} + except json.JSONDecodeError as e: + return {"error": f"Invalid JSON response from Poly Pizza API: {str(e)}"} + except Exception as e: + import traceback + traceback.print_exc() + return {"error": f"Failed to download model: {str(e)}"} + finally: + if temp_dir: + with suppress(Exception): + shutil.rmtree(temp_dir) + #endregion + + #region Hunyuan3D + def get_hunyuan3d_status(self): + """Get the current status of Hunyuan3D integration""" + if premium_active(): + return premium_integration_status("hunyuan3d", bpy.context.scene.blendermcp_use_hunyuan3d) + enabled = bpy.context.scene.blendermcp_use_hunyuan3d + hunyuan3d_mode = bpy.context.scene.blendermcp_hunyuan3d_mode + secret_id = self._get_hunyuan3d_secret_id() + secret_key = self._get_hunyuan3d_secret_key() + api_url = self._get_hunyuan3d_api_url() + if enabled: + match hunyuan3d_mode: + case "OFFICIAL_API": + if not secret_id or not 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 MCP for Blender 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 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 MCP for Blender 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 MCP for Blender 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, quality=None, **kwargs): + # quality is Premium-only; your own Tencent key has no such option. + if premium_active(): + return premium_create_hunyuan_job(*args, quality=quality, **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 = self._get_hunyuan3d_secret_id() + secret_key = self._get_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"} + profile = hunyuan_api_profile( + getattr(bpy.context.scene, "blendermcp_hunyuan3d_intl_pro", False)) + service = profile["service"] + action = profile["submit_action"] + version = profile["version"] + region = profile["region"] + + headParams={ + "Action": action, + "Version": version, + "Region": region, + } + + # Constructing request parameters + data = profile["submit_body"] + + # Handling text prompts + if text_prompt: + if len(text_prompt) > 1024: + return {"error": "Prompt exceeds 1024 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), + timeout=30, + ) + + 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 = self._get_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, timeout=30) + 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)}"} + + # The local server generates synchronously, so allow it minutes to reply. + response = requests.post( + f"{base_url}/generate", + json = data, + timeout=(10, 600), + ) + + 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): + if premium_active(): + return premium_poll_hunyuan_job_status(*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 = self._get_hunyuan3d_secret_id() + secret_key = self._get_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"} + + profile = hunyuan_api_profile( + getattr(bpy.context.scene, "blendermcp_hunyuan3d_intl_pro", False)) + service = profile["service"] + action = profile["query_action"] + version = profile["version"] + region = profile["region"] + + 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), + timeout=30, + ) + + 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): + if premium_active(): + return premium_import_generated_asset_hunyuan(*args, **kwargs) + return self.import_generated_asset_hunyuan_ai(*args, **kwargs) + + def _import_hunyuan_glb(self, name: str, glb_url: str): + temp_dir = tempfile.mkdtemp(prefix="hunyuan_glb_") + glb_path = osp.join(temp_dir, "model.glb") + try: + glb_response = requests.get(glb_url, stream=True, timeout=120) + glb_response.raise_for_status() + with open(glb_path, "wb") as f: + for chunk in glb_response.iter_content(chunk_size=8192): + f.write(chunk) + bpy.ops.import_scene.gltf(filepath=glb_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 from GLB"} + 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": + result["world_bounding_box"] = self._get_aabb(obj) + return {"succeed": True, **result} + except Exception as e: + return {"succeed": False, "error": str(e)} + finally: + with suppress(Exception): + shutil.rmtree(temp_dir) + + def import_generated_asset_hunyuan_ai(self, name: str, zip_file_url: str): + if not zip_file_url: + return {"error": "No file URL provided"} + + # Validate URL + if not re.match(r'^https?://', zip_file_url, re.IGNORECASE): + return {"error": "Invalid URL format. Must start with http:// or https://"} + + # Prefer GLB (self-contained with materials) over OBJ/ZIP (API 3.0 returns .glb URLs) + url_path = zip_file_url.split('?', 1)[0].split('#', 1)[0].lower() + if url_path.endswith('.glb'): + return self._import_hunyuan_glb(name, zip_file_url) + + # Fallback: ZIP/OBJ import (legacy) + 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") + try: + zip_response = requests.get(zip_file_url, stream=True, timeout=120) + 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) + with zipfile.ZipFile(zip_file_path, "r") as zip_ref: + # Mirror the Sketchfab zip-slip checks before extractall. + abs_temp_dir = os.path.abspath(temp_dir) + for file_info in zip_ref.infolist(): + file_path = file_info.filename + target_path = os.path.join(temp_dir, os.path.normpath(file_path)) + abs_target_path = os.path.abspath(target_path) + if not abs_target_path.startswith(abs_temp_dir + os.sep) and abs_target_path != abs_temp_dir: + return { + "succeed": False, + "error": "Security issue: Zip contains files with path traversal attempt", + } + if ".." in file_path: + return { + "succeed": False, + "error": "Security issue: Zip contains files with directory traversal sequence", + } + zip_ref.extractall(temp_dir) + 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"} + 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": + result["world_bounding_box"] = self._get_aabb(obj) + return {"succeed": True, **result} + except Exception as e: + return {"succeed": False, "error": str(e)} + finally: + with suppress(Exception): + shutil.rmtree(temp_dir) + #endregion + +#region Premium +# Premium is a different key, not a different set of tools. With the Premium +# toggle on, the Hyper3D and Hunyuan3D commands send the same request through +# the MCP for Blender server, which holds the fal key and decides quotas, and +# replies are reshaped into the fal/Tencent shapes the MCP tools already +# parse. Tripo is only offered through Premium. With generation_source left at +# BYOK nothing here runs, and nothing here touches the network until the user +# activates a key. + +# --- importing a finished job's files +# Files keep their own names, because an OBJ finds its MTL and the MTL finds +# its texture by name. + +MODEL_DOWNLOAD_TIMEOUT = 120 + + +def _safe_file_name(name, fallback): + name = re.sub(r"[^A-Za-z0-9._-]", "_", osp.basename(str(name or ""))) + return name if name.strip(".") else fallback + + +def _match_texture_names(directory): + """If a texture arrived without its file name, save it under the one name + the MTL is still missing.""" + names = os.listdir(directory) + referenced = set() + for name in names: + if name.lower().endswith(".mtl"): + with open(osp.join(directory, name), encoding="utf-8", errors="replace") as f: + for line in f: + parts = line.split() + if len(parts) >= 2 and (parts[0].lower().startswith("map_") or parts[0].lower() == "bump"): + referenced.add(osp.basename(parts[-1].replace("\\", "/"))) + missing = [name for name in referenced if name not in names] + spare = [name for name in names if name.lower().endswith((".png", ".jpg", ".jpeg")) and name not in referenced] + if len(missing) == 1 and len(spare) == 1 and _safe_file_name(missing[0], "") == missing[0]: + shutil.copyfile(osp.join(directory, spare[0]), osp.join(directory, missing[0])) + + +def _download_model_files(files, directory): + """files: [{role, format, url, file_name}]. Returns (model path, format).""" + model = None + used = set() + for index, file in enumerate(files): + url = str(file.get("url") or "") + if not re.match(r"^https?://", url, re.IGNORECASE): + raise ValueError("Invalid model file URL.") + fmt = str(file.get("format") or "").lower() + name = _safe_file_name(file.get("file_name"), f"{file.get('role') or 'file'}.{fmt or 'bin'}") + if name in used: + name = f"{index}_{name}" + used.add(name) + path = osp.join(directory, name) + response = requests.get(url, stream=True, timeout=MODEL_DOWNLOAD_TIMEOUT) + response.raise_for_status() + with open(path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + if file.get("role") == "model" and model is None: + model = (path, fmt or osp.splitext(name)[1].lstrip(".").lower()) + if model is None: + raise ValueError("No model file to import.") + _match_texture_names(directory) + return model + + +def _import_mesh_file(path, fmt, name): + existing_objects = set(bpy.data.objects) + existing_images = set(bpy.data.images) + if fmt == "obj": + if bpy.app.version >= (4, 0, 0): + bpy.ops.wm.obj_import(filepath=path) + else: + bpy.ops.import_scene.obj(filepath=path) + else: + bpy.ops.import_scene.gltf(filepath=path) + # The download folder is deleted after import, so embed any texture the + # OBJ importer loaded from it. glTF textures are packed already. + for image in set(bpy.data.images) - existing_images: + with suppress(Exception): + if image.filepath and not image.packed_file: + image.pack() + imported = set(bpy.data.objects) - existing_objects + meshes = [obj for obj in imported if obj.type == "MESH"] + if not meshes: + raise ValueError("No mesh objects were imported.") + obj = max(meshes, key=lambda o: len(o.data.vertices)) + # glTF wraps the mesh in an empty; drop it so the mesh is the object Claude moves. + parent = obj.parent + if parent is not None and parent in imported and parent.type == "EMPTY" and len(parent.children) == 1: + matrix = obj.matrix_world.copy() + obj.parent = None + obj.matrix_world = matrix + bpy.data.objects.remove(parent) + if name: + obj.name = name + with suppress(Exception): + obj.data.name = name + return obj + + +def import_model_files(files, name): + """Download and import; returns the importers' usual reply shape.""" + temp_dir = tempfile.mkdtemp(prefix="blendermcp_model_") + try: + path, fmt = _download_model_files(files, temp_dir) + obj = _import_mesh_file(path, fmt, name) + return { + "succeed": True, + "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], + "world_bounding_box": BlenderMCPServer._get_aabb(obj), + } + except Exception as e: + return {"succeed": False, "error": str(e)} + finally: + with suppress(Exception): + shutil.rmtree(temp_dir) + + +# Overridden by BLENDERMCP_PREMIUM_API_URL, so forks can point at their own +# Supabase project without editing code. +PREMIUM_API_URL_DEFAULT = "https://dbunjhuejdwwcafedslw.supabase.co/functions/v1/premium" +PREMIUM_PRICING_URL = "https://mcp-for-blender.com/premium" +PREMIUM_MANAGE_URL_DEFAULT = "https://mcp-for-blender.com/account" +PREMIUM_TIMEOUT = 30 +PREMIUM_MAX_IMAGE_BYTES = 8 * 1024 * 1024 # fal's image-to-3D input limit + +# Fallback wording for codes the addon raises itself or that arrive without a +# message. The server's message wins when it sends one. +PREMIUM_ERROR_MESSAGES = { + "INVALID_KEY": "License key not recognised. Check it in Preferences, or switch to your own API keys.", + "NOT_ACTIVATED": "This device isn't activated. Click Activate in Preferences.", + "ACTIVATION_LIMIT": "Key is active on 3 devices. Deactivate one from its Blender Preferences.", + "NO_ACTIVE_SUBSCRIPTION": "Your Premium subscription isn't active. Manage it at the account link.", + "NOT_IN_PLAN": "High-quality models are included in Pro. Use standard quality, or upgrade.", + "QUOTA_EXHAUSTED": "You've used all generations this month. Upgrade for more.", + "TOO_MANY_IN_FLIGHT": "Wait for the current generation to finish, then try again.", + "SERVICE_PAUSED": "Premium generation is paused briefly. Your own API keys still work.", + "PROVIDER_ERROR": "The model provider failed. This attempt wasn't counted; try again.", + "ADDON_OUTDATED": "Update the addon to keep using Premium.", + "NETWORK_ERROR": "Couldn't reach the Premium server. Your own API keys still work.", + "BAD_RESPONSE": "The Premium server sent an unexpected reply. Try again shortly.", +} + +# The scene checkboxes that turn each generator on, in both modes. +PREMIUM_INTEGRATIONS = { + "hyper3d": ("Hyper3D Rodin", "blendermcp_use_hyper3d"), + "hunyuan3d": ("Hunyuan3D", "blendermcp_use_hunyuan3d"), + "tripo": ("Tripo", "blendermcp_use_tripo"), +} + +# Last usage summary the server sent. draw() reads only this, never the network. +_premium_status = {} +# Hunyuan's import tool takes a URL, but an OBJ result is three files, so +# remember each finished job's full file list by its model URL. +_premium_files_by_url = {} + + +def _premium_prefs(): + try: + return get_blendermcp_addon_preferences() + except Exception: + return None + + +def _premium_setting(pref_attr, env_var): + prefs = _premium_prefs() + value = getattr(prefs, pref_attr, "") if prefs else "" + return value or os.getenv(env_var, "") + + +def premium_license_key(): + return _premium_setting("premium_license_key", "BLENDERMCP_PREMIUM_LICENSE_KEY").strip() + + +def premium_instance_id(): + return _premium_setting("premium_instance_id", "BLENDERMCP_PREMIUM_INSTANCE_ID").strip() + + +def premium_api_url(): + return (os.getenv("BLENDERMCP_PREMIUM_API_URL") or PREMIUM_API_URL_DEFAULT).rstrip("/") + + +def premium_active(): + """Premium replaces the user's own keys when selected in Preferences, or + for headless runs that supply a license key by env var.""" + prefs = _premium_prefs() + if prefs is not None and getattr(prefs, "generation_source", "BYOK") == "PREMIUM": + return True + return bool(os.getenv("BLENDERMCP_PREMIUM_LICENSE_KEY")) + + +def premium_enabled_generators(): + """Generators the user can call through Premium right now, for the + handshake. Reads local settings only, never the network.""" + try: + if not premium_active(): + return [] + scene = bpy.context.scene + return [name for name, (_label, prop) in PREMIUM_INTEGRATIONS.items() + if getattr(scene, prop, False)] + except Exception: + return [] + + +def premium_error(code, message=None): + message = message or PREMIUM_ERROR_MESSAGES.get(code, code) + return {"error": message, "code": code, "message": message} + + +def _premium_tag_redraw(): + try: + for window in bpy.context.window_manager.windows: + for area in window.screen.areas: + if area.type in {"VIEW_3D", "PREFERENCES"}: + area.tag_redraw() + except Exception: + pass + + +def _premium_remember_usage(data): + usage = data.get("usage") if isinstance(data, dict) else None + if isinstance(usage, dict): + _premium_status.clear() + _premium_status.update(usage) + _premium_tag_redraw() + + +def premium_request(method, path, payload=None, auth=True): + """Send one request to the Premium server. Never raises: transport + failures and non-JSON replies come back as {error, code, message}.""" + headers = { + "X-Addon-Version": ".".join(str(part) for part in bl_info["version"]), + "X-Addon-Protocol": str(ADDON_PROTOCOL_VERSION), + } + if auth: + key = premium_license_key() + if not key: + return premium_error("INVALID_KEY", "No Premium license key set. Paste it in Preferences, or switch to your own API keys.") + instance_id = premium_instance_id() + if not instance_id: + return premium_error("NOT_ACTIVATED") + headers["Authorization"] = f"Bearer {key}" + headers["X-Instance-Id"] = instance_id + try: + response = requests.request( + method, premium_api_url() + path, headers=headers, json=payload, timeout=PREMIUM_TIMEOUT, + ) + except Exception as e: + return premium_error("NETWORK_ERROR", f"{PREMIUM_ERROR_MESSAGES['NETWORK_ERROR']} ({e})") + try: + data = response.json() + except Exception: + data = None + if not isinstance(data, dict): + return premium_error("BAD_RESPONSE", f"{PREMIUM_ERROR_MESSAGES['BAD_RESPONSE']} (HTTP {response.status_code})") + _premium_remember_usage(data) + if response.status_code >= 400 or data.get("code"): + code = data.get("code") or "BAD_RESPONSE" + return premium_error(code, data.get("message")) + return data + + +def _premium_format_date(value): + try: + moment = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + return f"{moment.strftime('%b')} {moment.day}" + + +def _premium_remaining(status, pool): + counts = status.get(pool) or {} + return int(counts.get("remaining", max(0, counts.get("total", 0) - counts.get("used", 0)))) + + +def premium_short_status(): + """One line for the sidebar, from the cache only. The cache is empty after + a restart until the next request, which isn't the same as no plan.""" + if not premium_instance_id(): + return "Premium: not activated" + if not _premium_status: + return "Premium: activated" + if not _premium_status.get("tier"): + return "Premium: subscription not active" + return (f"Premium: {str(_premium_status['tier']).title()}, " + f"{_premium_remaining(_premium_status, 'standard')} + " + f"{_premium_remaining(_premium_status, 'premium')} left") + + +def premium_summary(status=None): + status = _premium_status if status is None else status + tier = status.get("tier") + if not tier: + return "Premium: no active subscription." + text = (f"Premium ({str(tier).title()}): {_premium_remaining(status, 'standard')} standard and " + f"{_premium_remaining(status, 'premium')} high-quality generations left") + reset = _premium_format_date(status.get("resets_at")) + return f"{text}, resets {reset}" if reset else text + + +def _premium_high_locked(): + """True once the server has said this plan has no high-quality allowance.""" + return bool(_premium_status.get("tier")) and (_premium_status.get("premium") or {}).get("total", 0) == 0 + + +def premium_default_quality(): + prefs = _premium_prefs() + value = getattr(prefs, "premium_default_quality", "standard") if prefs else "standard" + if value not in ("standard", "high") or (value == "high" and _premium_high_locked()): + return "standard" + return value + + +def _premium_quality(model, quality, from_image): + """An explicit quality wins. Otherwise Rodin, and Tripo from an image, use + high (their only option), and everything else the user's default.""" + if quality: + return quality + if model == "rodin" or (model == "tripo" and from_image): + return "high" + return premium_default_quality() + + +def premium_integration_status(integration, enabled): + """Reply for get_hyper3d_status / get_hunyuan3d_status / get_tripo_status.""" + label, prop = PREMIUM_INTEGRATIONS[integration] + if not enabled: + return {"enabled": False, "mode": "PREMIUM", "message": ( + f"{label} is turned off. To use it through Premium, check '{label}' in the MCP for Blender " + "sidebar (press N in the 3D Viewport).")} + result = premium_request("GET", "/status") + if result.get("code") in {"NETWORK_ERROR", "BAD_RESPONSE"} and _premium_status.get("tier"): + summary = premium_summary() + " (cached; the Premium server is unreachable right now)" + elif result.get("code"): + return {"enabled": False, "mode": "PREMIUM", "message": result["message"]} + else: + summary = premium_summary() + quality = (f"Default quality: {premium_default_quality()}. Pass quality='high' only when the user asks " + "for more detail; it uses a high-quality generation.") + flow = { + "hyper3d": ("Mode: PREMIUM. Behaves like FAL_AI mode: pass request_id to poll_rodin_job_status and " + "import_generated_asset. Every Rodin generation counts as high-quality."), + "hunyuan3d": ("Mode: PREMIUM. Follow the OFFICIAL_API flow: generate_hunyuan3d_model, then " + "poll_hunyuan_job_status, then import_generated_asset_hunyuan with the ResultFile3Ds URL. " + + quality), + "tripo": "Mode: PREMIUM. From an image, Tripo is high-quality only. " + quality, + }[integration] + return {"enabled": True, "mode": "PREMIUM", "message": f"{summary}. {flow}"} + + +def _premium_image_payload(image): + """URLs go to the server as-is; local files are read here, since the + server cannot see the user's disk.""" + if isinstance(image, (tuple, list)) and len(image) == 2: + suffix, encoded = image + raw = base64.b64decode(encoded) if isinstance(encoded, str) else encoded + return {"image_base64": base64.b64encode(raw).decode("ascii"), + "image_mime": _premium_mime(suffix)}, len(raw) + if re.match(r'^https?://', str(image), re.IGNORECASE): + return {"image_url": image}, 0 + with open(image, "rb") as f: + raw = f.read() + return {"image_base64": base64.b64encode(raw).decode("ascii"), + "image_mime": _premium_mime(osp.splitext(image)[1])}, len(raw) + + +def _premium_mime(suffix): + suffix = str(suffix or "").lower().lstrip(".") + return {"jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp"}.get(suffix, "image/png") + + +def premium_create_job(model, quality="standard", text_prompt=None, image=None, bbox_condition=None): + if bool(text_prompt) == bool(image): + return {"error": "Give exactly one of a text prompt or an image."} + if text_prompt and len(text_prompt) > 1024: + return {"error": "Prompt exceeds 1024 characters limit"} + if quality not in ("standard", "high"): + return {"error": "quality must be 'standard' or 'high'"} + payload = {"client_request_id": str(uuid.uuid4()), "operation": "generate", + "model": model, "quality": quality} + if text_prompt: + payload["prompt"] = text_prompt + else: + try: + image_fields, size = _premium_image_payload(image) + except Exception as e: + return {"error": f"Image encoding failed: {e}"} + if size > PREMIUM_MAX_IMAGE_BYTES: + return {"error": "Image is larger than 8 MB; use a smaller image or an image URL."} + payload.update(image_fields) + if bbox_condition: + payload["bbox_condition"] = bbox_condition + result = premium_request("POST", "/jobs", payload) + if result.get("code") == "NETWORK_ERROR": + # The first attempt may have reached the server; the same + # client_request_id returns that job instead of charging again. + result = premium_request("POST", "/jobs", payload) + return result + + +def _premium_job(job_id): + return premium_request("GET", f"/jobs/{quote(str(job_id), safe='')}") + + +def _premium_job_files(job): + files = job.get("files") + if not files and job.get("result_url"): + # Jobs finished before the server sent file lists were always one GLB. + files = [{"role": "model", "format": "glb", "url": job["result_url"], "file_name": "model.glb"}] + return files or None + + +def premium_import_job(request_id, name): + if not request_id: + return {"succeed": False, "error": "request_id is required"} + job = _premium_job(request_id) + if job.get("code"): + return {"succeed": False, **job} + files = _premium_job_files(job) + if job.get("status") != "succeeded" or not files: + return {"succeed": False, "error": "Generation isn't finished yet. Poll until COMPLETED, then import."} + return import_model_files(files, name) + + +# Rodin and Tripo: fal-style shapes, so the tools treat Premium like fal mode. + +_PREMIUM_FAL_STATUS = {"reserved": "IN_QUEUE", "succeeded": "COMPLETED", "failed": "FAILED"} + + +def premium_poll_fal_job(request_id): + if not request_id: + return {"error": "request_id is required"} + job = _premium_job(request_id) + if job.get("code") or "status" not in job: + return job + status = _PREMIUM_FAL_STATUS.get(job["status"]) + if status is None: + status = "IN_QUEUE" if job.get("provider_status") == "IN_QUEUE" else "IN_PROGRESS" + reply = {"status": status} + if status == "FAILED": + reply["error"] = (job.get("error") or "Generation failed") + ". This attempt wasn't counted." + return reply + + +def _premium_fal_reply(result, note=None): + if not result.get("job_id"): + return result + reply = {"request_id": result["job_id"], "status": "IN_QUEUE", "message": premium_summary()} + if note: + reply["note"] = note + return reply + + +def premium_create_rodin_job(text_prompt=None, images=None, bbox_condition=None): + image = images[0] if images else None + result = premium_create_job("rodin", "high", text_prompt=text_prompt, image=image, + bbox_condition=bbox_condition) + return _premium_fal_reply(result, "Premium uses the first image only." if images and len(images) > 1 else None) + + +def premium_create_tripo_job(text_prompt=None, image=None, quality=None): + quality = _premium_quality("tripo", quality, bool(image)) + return _premium_fal_reply(premium_create_job("tripo", quality, text_prompt=text_prompt, image=image)) + + +def premium_tripo_status(): + enabled = bool(getattr(bpy.context.scene, "blendermcp_use_tripo", False)) + if premium_active(): + return premium_integration_status("tripo", enabled) + return {"enabled": False, "message": "Tripo is only available with MCP for Blender Premium."} + + +def premium_tripo_handlers(): + """Tripo commands, registered in Premium mode when its checkbox is on.""" + if not (premium_active() and getattr(bpy.context.scene, "blendermcp_use_tripo", False)): + return {} + return { + "create_tripo_job": premium_create_tripo_job, + "poll_tripo_job_status": premium_poll_fal_job, + "import_generated_asset_tripo": premium_import_job, + } + + +# Hunyuan: Tencent-style shapes, so server.py wraps JobId as job_ and +# reads Status / ResultFile3Ds exactly as it does for OFFICIAL_API. + +_PREMIUM_TENCENT_STATUS = {"reserved": "WAIT", "running": "RUN", "succeeded": "DONE", "failed": "FAIL"} + + +def premium_create_hunyuan_job(text_prompt=None, image=None, quality=None): + quality = _premium_quality("hunyuan", quality, bool(image)) + result = premium_create_job("hunyuan", quality, text_prompt=text_prompt, image=image) + if not result.get("job_id"): + return result + return {"Response": {"JobId": result["job_id"]}, "message": premium_summary()} + + +def premium_poll_hunyuan_job_status(job_id=None): + if not job_id: + return {"error": "JobId is required"} + job = _premium_job(str(job_id).removeprefix("job_")) + if job.get("code") or "status" not in job: + return job + response = {"Status": _PREMIUM_TENCENT_STATUS.get(job["status"], "RUN")} + files = _premium_job_files(job) if job["status"] == "succeeded" else None + if files: + model = next((f for f in files if f.get("role") == "model"), files[0]) + _premium_files_by_url[model["url"]] = files + response["ResultFile3Ds"] = [{"Type": str(model.get("format") or "glb").upper(), "Url": model["url"]}] + if job["status"] == "failed": + response["ErrorMessage"] = (job.get("error") or "Generation failed") + ". This attempt wasn't counted." + return {"Response": response} + + +def premium_import_generated_asset_hunyuan(name, zip_file_url): + if not zip_file_url or not re.match(r'^https?://', zip_file_url, re.IGNORECASE): + return {"error": "Invalid URL format. Must start with http:// or https://"} + files = _premium_files_by_url.get(zip_file_url) + if not files: + # Blender restarted since the poll: import the URL on its own. + path = zip_file_url.split("?", 1)[0].split("#", 1)[0].lower() + fmt = "obj" if path.endswith(".obj") else "glb" + files = [{"role": "model", "format": fmt, "url": zip_file_url, "file_name": f"model.{fmt}"}] + return import_model_files(files, name) + + +# --- UI + +def _premium_draw_quality(prefs, layout, text="Default quality"): + row = layout.row(align=True) + row.label(text=text) + row.prop_enum(prefs, "premium_default_quality", "standard") + locked = _premium_high_locked() + sub = row.row(align=True) + sub.enabled = not locked + sub.prop_enum(prefs, "premium_default_quality", "high", text="High (Pro)" if locked else "High") + + +def premium_draw_preferences(prefs, box): + col = box.column() + col.prop(prefs, "premium_license_key", text="License Key") + row = col.row(align=True) + if prefs.premium_instance_id: + row.operator("blendermcp.premium_deactivate", text="Deactivate", icon='UNLINKED') + row.operator("blendermcp.premium_refresh", text="Refresh", icon='FILE_REFRESH') + else: + row.operator("blendermcp.premium_activate", text="Activate", icon='LINKED') + if prefs.premium_instance_id: + col.label(text=premium_summary() if _premium_status else "Click Refresh to load your plan.", + icon='INFO') + _premium_draw_quality(prefs, col) + row = col.row(align=True) + row.operator("blendermcp.premium_open_account", text="Manage account", icon='URL') + row.operator("wm.url_open", text="Get Premium", icon='FUND').url = PREMIUM_PRICING_URL + + +def premium_draw_upsell(layout): + """For own-key users: a single line saying Premium exists and where to get it.""" + row = layout.row(align=True) + row.label(text="No API keys? Use Premium", icon='FUND') + row.operator("wm.url_open", text="Get Premium").url = PREMIUM_PRICING_URL + + +def premium_draw_panel(layout, prefs): + """Sidebar status in Premium mode. Reads the cache only.""" + box = layout.box() + col = box.column(align=True) + col.label(text=premium_short_status(), icon='FUND') + if not premium_instance_id(): + col.label(text="Activate your license key in Preferences.") + row = col.row(align=True) + row.operator("screen.userpref_show", text="Open Preferences", icon='PREFERENCES').section = 'ADDONS' + row.operator("wm.url_open", text="Get Premium", icon='FUND').url = PREMIUM_PRICING_URL + elif not _premium_status: + col.operator("blendermcp.premium_refresh", text="Load plan", icon='FILE_REFRESH') + elif not _premium_status.get("tier"): + col.label(text="Your subscription isn't active.") + row = col.row(align=True) + row.operator("blendermcp.premium_open_account", text="Manage account", icon='URL') + row.operator("wm.url_open", text="Get Premium", icon='FUND').url = PREMIUM_PRICING_URL + elif prefs is not None: + # Quality only means something once there's a plan to spend. + col.separator() + _premium_draw_quality(prefs, col, text="Quality") + + +class BLENDERMCP_OT_PremiumActivate(bpy.types.Operator): + bl_idname = "blendermcp.premium_activate" + bl_label = "Activate Premium" + bl_description = "Activate this device with your Premium license key" + + def execute(self, context): + prefs = get_blendermcp_addon_preferences(context) + key = premium_license_key() + if not prefs or not key: + self.report({'ERROR'}, "Paste your license key first.") + return {'CANCELLED'} + result = premium_request("POST", "/activate", { + "license_key": key, + "device_name": socket.gethostname() or "Blender", + }, auth=False) + if result.get("code") or not result.get("instance_id"): + self.report({'ERROR'}, result.get("message") or "Activation failed.") + return {'CANCELLED'} + prefs.premium_instance_id = result["instance_id"] + prefs.generation_source = "PREMIUM" + # Premium covers every generator, so switch them all on in this scene. + for _label, prop in PREMIUM_INTEGRATIONS.values(): + with suppress(Exception): + setattr(context.scene, prop, True) + _premium_tag_redraw() + self.report({'INFO'}, premium_summary()) + return {'FINISHED'} + + +class BLENDERMCP_OT_PremiumDeactivate(bpy.types.Operator): + bl_idname = "blendermcp.premium_deactivate" + bl_label = "Deactivate Premium" + bl_description = "Free this device's activation slot so the key can be used on another machine" + + def execute(self, context): + prefs = get_blendermcp_addon_preferences(context) + result = premium_request("POST", "/deactivate", {}) + # A key the server no longer knows has nothing left to free. + if result.get("code") and result["code"] not in {"INVALID_KEY", "NOT_ACTIVATED"}: + self.report({'ERROR'}, result["message"]) + return {'CANCELLED'} + if prefs: + prefs.premium_instance_id = "" + # Without an activation Premium can't generate, so go back to own keys. + # The license key stays filled in, so reactivating is one click. + prefs.generation_source = "BYOK" + _premium_status.clear() + # Deactivate runs from Preferences; the 3D Viewport sidebar won't redraw on its own. + _premium_tag_redraw() + self.report({'INFO'}, "This device was deactivated. Generation uses your own API keys again.") + return {'FINISHED'} + + +class BLENDERMCP_OT_PremiumRefresh(bpy.types.Operator): + bl_idname = "blendermcp.premium_refresh" + bl_label = "Refresh Premium Status" + bl_description = "Fetch your plan and remaining generations" + + def execute(self, context): + result = premium_request("GET", "/status") + if result.get("code"): + self.report({'ERROR'}, result["message"]) + return {'CANCELLED'} + self.report({'INFO'}, premium_summary()) + return {'FINISHED'} + + +class BLENDERMCP_OT_PremiumOpenAccount(bpy.types.Operator): + bl_idname = "blendermcp.premium_open_account" + bl_label = "Manage Premium Account" + bl_description = "Upgrade, cancel or download invoices" + + def execute(self, context): + # Portal links are per customer and short-lived, so fetch one per click. + url = PREMIUM_MANAGE_URL_DEFAULT + if premium_instance_id(): + url = premium_request("GET", "/status?portal=1").get("manage_url") or url + bpy.ops.wm.url_open(url=url) + return {'FINISHED'} + + +PREMIUM_CLASSES = ( + BLENDERMCP_OT_PremiumActivate, + BLENDERMCP_OT_PremiumDeactivate, + BLENDERMCP_OT_PremiumRefresh, + BLENDERMCP_OT_PremiumOpenAccount, +) +#endregion + +# Blender Addon Preferences +class BLENDERMCP_AddonPreferences(bpy.types.AddonPreferences): + bl_idname = __name__ + + def _on_telemetry_consent_changed(self, context): + try: + sync_edit_capture_handlers() + except Exception as e: + print(f"BlenderMCP: could not sync manual edit handlers: {e}") + + telemetry_consent: BoolProperty( + name="Allow Telemetry", + description="Opt in to collection of prompts, code snippets, screenshots, and trajectory data to help improve MCP for Blender. Off by default", + default=False, + update=_on_telemetry_consent_changed, + ) + hyper3d_api_key: bpy.props.StringProperty( + name="Hyper3D API Key", + subtype="PASSWORD", + description="Persistent Hyper3D API Key", + default="" + ) + sketchfab_api_key: bpy.props.StringProperty( + name="Sketchfab API Key", + subtype="PASSWORD", + description="Persistent Sketchfab API Key", + default="" + ) + polypizza_api_key: bpy.props.StringProperty( + name="Poly Pizza API Key", + subtype="PASSWORD", + description="Persistent Poly Pizza API Key", + default="" + ) + hunyuan3d_secret_id: bpy.props.StringProperty( + name="Hunyuan3D SecretId", + description="Persistent Hunyuan3D SecretId", + default="" + ) + hunyuan3d_secret_key: bpy.props.StringProperty( + name="Hunyuan3D SecretKey", + subtype="PASSWORD", + description="Persistent Hunyuan3D SecretKey", + default="" + ) + hunyuan3d_api_url: bpy.props.StringProperty( + name="Hunyuan3D API URL", + description="Persistent Hunyuan3D API URL", + default="" + ) + generation_source: bpy.props.EnumProperty( + name="Generation Source", + description="Where 3D model generation runs", + items=[ + ("BYOK", "Your own API keys", "Use your own Hyper3D, fal.ai or Tencent Cloud keys"), + ("PREMIUM", "Premium", "Generate through MCP for Blender Premium with one license key"), + ], + default="BYOK", + ) + premium_license_key: bpy.props.StringProperty( + name="Premium License Key", + subtype="PASSWORD", + description="License key from your MCP for Blender Premium purchase", + default="" + ) + premium_instance_id: bpy.props.StringProperty( + name="Premium Instance ID", + description="Set when this device is activated", + default="", + options={'HIDDEN'}, + ) + premium_default_quality: bpy.props.EnumProperty( + name="Default Quality", + description="Quality Claude uses unless you ask for another. High uses a high-quality generation", + items=[ + ("standard", "Standard", "Uses a standard generation"), + ("high", "High", "More detail; uses a high-quality generation (Pro)"), + ], + default="standard", + ) + + 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="Opted in: We collect anonymized prompts, code, screenshots,", icon='INFO') + box.label(text="and trajectory data (actions, scene state, feedback).", icon='BLANK1') + else: + box.label(text="Off (default): 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="Data is not linked to your name or account. 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') + + layout.separator() + layout.label(text="3D Generation:", icon='SHADERFX') + gen_box = layout.box() + gen_box.row().prop(self, "generation_source", expand=True) + if self.generation_source == "PREMIUM": + premium_draw_preferences(self, gen_box) + else: + col = gen_box.column() + premium_draw_upsell(col) + col.separator() + col.prop(self, "hyper3d_api_key", text="Hyper3D API Key") + row = col.row(align=True) + row.operator("wm.url_open", text="hyper3d.ai keys", icon='URL').url = "https://hyper3d.ai/" + row.operator("wm.url_open", text="fal.ai keys", icon='URL').url = "https://fal.ai/dashboard/keys" + col.separator() + col.prop(self, "hunyuan3d_secret_id", text="Hunyuan3D SecretId") + col.prop(self, "hunyuan3d_secret_key", text="Hunyuan3D SecretKey") + col.operator("wm.url_open", text="Tencent Cloud keys", icon='URL').url = \ + "https://console.cloud.tencent.com/cam/capi" + col.prop(self, "hunyuan3d_api_url", text="Hunyuan3D API URL") + + layout.separator() + layout.label(text="Persistent API Credentials:", icon='LOCKED') + cred_box = layout.box() + cred_box.prop(self, "sketchfab_api_key", text="Sketchfab API Key") + cred_box.prop(self, "polypizza_api_key", text="Poly Pizza API Key") + +# Blender UI Panel +class BLENDERMCP_PT_Panel(bpy.types.Panel): + bl_label = "MCP for Blender" + bl_idname = "BLENDERMCP_PT_Panel" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = 'MCP for Blender' + + def _integration_header(self, layout, scene, prop_name, title, icon): + """Draw an integration as a box with a checkbox header row. + Returns the box if the integration is enabled (for settings), else None.""" + box = layout.box() + row = box.row() + row.prop(scene, prop_name, text="") + row.label(text=title, icon=icon) + return box if getattr(scene, prop_name) else None + + def draw(self, context): + layout = self.layout + scene = context.scene + prefs = get_blendermcp_addon_preferences(context) + + # Connection + box = layout.box() + col = box.column() + if scene.blendermcp_server_running: + server = getattr(bpy.types, "blendermcp_server", None) + running_port = getattr(server, "port", scene.blendermcp_port) + col.label(text=f"Connected on port {running_port}", icon='CHECKMARK') + col.operator("blendermcp.stop_server", text="Disconnect", icon='X') + else: + col.label(text="Not connected", icon='RADIOBUT_OFF') + col.prop(scene, "blendermcp_port") + col.operator("blendermcp.start_server", text="Connect to MCP server", icon='PLAY') + + # Asset libraries + layout.separator() + layout.label(text="Asset Libraries", icon='ASSET_MANAGER') + + sub = self._integration_header( + layout, scene, "blendermcp_use_polyhaven", "Poly Haven", 'WORLD') + if sub: + col = sub.column(align=True) + col.label(text="Free CC0 HDRIs, textures and models") + col.operator("wm.url_open", text="polyhaven.com", icon='URL').url = POLYHAVEN_SITE + + sub = self._integration_header( + layout, scene, "blendermcp_use_sketchfab", "Sketchfab", 'MESH_MONKEY') + if sub: + col = sub.column(align=True) + if prefs: + col.prop(prefs, "sketchfab_api_key", text="API Key") + else: + col.prop(scene, "blendermcp_sketchfab_api_key", text="API Key") + + sub = self._integration_header( + layout, scene, "blendermcp_use_polypizza", "Poly Pizza", 'MESH_ICOSPHERE') + if sub: + col = sub.column(align=True) + if prefs: + col.prop(prefs, "polypizza_api_key", text="API Key") + else: + col.prop(scene, "blendermcp_polypizza_api_key", text="API Key") + + # AI model generation + layout.separator() + layout.label(text="AI Model Generation", icon='SHADERFX') + premium = premium_active() + if premium: + premium_draw_panel(layout, prefs) + else: + premium_draw_upsell(layout) + + sub = self._integration_header( + layout, scene, "blendermcp_use_hyper3d", "Hyper3D Rodin", 'MESH_UVSPHERE') + if sub and premium: + sub.label(text="Runs through Premium") + elif sub: + col = sub.column(align=True) + col.prop(scene, "blendermcp_hyper3d_mode", text="Mode") + if prefs: + col.prop(prefs, "hyper3d_api_key", text="API Key") + else: + col.prop(scene, "blendermcp_hyper3d_api_key", text="API Key") + sub.operator("blendermcp.set_hyper3d_free_trial_api_key", + text="Set Free Trial API Key", icon='KEYINGSET') + + sub = self._integration_header( + layout, scene, "blendermcp_use_hunyuan3d", "Tencent Hunyuan 3D", 'MESH_CUBE') + if sub and premium: + sub.label(text="Runs through Premium") + elif sub: + col = sub.column(align=True) + col.prop(scene, "blendermcp_hunyuan3d_mode", text="Mode") + if scene.blendermcp_hunyuan3d_mode == 'OFFICIAL_API': + if prefs: + col.prop(prefs, "hunyuan3d_secret_id", text="SecretId") + col.prop(prefs, "hunyuan3d_secret_key", text="SecretKey") + else: + col.prop(scene, "blendermcp_hunyuan3d_secret_id", text="SecretId") + col.prop(scene, "blendermcp_hunyuan3d_secret_key", text="SecretKey") + col.prop(scene, "blendermcp_hunyuan3d_intl_pro", text="International (Pro) account") + if scene.blendermcp_hunyuan3d_mode == 'LOCAL_API': + if prefs: + col.prop(prefs, "hunyuan3d_api_url", text="API URL") + else: + col.prop(scene, "blendermcp_hunyuan3d_api_url", text="API URL") + col.separator() + col.prop(scene, "blendermcp_hunyuan3d_octree_resolution", text="Octree Resolution") + col.prop(scene, "blendermcp_hunyuan3d_num_inference_steps", text="Inference Steps") + col.prop(scene, "blendermcp_hunyuan3d_guidance_scale", text="Guidance Scale") + col.prop(scene, "blendermcp_hunyuan3d_texture", text="Generate Texture") + + if premium: + sub = self._integration_header(layout, scene, "blendermcp_use_tripo", "Tripo", 'MESH_TORUS') + if sub: + sub.label(text="Runs through Premium") + + # Community section + layout.separator() + community_box = layout.box() + + col = community_box.column(align=True) + # Wrap by panel width so Blender doesn't elide the phrase mid-line. + ui_scale = context.preferences.system.ui_scale + region_width = context.region.width if context.region else 300 + chars = max(12, int((region_width - 30) / (7 * ui_scale))) + for line in textwrap.wrap("Stay updated with the latest developments", chars): + col.label(text=line) + col.separator() + row = col.row() + row.scale_y = 1.3 + row.operator("wm.url_open", text="Join Discord", icon='URL').url = DISCORD_URL + +# 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): + prefs = get_blendermcp_addon_preferences(context) + if prefs: + if not prefs.hyper3d_api_key or prefs.hyper3d_api_key == RODIN_FREE_TRIAL_KEY: + prefs.hyper3d_api_key = RODIN_FREE_TRIAL_KEY + else: + self.report( + {'INFO'}, + "Using free trial for this session only; saved private key was kept." + ) + 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 MCP for Blender server to connect with Claude" + + def execute(self, context): + global _user_stopped_server + _user_stopped_server = False + 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 = bpy.types.blendermcp_server.running + + 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): + global _user_stopped_server + _user_stopped_server = True + 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 MCP for Blender 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_auto_start_server = bpy.props.BoolProperty( + name="Auto-Start Server", + description="Automatically start the MCP server when Blender loads", + default=True + ) + + 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_intl_pro = bpy.props.BoolProperty( + name="International (Pro)", + description="Use the Tencent Cloud International 'Hunyuan-to-3D (Professional)' service " + "(hunyuan API, region ap-singapore, PBR enabled). Enable this when your SecretId/" + "SecretKey come from tencentcloud.com; leave it off for mainland AI3D 3.0 accounts", + default=False + ) + + 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="" + ) + + bpy.types.Scene.blendermcp_use_tripo = bpy.props.BoolProperty( + name="Use Tripo", + description="Enable Tripo 3D model generation (Premium)", + default=False + ) + + bpy.types.Scene.blendermcp_use_polypizza = bpy.props.BoolProperty( + name="Use Poly Pizza", + description="Enable Poly Pizza asset integration", + default=False + ) + + bpy.types.Scene.blendermcp_polypizza_api_key = bpy.props.StringProperty( + name="Poly Pizza API Key", + subtype="PASSWORD", + description="API Key provided by Poly Pizza", + 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) + for cls in PREMIUM_CLASSES: + bpy.utils.register_class(cls) + + # Add-on registration can run before Blender has a stable UI/scene context. + # Defer socket startup and retry after startup-file or .blend loads. + _blendermcp_register_auto_start() + + print("BlenderMCP addon registered") + +def unregister(): + _blendermcp_unregister_auto_start() + + _unregister_edit_capture_handlers() + + # 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) + for cls in PREMIUM_CLASSES: + bpy.utils.unregister_class(cls) + bpy.utils.unregister_class(BLENDERMCP_AddonPreferences) + + del bpy.types.Scene.blendermcp_port + del bpy.types.Scene.blendermcp_server_running + del bpy.types.Scene.blendermcp_auto_start_server + 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_tripo + del bpy.types.Scene.blendermcp_use_polypizza + del bpy.types.Scene.blendermcp_polypizza_api_key + del bpy.types.Scene.blendermcp_use_hunyuan3d + del bpy.types.Scene.blendermcp_hunyuan3d_mode + del bpy.types.Scene.blendermcp_hunyuan3d_intl_pro + 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() diff --git a/stacks/blender/conf/scripts/startup/fleet_mcp.py b/stacks/blender/conf/scripts/startup/fleet_mcp.py new file mode 100644 index 0000000..0d0c1a2 --- /dev/null +++ b/stacks/blender/conf/scripts/startup/fleet_mcp.py @@ -0,0 +1,97 @@ +"""Fleet startup hook for the mcp-for-blender add-on (runs at every Blender launch, from the +user scripts/startup dir). Part of eshpfi-management stacks/blender; see its README. + +The add-on (scripts/addons/blender_mcp.py, pinned upstream commit 41a18432) serves a socket that +runs arbitrary Python with NO authentication. The MCP server that talks to it runs INSIDE this +same container (/work/.mcp-venv), so the socket stays on the container's localhost and no port is +published. That same-container arrangement is also what makes viewport screenshots work: the MCP +server reads the image file Blender writes, which needs a shared filesystem. + +This hook exists because the add-on is not listed in any saved user preferences. It enables the +add-on after the prefs have loaded, and makes sure its server is running. + +Blender's stdout goes to /dev/null in this image, so every step is logged to +/config/.local/state/fleet_mcp.log. +""" +import os +import time +import traceback + +import addon_utils +import bpy + +MODULE, HOST, PORT = "blender_mcp", "localhost", 9876 +LOG = "/config/.local/state/fleet_mcp.log" + + +def log(msg): + os.makedirs(os.path.dirname(LOG), exist_ok=True) + with open(LOG, "a") as f: + f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n") + + +def _ensure_serving(): + """Timer: once Blender is fully up, enable the add-on and make sure its server is running on + HOST:PORT. Retries every second until it is. + + ⚠ The add-on is enabled HERE, not in register(). Startup scripts run before Blender loads + the user preferences, and loading them disables any add-on the prefs do not list: an early + enable was undone, taking the add-on's Scene properties with it, while the server object + kept answering "'Scene' object has no attribute 'blendermcp_use_polyhaven'" (found + 2026-09-27).""" + try: + server = getattr(bpy.types, "blendermcp_server", None) + if not addon_utils.check(MODULE)[1]: + import blender_mcp # our object first, so the add-on's auto-start adopts it + if server is None: + bpy.types.blendermcp_server = blender_mcp.BlenderMCPServer(host=HOST, port=PORT) + addon_utils.enable(MODULE, default_set=True) + log(f"add-on enabled: {MODULE}") + return 1.0 + import blender_mcp + + if server is not None and server.host != HOST: + if server.running: + server.stop() + server = None + if server is None: + server = blender_mcp.BlenderMCPServer(host=HOST, port=PORT) + bpy.types.blendermcp_server = server + if not server.running: + server.start() + if server.running and hasattr(bpy.context.scene, "blendermcp_use_polyhaven"): + log(f"serving on {server.host}:{server.port}") + _gpu_by_default() + return None + log("not serving yet; retrying") + return 1.0 + except Exception: + log("ensure_serving failed:\n" + traceback.format_exc()) + return 2.0 + + +def _gpu_by_default(): + """Point Cycles at GPU 3 through OptiX. Safe mode forbids agent code from touching + bpy.context.preferences, so a model cannot select the GPU itself: "'preferences' reached + through an unresolvable receiver" (found 2026-09-27). It CAN set scene.cycles.device, so the + current scene is set to GPU here too, and agents set it on scenes they create.""" + try: + prefs = bpy.context.preferences.addons["cycles"].preferences + prefs.compute_device_type = "OPTIX" + prefs.refresh_devices() + for d in prefs.devices: + d.use = d.type == "OPTIX" + bpy.context.scene.cycles.device = "GPU" + log("cycles: OPTIX on " + ", ".join(d.name for d in prefs.devices if d.use)) + except Exception: + log("cycles GPU default failed:\n" + traceback.format_exc()) + + +def register(): + if bpy.app.background: # headless renders never need the socket + return + bpy.app.timers.register(_ensure_serving, first_interval=2.0, persistent=True) + + +def unregister(): + pass