update addons (for 5.2)

This commit is contained in:
Nathan
2026-07-14 14:44:58 -06:00
parent a232ea1c9c
commit e3310263f3
246 changed files with 21097 additions and 9607 deletions
@@ -0,0 +1,29 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "mcp",
"name": "MCP",
"tagline": "MCP server add-on for LLM interaction",
"version": "1.0.0",
"type": "add-on",
"maintainer": "Blender Lab",
"license": [
"SPDX:GPL-3.0-or-later"
],
"blender_version_min": "5.1.0",
"website": "https://www.blender.org/lab/mcp-server",
"permissions": {
"network": "Runs a local TCP socket server for MCP client communication"
},
"tags": [
"Development"
],
"archive_url": "https://projects.blender.org/lab/blender_mcp/releases/download/v1.0.0/mcp-1.0.0.zip",
"archive_size": 16765,
"archive_hash": "sha256:838c3449f01015c861290658ae67f122f0846f7882f60a5dfda0ef7e6a9b8403"
}
]
}
+353
View File
@@ -0,0 +1,353 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Blender add-on that provides an MCP socket bridge-server.
"""
__all__ = (
"register",
"unregister",
)
import bpy # pylint: disable=import-error
from bpy.props import (
BoolProperty,
FloatProperty,
IntProperty,
StringProperty,
) # pylint: disable=import-error
from . import mcp_to_blender_server
_PORT_MIN = 1024
_PORT_MAX = 65535
# Default seconds to wait after registration before auto-starting the server.
# Avoids adding work to Blender's startup sequence.
_AUTOSTART_DELAY = 1.0
# Store the CLI handle, only for correct register/unregister.
_cli_commands: list[object] = []
# This error is shown in the UI & command line when online access isn't enabled.
#
# NOTE(@ideasman42): we could consider `localhost` to be acceptable, this is a grey area
# regarding what counts as "online" or not.
_state_offline_error_message = "Online access must be enabled in the system preferences"
class _State:
"""
Module-level runtime state that is not persisted across sessions.
"""
# Communicate to the user if there is a problem.
# Displayed in the preferences UI when non-empty.
autostart_error: str = ""
@classmethod
def startup_info_set(cls, error: str) -> None:
"""
Store a startup error message to display in the preferences UI.
"""
cls.autostart_error = error
@classmethod
def startup_info_set_from_exception(cls, ex: Exception) -> None:
"""
Store a startup exception message to display in the preferences UI
and print the full traceback to stderr for debugging.
"""
# NOTE: this is correct but reads like an unhandled exception.
# import traceback
# traceback.print_exception(ex)
cls.autostart_error = str(ex)
@classmethod
def startup_info_clear(cls) -> None:
"""
Clear any startup error so it no longer appears in the preferences UI.
"""
cls.autostart_error = ""
@classmethod
def startup_online_ok_or_error(cls) -> bool:
"""
Return True when online access is permitted, otherwise store an error and return False.
"""
if bpy.app.online_access:
return True
cls.startup_info_set(_state_offline_error_message)
if bpy.app.background:
print("Error: {:s}".format(_state_offline_error_message))
print(" Use --online-mode to enable online access from the command line")
return False
class _BlenderMCPPreferences(bpy.types.AddonPreferences): # type: ignore[misc]
bl_idname = __package__
host: StringProperty( # type: ignore[valid-type]
name="Host",
default=mcp_to_blender_server.DEFAULT_HOST,
)
port: IntProperty( # type: ignore[valid-type]
name="Port",
default=mcp_to_blender_server.DEFAULT_PORT,
min=_PORT_MIN,
max=_PORT_MAX,
)
use_autostart: BoolProperty( # type: ignore[valid-type]
name="Auto Start",
description=(
"Automatically start the MCP bridge server when Blender starts.\n"
"Without this, you must manually start from the preferences UI.\n"
"(ignored in background mode)"
),
default=True,
)
autostart_delay: FloatProperty( # type: ignore[valid-type]
name="Auto Start Delay",
description=(
"Seconds to wait after Blender starts before auto-starting the server.\n"
"Avoids adding overhead to Blender's startup sequence"
),
default=_AUTOSTART_DELAY,
min=0.0,
max=30.0,
step=10,
precision=1,
subtype="TIME_ABSOLUTE",
)
def _update_use_log(self, _context: bpy.types.Context) -> None:
mcp_to_blender_server.use_log = self.use_log
use_log: BoolProperty( # type: ignore[valid-type]
name="Log",
description="Print every tool request and response status to the terminal",
default=False,
update=_update_use_log,
)
def _update_timer_interval_active(self, _context: bpy.types.Context) -> None:
# Cached on the server module because the timer callback may fire
# many times a second, avoid slower preferences lookups.
mcp_to_blender_server.timer_internal_vars_calc(active=self.timer_interval_active)
timer_interval_active: FloatProperty( # type: ignore[valid-type]
name="Timer Interval",
description="Seconds between queue polling ticks in interactive mode",
default=0.25,
min=0.05,
max=5.0,
step=1,
precision=2,
subtype='TIME_ABSOLUTE',
update=_update_timer_interval_active,
)
def _update_timer_interval_idle(self, _context: bpy.types.Context) -> None:
# Cached on the server module because the timer callback may fire
# many times a second, avoid slower preferences lookups.
mcp_to_blender_server.timer_internal_vars_calc(idle=self.timer_interval_idle)
timer_interval_idle: FloatProperty( # type: ignore[valid-type]
name="Timer Interval Idle",
description="Seconds between queue polling ticks while idle (no pending work)",
default=1.0,
min=0.1,
max=10.0,
step=10,
precision=2,
subtype='TIME_ABSOLUTE',
update=_update_timer_interval_idle,
)
def _update_timer_interval_idle_delay(self, _context: bpy.types.Context) -> None:
# Cached on the server module because the timer callback may fire
# many times a second, avoid slower preferences lookups.
mcp_to_blender_server.timer_internal_vars_calc(idle_delay=self.timer_interval_idle_delay)
timer_interval_idle_delay: FloatProperty( # type: ignore[valid-type]
name="Idle Delay",
description="Seconds of inactivity before switching to the idle polling interval",
default=5.0,
min=1.0,
max=60.0,
step=100,
precision=1,
subtype='TIME_ABSOLUTE',
update=_update_timer_interval_idle_delay,
)
def draw(self, context: bpy.types.Context) -> None:
del context
layout = self.layout
layout.prop(self, "host")
layout.prop(self, "port")
layout.prop(self, "use_autostart")
layout.prop(self, "autostart_delay")
layout.prop(self, "timer_interval_active")
layout.prop(self, "timer_interval_idle")
layout.prop(self, "timer_interval_idle_delay")
layout.prop(self, "use_log")
if mcp_to_blender_server.is_running():
layout.operator("blmcp.server_stop", icon="CANCEL")
layout.label(text="Server is running", icon="CHECKMARK")
else:
layout.operator("blmcp.server_start", icon="PLAY")
layout.label(text="Server is stopped", icon="X")
if _State.autostart_error:
layout.label(text=_State.autostart_error, icon="ERROR")
class _BLMCP_OT_server_start(bpy.types.Operator): # type: ignore[misc]
bl_idname = "blmcp.server_start"
bl_label = "Start MCP Bridge Server"
bl_description = "Start the MCP socket bridge server that the MCP server can connect to"
def execute(self, context: bpy.types.Context) -> set[str]:
from . import execute_interactive
# Timers do not fire in background mode. Use the CLI command instead:
# `blender --background file.blend --command blender_mcp`.
if bpy.app.background:
self.report({"ERROR"}, "Use `--command blender_mcp` to start the MCP bridge server in background mode")
return {"CANCELLED"}
if not _State.startup_online_ok_or_error():
self.report({"ERROR"}, _state_offline_error_message)
return {"CANCELLED"}
# Clear any stale auto-start error so it does not persist in the UI.
_State.startup_info_clear()
prefs = context.preferences.addons[__package__].preferences
mcp_to_blender_server.timer_internal_vars_calc(
active=prefs.timer_interval_active,
idle=prefs.timer_interval_idle,
idle_delay=prefs.timer_interval_idle_delay,
)
mcp_to_blender_server.use_log = prefs.use_log
try:
mcp_to_blender_server.start(prefs.host, prefs.port)
except Exception as ex: # pylint: disable=broad-exception-caught
_State.startup_info_set_from_exception(ex)
self.report({"ERROR"}, str(ex))
return {"CANCELLED"}
bpy.app.timers.register(
execute_interactive.run,
first_interval=mcp_to_blender_server.TIMER_INTERVAL_ACTIVE,
persistent=True)
self.report({"INFO"}, "MCP server started on {:s}:{:d}".format(prefs.host, prefs.port))
return {"FINISHED"}
class _BLMCP_OT_server_stop(bpy.types.Operator): # type: ignore[misc]
bl_idname = "blmcp.server_stop"
bl_label = "Stop MCP Server"
bl_description = "Stop the MCP Bridge Server"
def execute(self, context: bpy.types.Context) -> set[str]:
del context
from . import execute_interactive
# Clear any stale auto-start error so it does not persist in the UI.
_State.startup_info_clear()
mcp_to_blender_server.stop()
if bpy.app.timers.is_registered(execute_interactive.run):
bpy.app.timers.unregister(execute_interactive.run)
self.report({"INFO"}, "MCP bridge server stopped")
return {"FINISHED"}
def _autostart_timer() -> None:
"""
Deferred timer callback that starts the server when ``use_autostart``
is enabled. Runs after a delay to avoid slowing down Blender's startup.
"""
from . import execute_interactive
if not _State.startup_online_ok_or_error():
return
prefs = bpy.context.preferences.addons[__package__].preferences
mcp_to_blender_server.timer_internal_vars_calc(
active=prefs.timer_interval_active,
idle=prefs.timer_interval_idle,
idle_delay=prefs.timer_interval_idle_delay,
)
mcp_to_blender_server.use_log = prefs.use_log
# This isn't expected:
# - Maybe the operator is explicitly called as part of an automated action.
# - The user might have set a very long delay for initial startup and
# manually enabled before the timer fires.
# Whatever the case, running multiple servers would cause confusing errors, so don't do it.
if mcp_to_blender_server.is_running():
return
try:
mcp_to_blender_server.start(prefs.host, prefs.port)
except Exception as ex: # pylint: disable=broad-exception-caught
_State.startup_info_set_from_exception(ex)
return
bpy.app.timers.register(
execute_interactive.run,
first_interval=mcp_to_blender_server.TIMER_INTERVAL_ACTIVE,
persistent=True)
def _cli_execute_handler(argv: list[str]) -> int:
"""
Callback for the CLI: ``blender -c blender_mcp``.
"""
if not _State.startup_online_ok_or_error():
return 1
from .cli import cli_execute
return cli_execute(argv)
_classes = (
_BlenderMCPPreferences,
_BLMCP_OT_server_start,
_BLMCP_OT_server_stop,
)
def register() -> None:
for cls in _classes:
bpy.utils.register_class(cls)
_cli_commands.append(bpy.utils.register_cli_command("blender_mcp", _cli_execute_handler))
# Defer auto-start so the server does not slow down Blender's startup.
if not bpy.app.background:
if not _State.startup_online_ok_or_error():
return
prefs = bpy.context.preferences.addons[__package__].preferences
if prefs.use_autostart:
bpy.app.timers.register(
_autostart_timer,
first_interval=prefs.autostart_delay,
persistent=True,
)
def unregister() -> None:
from . import execute_interactive
for cmd in _cli_commands:
bpy.utils.unregister_cli_command(cmd)
_cli_commands.clear()
if bpy.app.timers.is_registered(_autostart_timer):
bpy.app.timers.unregister(_autostart_timer)
mcp_to_blender_server.stop()
if bpy.app.timers.is_registered(execute_interactive.run):
bpy.app.timers.unregister(execute_interactive.run)
for cls in reversed(_classes):
bpy.utils.unregister_class(cls)
@@ -0,0 +1,19 @@
schema_version = "1.0.0"
id = "mcp"
version = "1.0.0"
name = "MCP"
tagline = "MCP server add-on for LLM interaction"
maintainer = "Blender Lab"
type = "add-on"
website = "https://www.blender.org/lab/mcp-server/"
blender_version_min = "5.1.0"
license = [
"SPDX:GPL-3.0-or-later",
]
tags = ["Development"]
[permissions]
network = "Runs a local TCP socket server for MCP client communication"
@@ -0,0 +1,82 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Context manager to capture STDOUT/STDERR while also forwarding to the real output.
Useful so the LLM may use print(..) style debugging and receive the results as part of the response.
"""
__all__ = (
"CaptureOutput",
)
import io
import sys
from typing import IO, Self
class _Tee(io.TextIOBase):
"""Write to both a :class:`io.StringIO` buffer and the original stream."""
__slots__ = (
"_buffer",
"_original",
)
def __init__(self, original: IO[str]) -> None:
self._buffer = io.StringIO()
self._original = original
def write(self, s: str) -> int:
self._original.write(s)
return self._buffer.write(s)
def flush(self) -> None:
self._original.flush()
self._buffer.flush()
def getvalue(self) -> str:
return self._buffer.getvalue()
class CaptureOutput:
"""
Context manager that captures STDOUT & STDERR.
Output is forwarded to the original streams in real time
and also stored for retrieval via :meth:`stdout` and :meth:`stderr`.
"""
__slots__ = (
"_tee_out",
"_tee_err",
"_original_out",
"_original_err",
)
def __enter__(self) -> Self:
self._original_out = sys.stdout
self._original_err = sys.stderr
self._tee_out = _Tee(self._original_out)
self._tee_err = _Tee(self._original_err)
sys.stdout = self._tee_out
sys.stderr = self._tee_err
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: object,
) -> None:
del exc_type, exc_val, exc_tb
sys.stdout = self._original_out
sys.stderr = self._original_err
@property
def stdout(self) -> str:
return self._tee_out.getvalue()
@property
def stderr(self) -> str:
return self._tee_err.getvalue()
+58
View File
@@ -0,0 +1,58 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""
CLI command handler for running the MCP server in background mode.
Started via ``blender --background file.blend --command blender_mcp``.
"""
__all__ = (
"cli_execute",
)
import argparse
from . import execute_blocking
from . import mcp_to_blender_server
def cli_execute(argv: list[str]) -> int:
"""
Block and serve MCP requests until interrupted.
"""
parser = argparse.ArgumentParser(
prog="blender_mcp",
description=(
"Start the Blender MCP server. "
"Deferred responses are not supported in background mode; "
"each request must complete before returning."
),
)
parser.add_argument(
"--host",
default=mcp_to_blender_server.DEFAULT_HOST,
help="Host to bind to.",
)
parser.add_argument(
"--port",
type=int,
default=mcp_to_blender_server.DEFAULT_PORT,
help="Port to listen on.",
)
args = parser.parse_args(argv)
try:
mcp_to_blender_server.start(args.host, args.port)
except Exception as ex: # pylint: disable=broad-exception-caught
print("Error: {:s}".format(str(ex)))
return 1
print("MCP server started on {:s}:{:d}, press Ctrl+C to exit.".format(args.host, args.port))
try:
execute_blocking.run()
finally:
mcp_to_blender_server.stop()
return 0
@@ -0,0 +1,216 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Deferred response handling for Blender background jobs.
When tool-code starts a background job (e.g. rendering with
``INVOKE_DEFAULT``), the response cannot be sent immediately. This
module holds the client connection open and polls a checker callable
until the operation completes, then sends the result.
The checker (``check_fn``) is called on the server's standard timer
which starts at an active interval but backs off when idle.
Checkers should be lightweight (e.g. check a flag or file existence)
so they don't block the UI, yet return promptly so the user is not left waiting after the job finishes.
"""
__all__ = (
"add",
"close_all",
"has_pending",
"poll",
)
import json
import socket
import time
import traceback
from collections.abc import Callable
from .mcp_to_blender_server import _encode_response
# Total wall-time in seconds allowed for a background task (e.g. rendering) to complete.
# When exceeded, an error response is sent and the connection is closed.
# The background task itself continues to run in Blender.
# One hour is long for what should typically be an interactive experience,
# but renders can take a long time and it's not desirable for them to simply give up.
_DEFERRED_TIMEOUT = (60.0 * 60.0)
class _DeferredClient:
"""
A client connection waiting for a background job to complete.
"""
__slots__ = (
"conn",
"check_fn",
"strict_json",
"stdout",
"stderr",
"deadline",
)
def __init__(
self,
conn: socket.socket,
check_fn: Callable[[], dict[str, object] | None],
strict_json: bool,
stdout: str,
stderr: str,
) -> None:
self.conn: socket.socket = conn
self.check_fn: Callable[[], dict[str, object] | None] = check_fn
self.strict_json: bool = strict_json
self.stdout: str = stdout
self.stderr: str = stderr
self.deadline: float = time.monotonic() + _DEFERRED_TIMEOUT
# Connections waiting for a background job to finish, polled each timer tick.
_deferred_clients: list[_DeferredClient] = []
def _send_and_close(dc: _DeferredClient, response: dict[str, object]) -> None:
try:
dc.conn.sendall(_encode_response(response))
except OSError:
pass
try:
dc.conn.close()
except OSError:
pass
try:
_deferred_clients.remove(dc)
except ValueError:
pass
def _is_disconnected(conn: socket.socket) -> bool:
"""
Return ``True`` if the remote end has closed the connection.
"""
try:
data = conn.recv(1, socket.MSG_PEEK)
# Empty data means the peer closed the connection.
return len(data) == 0
except BlockingIOError:
# No data available - connection is still alive.
return False
except OSError:
return True
def add(
conn: socket.socket,
check_fn: Callable[[], dict[str, object] | None],
strict_json: bool,
stdout: str,
stderr: str,
) -> None:
"""
Register a deferred client to be polled for completion.
"""
_deferred_clients.append(_DeferredClient(conn, check_fn, strict_json, stdout, stderr))
def poll() -> bool:
"""
Check all deferred clients for completion.
Return ``True`` if at least one client was resolved or removed.
"""
did_work = False
for dc in _deferred_clients[:]:
# Check for client disconnection.
if _is_disconnected(dc.conn):
try:
dc.conn.close()
except OSError:
pass
try:
_deferred_clients.remove(dc)
except ValueError:
pass
did_work = True
continue
# Check for timeout.
if time.monotonic() > dc.deadline:
_send_and_close(dc, {
"status": "error",
"message": "Deferred operation timed out after {:.0f} seconds".format(_DEFERRED_TIMEOUT),
})
did_work = True
continue
# Call the checker.
try:
result = dc.check_fn()
except Exception: # pylint: disable=broad-exception-caught
_send_and_close(dc, {
"status": "error",
"message": traceback.format_exc(),
})
did_work = True
continue
if result is None:
# Still pending.
continue
if not isinstance(result, dict):
_send_and_close(dc, {
"status": "error",
"message": "check_is_finished must return None or dict, not {:s}".format(
type(result).__name__,
),
})
did_work = True
continue
# Validate JSON serializability when strict_json is set.
if dc.strict_json:
try:
json.dumps(result)
except (TypeError, ValueError) as ex:
_send_and_close(dc, {
"status": "error",
"message": "Deferred result is not JSON-serializable: {:s}".format(str(ex)),
})
did_work = True
continue
# Build the final response with the standard envelope.
response: dict[str, object] = {"status": "ok", "result": result}
if dc.stdout:
response["stdout"] = dc.stdout
if dc.stderr:
response["stderr"] = dc.stderr
_send_and_close(dc, response)
did_work = True
return did_work
def has_pending() -> bool:
"""
Return ``True`` if there are deferred clients awaiting completion.
"""
return bool(_deferred_clients)
def close_all() -> None:
"""
Close all deferred client connections without sending responses.
"""
for dc in _deferred_clients:
try:
dc.conn.close()
except OSError:
pass
_deferred_clients.clear()
@@ -0,0 +1,32 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Blocking execution loop for the MCP server.
Uses ``select`` to wait for socket activity, intended for use in
``blender --background`` mode where ``bpy.app.timers`` do not fire.
Background mode does not support deferred responses; requests must complete
before returning.
"""
__all__ = (
"run",
)
from . import mcp_to_blender_server
def run() -> None:
"""
Block polling client connections until the server stops.
Catches ``KeyboardInterrupt`` so that Ctrl+C exits cleanly.
"""
try:
while mcp_to_blender_server.is_running():
mcp_to_blender_server.poll_blocking()
except KeyboardInterrupt:
pass
@@ -0,0 +1,48 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Interactive timer-based execution for the MCP server.
Polls client connections via ``bpy.app.timers`` so that requests are
handled in Blender's main loop during normal interactive sessions.
"""
__all__ = (
"run",
)
from . import mcp_to_blender_server
def run() -> float | None:
"""
Timer callback: poll connections, return next interval.
Returns ``None`` when the server is no longer running, which causes
``bpy.app.timers`` to unregister this callback.
"""
# While errors *should* never happen: without exception handling here,
# any error would remove the timer - effectively breaking the add-on.
try:
did_work = mcp_to_blender_server.poll()
except Exception: # pylint: disable=broad-exception-caught
import traceback
import sys
print(
"Error: unhandled exception in the MCP server timer.\n"
"This may be a bug in Blender-MCP, as errors should not be raised at this point, continuing:\n"
"{:s}".format(traceback.format_exc()),
file=sys.stderr,
)
# This is undefined, set to true so we reset the timer.
did_work = True
if not mcp_to_blender_server.is_running():
return None
if did_work:
mcp_to_blender_server.timer_idle_reset()
return mcp_to_blender_server.timer_idle_interval()
@@ -0,0 +1,615 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Non-blocking TCP socket server that runs inside Blender.
Listens for null-byte-delimited JSON requests, executes Python code
directly in the calling thread, and returns JSON responses.
All socket operations are non-blocking so the server never blocks
Blender's main thread.
"""
__all__ = (
"DEFAULT_HOST",
"DEFAULT_PORT",
"TIMER_INTERVAL_ACTIVE",
"is_running",
"poll",
"poll_blocking",
"start",
"stop",
"timer_idle_interval",
"timer_idle_reset",
"timer_internal_vars_calc",
"use_log",
)
import json
import math
import select
import socket
import sys
import traceback
from collections.abc import Callable
from typing import NamedTuple
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 9876
# Seconds between main-thread timer ticks.
TIMER_INTERVAL_ACTIVE = 0.05
# Seconds between main-thread timer ticks while idle (no pending work).
_TIMER_INTERVAL_IDLE = 1.0
# Seconds of inactivity before switching to the idle interval.
_TIMER_INTERVAL_IDLE_DELAY = 5.0
class _TimerState:
"""
Mutable singleton holding timer-related runtime state.
This is manipulated from the preferences and updated via ``timer_internal_vars_calc``.
"""
__slots__ = (
"interval_active",
"interval_idle",
"interval_idle_delay",
"idle_countdown_reset",
"idle_countdown",
"client_timeout_countdown",
)
def __init__(self) -> None:
self.interval_active: float = TIMER_INTERVAL_ACTIVE
self.interval_idle: float = _TIMER_INTERVAL_IDLE
self.interval_idle_delay: float = _TIMER_INTERVAL_IDLE_DELAY
# Number of active-rate ticks before switching to idle.
self.idle_countdown_reset: int = 0
# Current countdown. When zero, `timer_idle_interval` returns idle.
self.idle_countdown: int = 0
# Poll ticks before an idle client is evicted.
self.client_timeout_countdown: int = 2
_timer = _TimerState()
def timer_internal_vars_calc(
active: float | None = None,
idle: float | None = None,
idle_delay: float | None = None,
) -> None:
"""
Optionally update ``TIMER_*`` constants and recalculate internal variables.
When keyword arguments are provided they replace the corresponding
module-level ``TIMER_*`` value. Pass ``None`` (the default) to leave
a value unchanged.
"""
if active is not None:
_timer.interval_active = active
if idle is not None:
_timer.interval_idle = idle
if idle_delay is not None:
_timer.interval_idle_delay = idle_delay
# Round up so the delay is never shorter than requested.
_timer.idle_countdown_reset = math.ceil(_timer.interval_idle_delay / _timer.interval_active)
_timer.idle_countdown = _timer.idle_countdown_reset
_timer.client_timeout_countdown = max(2, math.ceil(_CLIENT_TIMEOUT / _timer.interval_active))
def timer_idle_reset() -> None:
"""
Signal that work was processed, resetting the idle countdown.
"""
_timer.idle_countdown = _timer.idle_countdown_reset
def timer_idle_interval() -> float:
"""
Return the appropriate timer interval, decrementing the idle countdown.
Returns ``TIMER_INTERVAL_ACTIVE`` while the countdown is positive,
then ``_TIMER_INTERVAL_IDLE`` once it reaches zero.
"""
if _timer.idle_countdown > 0:
_timer.idle_countdown -= 1
return _timer.interval_active
return _timer.interval_idle
# When True, print every request and response status to STDERR.
use_log: bool = False
_MAX_REQUEST_BYTES = 10 * 1024 * 1024 # 10 MiB.
# Maximum number of queued incoming connections.
_LISTEN_BACKLOG = 5
_RECV_BUFFER_SIZE = 4096
# Seconds before a client that has not sent a complete request is closed.
_CLIENT_TIMEOUT = 10.0
# How often `poll_blocking` checks for shutdown.
_POLL_BLOCKING_TIMEOUT = 1.0
_DEFERRED_UNSUPPORTED_MESSAGE = (
"Deferred responses via `check_is_finished` are only supported "
"by the interactive addon server, and are not available in "
"background mode. Finish the request synchronously instead."
)
timer_internal_vars_calc()
# ---------------------------------------------------------------------------
# Client connection state.
class _Client:
"""
Per-connection state for a client (the MCP server process) that has not yet sent a complete request.
"""
__slots__ = (
"conn",
"buffer",
"timeout",
)
def __init__(self, conn: socket.socket) -> None:
self.conn: socket.socket = conn
# Accumulates data until the null-byte delimiter is received.
self.buffer: bytearray = bytearray()
# Poll ticks remaining before this client is evicted.
self.timeout: int = _timer.client_timeout_countdown
# ---------------------------------------------------------------------------
# Server state.
class _State:
"""
Mutable singleton holding the runtime state of this socket server (the Blender add-on side).
"""
__slots__ = (
"sock",
"clients",
)
def __init__(self) -> None:
# The listening socket, or `None` when not running.
self.sock: socket.socket | None = None
# Connected clients that have not yet sent a complete request.
self.clients: list[_Client] = []
_state = _State()
class _ExecResult(NamedTuple):
"""
Result of executing tool-code.
When *check_fn* is not ``None``, the caller must defer the response
and poll the callable for completion (see ``deferred_tool``).
Otherwise *response* is the final result to send.
"""
response: dict[str, object]
check_fn: Callable[[], dict[str, object] | None] | None = None
def _encode_response(response: dict[str, object]) -> bytes:
"""
Serialize a response dict as null-byte-delimited JSON bytes.
"""
return (json.dumps(response) + "\0").encode("utf-8")
def _execute_code(
code: str,
strict_json: bool,
) -> _ExecResult:
"""
Execute *code* and return an ``_ExecResult``.
:param strict_json: When true, the response *must* be serializable.
Should always be true, when executing Python code we have full-control over,
because any non-serializable data is effectively a bug.
Only allow it to be false when executing arbitrary LLM generated code,
in this case it's not worth the overhead of correcting the LLM mistake,
just ``__repr__`` the value so it can fumble its way forward.
"""
from .capture_output import CaptureOutput
from .weak_sandbox import WeakSandboxForLLM
namespace: dict[str, object] = {"result": {}}
with CaptureOutput() as captured, WeakSandboxForLLM():
try:
exec(code, namespace)
except Exception: # pylint: disable=broad-exception-caught
response: dict[str, object] = {"status": "error", "message": traceback.format_exc()}
if captured.stdout:
response["stdout"] = captured.stdout
if captured.stderr:
response["stderr"] = captured.stderr
return _ExecResult(response)
# Check for a deferred response (background job in progress).
check_fn_raw = namespace.get("check_is_finished")
if check_fn_raw is not None and callable(check_fn_raw):
check_fn: Callable[[], dict[str, object] | None] = check_fn_raw
response = {}
if captured.stdout:
response["stdout"] = captured.stdout
if captured.stderr:
response["stderr"] = captured.stderr
return _ExecResult(response, check_fn)
result = namespace["result"]
if not isinstance(result, dict):
response = {
"status": "error",
"message": (
"The `result` variable must be a dict, not {:s}. "
"Wrap your return value: `result = {{\"key\": value}}`"
).format(type(result).__name__),
}
else:
# Guard against LLM-generated code storing non-serializable values
# such as Blender objects, e.g. `result = {"obj": bpy.context.active_object}`.
# Without this, `json.dumps` fails inside `_encode_response`.
if strict_json:
try:
json.dumps(result)
except (TypeError, ValueError) as ex:
response = {
"status": "error",
"message": "The `result` value is not JSON-serializable: {:s}".format(str(ex)),
}
else:
response = {"status": "ok", "result": result}
else:
# Use `repr` as a fallback so non-serializable objects
# (e.g. Blender ID types) appear as their string representation.
result = json.loads(json.dumps(result, default=repr))
response = {"status": "ok", "result": result}
if captured.stdout:
response["stdout"] = captured.stdout
if captured.stderr:
response["stderr"] = captured.stderr
return _ExecResult(response)
def _execute_code_from_request(
data: bytes,
) -> tuple[_ExecResult, bool]:
"""
Parse a raw request and execute it.
Return ``(exec_result, strict_json)``.
"""
# NOTE: This function is not expected to raise exceptions because we control the MCP server, tool-code and add-on.
# If there is an error, it will be handled by the caller (the LLM will get the stack trace).
#
# Even so, if a tool is misbehaving, or a change in the code causes an error,
# give a "helpful" response - to avoid the hassles of searching about for the root cause.
# Invalid JSON is not expected since the MCP server serializes requests with `json.dumps`.
# Any error should be rare, the "default" exception path is fine.
request = json.loads(data)
if request.get("type") != "execute":
return _ExecResult({
"status": "error",
"message": "Unknown request type: {!r}".format(request.get("type")),
}), False
code = request.get("code", "")
# Not expected in normal use, but a clear message beats a cryptic trace-back,
# Also make it clear where the error should be addressed.
strict_json = request.get("strict_json")
if not isinstance(strict_json, bool):
return (
_ExecResult({
"status": "error",
"message": (
"Internal error: a blender_mcp tool sent a request without the required 'strict_json' boolean key. "
"This is a bug in the tool that generated this code"
),
}),
False,
)
if use_log:
print("request:\n{:s}".format(code), file=sys.stderr)
exec_result = _execute_code(code, strict_json=strict_json)
if use_log:
if exec_result.check_fn is not None:
print("response: deferred", file=sys.stderr)
else:
print("response: {:s}".format(json.dumps(exec_result.response, indent=2)), file=sys.stderr)
return exec_result, strict_json
def _close_client(client: _Client) -> None:
"""
Close a client connection and remove it from the active list.
"""
try:
client.conn.close()
except Exception: # pylint: disable=broad-exception-caught
pass
try:
_state.clients.remove(client)
except ValueError:
pass
# ---------------------------------------------------------------------------
# Polling (called from the execution modules).
def _accept_clients() -> None:
"""
Accept all pending connections on the listening socket.
"""
if _state.sock is None:
return
while True:
try:
conn, _addr = _state.sock.accept()
conn.setblocking(False)
_state.clients.append(_Client(conn))
except BlockingIOError:
break
except OSError:
break
def _service_clients() -> bool:
"""
Read from all connected clients, execute complete requests.
Return ``True`` if at least one request was executed.
"""
did_work = False
# Iterate over a copy since clients may be removed during the loop.
for client in _state.clients[:]:
# Evict clients that have not sent a complete request in time.
client.timeout -= 1
if client.timeout <= 0:
try:
err: dict[str, object] = {
"status": "error",
"message": "Client timed out",
}
client.conn.sendall(_encode_response(err))
except OSError:
pass
_close_client(client)
continue
try:
chunk = client.conn.recv(_RECV_BUFFER_SIZE)
except BlockingIOError:
# No data available yet.
continue
except OSError:
_close_client(client)
continue
if not chunk:
# Client disconnected.
_close_client(client)
continue
client.buffer.extend(chunk)
# Guard against unbounded input from a misbehaving client.
if len(client.buffer) > _MAX_REQUEST_BYTES:
try:
err = {
"status": "error",
"message": "Request exceeds {:d} byte limit".format(_MAX_REQUEST_BYTES),
}
client.conn.sendall(_encode_response(err))
except OSError:
pass
_close_client(client)
continue
if b"\0" not in client.buffer:
# Request not yet complete.
continue
# Execute the request and send the response.
request_data = bytes(client.buffer[:client.buffer.index(b"\0")])
try:
exec_result, strict_json = _execute_code_from_request(request_data)
except Exception: # pylint: disable=broad-exception-caught
exec_result = _ExecResult({"status": "error", "message": traceback.format_exc()})
strict_json = False
if exec_result.check_fn is not None:
# Deferred response: hand the connection to deferred_tool.
from . import deferred_tool
deferred_tool.add(
client.conn,
exec_result.check_fn,
strict_json,
str(exec_result.response.get("stdout", "")),
str(exec_result.response.get("stderr", "")),
)
# Remove from clients without closing the socket.
try:
_state.clients.remove(client)
except ValueError:
pass
else:
try:
client.conn.sendall(_encode_response(exec_result.response))
except OSError:
pass
_close_client(client)
did_work = True
return did_work
def poll() -> bool:
"""
Non-blocking poll: accept new connections, service existing clients,
and check deferred responses.
Return ``True`` if work was done or deferred clients are pending.
"""
from . import deferred_tool
_accept_clients()
did_work = _service_clients()
if deferred_tool.poll():
did_work = True
# Stay in active polling mode while deferred clients exist.
if deferred_tool.has_pending():
did_work = True
return did_work
def _handle_blocking_client(conn: socket.socket) -> bool:
"""
Handle a single client connection synchronously with blocking I/O.
Return ``True`` if a request was executed.
"""
conn.settimeout(_CLIENT_TIMEOUT)
try:
buf = bytearray()
while b"\0" not in buf:
chunk = conn.recv(_RECV_BUFFER_SIZE)
if not chunk:
# Client disconnected.
return False
buf.extend(chunk)
if len(buf) > _MAX_REQUEST_BYTES:
err: dict[str, object] = {
"status": "error",
"message": "Request exceeds {:d} byte limit".format(_MAX_REQUEST_BYTES),
}
conn.sendall(_encode_response(err))
return False
request_data = bytes(buf[:buf.index(b"\0")])
try:
exec_result, _strict_json = _execute_code_from_request(request_data)
if exec_result.check_fn is not None:
# Unpack to preserve stdout/stderr captured before the deferred handler was set up.
response = {**exec_result.response, "status": "error", "message": _DEFERRED_UNSUPPORTED_MESSAGE}
exec_result = _ExecResult(response)
except Exception: # pylint: disable=broad-exception-caught
exec_result = _ExecResult({"status": "error", "message": traceback.format_exc()})
conn.sendall(_encode_response(exec_result.response))
return True
except socket.timeout:
try:
err = {"status": "error", "message": "Client timed out"}
conn.sendall(_encode_response(err))
except OSError:
pass
return False
except OSError:
return False
finally:
conn.close()
def poll_blocking(timeout: float = _POLL_BLOCKING_TIMEOUT) -> bool:
"""
Block until a connection arrives (up to *timeout* seconds), then
handle it synchronously with blocking I/O.
For use in background mode where the GUI is not running.
Return ``True`` if a request was executed.
"""
if _state.sock is None:
return False
try:
readable, _writable, _errored = select.select([_state.sock], [], [], timeout)
except (OSError, ValueError):
return False
if not readable:
return False
try:
conn, _addr = _state.sock.accept()
except (BlockingIOError, OSError):
return False
return _handle_blocking_client(conn)
# ---------------------------------------------------------------------------
# Public API.
def start(host: str, port: int) -> None:
"""
Bind the listening socket and begin accepting connections.
This does not block. The caller must arrange for ``poll`` to be
called periodically (see ``execute_interactive`` and
``execute_blocking``).
Callers should catch ``Exception`` broadly rather than specific types,
since failures may be:
- ``RuntimeError``, e.g. server already running.
- ``OSError``, e.g. address already in use.
...other exceptions that are difficult to predict exhaustively.
"""
if is_running():
raise RuntimeError("Server is already running")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setblocking(False)
sock.bind((host, port))
sock.listen(_LISTEN_BACKLOG)
except OSError:
sock.close()
raise
_state.sock = sock
def stop() -> None:
"""
Close the listening socket, all client connections, and deferred responses.
"""
from . import deferred_tool
sock = _state.sock
_state.sock = None
if sock is not None:
try:
sock.close()
except Exception: # pylint: disable=broad-exception-caught
pass
for client in _state.clients:
try:
client.conn.close()
except Exception: # pylint: disable=broad-exception-caught
pass
_state.clients.clear()
deferred_tool.close_all()
def is_running() -> bool:
"""
Return whether the server is currently listening.
"""
return _state.sock is not None
@@ -0,0 +1,161 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Weak sandbox for LLM-generated code execution.
Note that this isn't really a sandbox,
more guidance that some things should not be done.
Notes:
- The reason *not* to use the prompt is that it tends not to be reliable,
sometimes initial requests leave the context window (or are ignored for whatever reason),
so we are better off with a simple way to prevent some things from happening.
- If the LLM (or its user) is motivated these can be worked around.
This is more of a slap on the wrist not to try some things.
"""
__all__ = (
"WeakSandboxForLLM",
)
import sys
from typing import Any, Self
def _blocked_exit(*args: object, **kwargs: object) -> None: # noqa: ARG001
raise RuntimeError("sys.exit() is not allowed in LLM-generated code")
# Each entry is `(object, attr_name, replacement)`.
_OVERRIDES: tuple[tuple[object, str, object], ...] = (
(sys, "exit", _blocked_exit),
)
# Operators that LLM-generated code must not access.
# Each entry is `("module.func", "reason")`.
#
# Use this sparingly.
# The rule of thumb for inclusion is:
#
# The operator is guaranteed to cause problems and/or failure.
#
# There are lots of operations that are fairly questionable:
# - `bpy.ops.screen.spacedata_cleanup`.
# - `bpy.ops.wm.previews_clear`
#
# but it's not the purpose of this weak sandbox to disallow
# things the LLM probably shouldn't be doing.
#
# NOTE(@ideasman42): The scope of this may change over time,
# the statement above is a rule of thumb - to apply for now.
#
_BLOCKED_OPS: tuple[tuple[str, str], ...] = (
("wm.quit_blender", "Terminates the Blender process, use bpy.app.quit() if you must"),
(
"wm.read_factory_settings",
"Resets all user preferences and startup file, "
"use bpy.ops.wm.read_homefile() or "
"bpy.ops.wm.read_homefile(use_empty=True, use_factory_startup=True) instead",
),
(
"wm.read_factory_userpref",
"Resets all user preferences, "
"use bpy.ops.wm.read_homefile() or "
"bpy.ops.wm.read_homefile(use_empty=True, use_factory_startup=True) instead",
),
("wm.read_userpref", "May reset user preferences disabling this add-on, avoid calling"),
)
_BLOCKED_OPS_SET: frozenset[str] = frozenset(op for op, _reason in _BLOCKED_OPS)
class WeakSandboxForLLM:
"""Context manager wrapping ``exec()`` of LLM-generated code."""
__slots__ = (
"_store_attrs",
"_store_ops",
)
# -------------------------------------------------------------------------
# Attribute overrides
@staticmethod
def override_store() -> list[tuple[object, str, object]]:
"""Save current values listed in ``_OVERRIDES`` and apply replacements."""
saved: list[tuple[object, str, object]] = []
for obj, attr, replacement in _OVERRIDES:
saved.append((obj, attr, getattr(obj, attr)))
setattr(obj, attr, replacement)
return saved
@staticmethod
def override_restore(saved: list[tuple[object, str, object]]) -> None:
"""Restore values previously captured by :meth:`override_store`."""
for obj, attr, original in saved:
setattr(obj, attr, original)
# -------------------------------------------------------------------------
# Operator blocking
@staticmethod
def ops_blocked_store() -> tuple[Any, Any]:
"""Replace ``bpy.ops._op_create_function`` with a filtered wrapper.
Returns ``(bpy_ops_module, original_function)`` for later restore.
"""
import bpy.ops as _bpy_ops # noqa: WPS433
original = _bpy_ops._op_create_function
def _filtered_op_create_function(module: str, func: str) -> Any:
key = "{:s}.{:s}".format(module, func)
if key in _BLOCKED_OPS_SET:
reason = next(r for op, r in _BLOCKED_OPS if op == key)
def _blocked(
*args: tuple[object, ...],
**kwargs: dict[str, object],
) -> None:
# Include the arguments as they may help the LLM pin-point the cause of the error.
args_str = ", ".join(
[repr(a) for a in args] + ["{:s}={!r}".format(k, v) for k, v in kwargs.items()]
)
raise RuntimeError(
"Operator 'bpy.ops.{:s}({:s})' is not allowed in LLM-generated code: {:s}".format(
key, args_str, reason,
)
)
return _blocked
return original(module, func)
_bpy_ops._op_create_function = _filtered_op_create_function
return (_bpy_ops, original)
@staticmethod
def ops_blocked_restore(saved: tuple[Any, Any]) -> None:
"""Restore the original ``_op_create_function``."""
bpy_ops_module, original = saved
bpy_ops_module._op_create_function = original
# -------------------------------------------------------------------------
# Context manager
def __enter__(self) -> Self:
self._store_attrs = self.override_store()
self._store_ops = self.ops_blocked_store()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: object,
) -> None:
del exc_type, exc_val, exc_tb
self.ops_blocked_restore(self._store_ops)
self.override_restore(self._store_attrs)