update addons (for 5.2)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user