testsAndMisc/linux_configuration/scripts/blender-mcp-host
Krzysztof kuhy Rudnicki 8be1fac8f9
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
linux_configuration: track ~/.local/bin wrapper scripts in repo, symlink instead of generating
Moves 7 loose ~/.local/bin scripts into linux_configuration/scripts/ and
tracks the flutter/dart git-env wrapper; fix_flutter_git_env.sh and
unreal_mcp_repo.sh now symlink to the tracked files instead of writing
heredocs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhTkYV6ZeuAbQYvykFGFc

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 18:49:56 +02:00

175 lines
5.5 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# Start Blender as a headless MCP host for blender-mcp.
#
# The blender-mcp addon refuses to run under `blender --background`: its
# command queue is pumped by bpy.app.timers, which never tick in background
# mode, so requests would be accepted and then never execute. The addon's own
# error message names the fix -- give Blender a display. Xvfb provides one
# without putting a window on a real output (i3 focus stealing is an explicit
# hazard here), and a real EEVEE render works fine against it.
#
# Known limitation: get_viewport_screenshot returns success but writes an
# all-black PNG under Xvfb -- there is no GL viewport to capture. Use
# bpy.ops.render.render(write_still=True) via execute_code instead; that is
# verified working and is what the artgate 3D route needs anyway.
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
readonly ADDON_PATH="/home/kuhy/src/mcp-servers/servers/blender-mcp/addon.py"
readonly START_SCRIPT="/home/kuhy/.local/share/blender-mcp-host/start_addon.py"
readonly LOG_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/blender-mcp-host.log"
PORT="${BLENDER_PORT:-9876}"
usage() {
echo "Usage: $SCRIPT_NAME [start|stop|status|restart]"
echo " start Launch headless Blender hosting the MCP socket (default)"
echo " stop Terminate the hosted Blender instance"
echo " status Report whether the socket is being served"
echo " restart stop, then start"
echo
echo "Env: BLENDER_PORT (default 9876)"
exit 0
}
validate_requirements() {
local missing=0
for tool in blender xvfb-run; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "Error: required tool '$tool' not found in PATH" >&2
missing=1
fi
done
if [[ ! -f "$ADDON_PATH" ]]; then
echo "Error: addon not found at $ADDON_PATH" >&2
missing=1
fi
[[ $missing -eq 0 ]] || exit 1
}
# True when something is listening on the MCP port.
port_is_served() {
ss -ltn 2>/dev/null | grep -q ":${PORT}\b"
}
# PID of the Blender process holding the port, empty if none.
host_pid() {
host_pids | head -1
}
# Every process belonging to a host instance: the Blender that serves the
# socket plus the xvfb-run wrapper that owns its display. Stopping only the
# Blender pid leaves the wrapper behind, which then keeps a stale instance
# alive and makes the next `start` a silent no-op.
host_pids() {
pgrep -f "blender .*--python ${START_SCRIPT}" || true
pgrep -f "xvfb-run .*--python ${START_SCRIPT}" || true
}
do_start() {
if port_is_served; then
echo "Already serving on port ${PORT} (pid $(host_pid))"
return 0
fi
mkdir -p "$(dirname "$START_SCRIPT")" "$(dirname "$LOG_FILE")"
# The addon's register() auto-starts the server, so this script only needs
# to load it and then block -- Blender must stay alive to pump the timers.
cat > "$START_SCRIPT" <<'PY'
"""Load the blender-mcp addon; Blender's event loop then serves the socket."""
import importlib.util
import sys
_SPEC = importlib.util.spec_from_file_location(
"blendermcp_addon", "/home/kuhy/src/mcp-servers/servers/blender-mcp/addon.py")
_MOD = importlib.util.module_from_spec(_SPEC)
sys.modules["blendermcp_addon"] = _MOD
_SPEC.loader.exec_module(_MOD)
_MOD.register()
print("blender-mcp-host: addon registered, serving", flush=True)
PY
echo "Starting headless Blender on port ${PORT}..."
BLENDER_PORT="$PORT" nohup xvfb-run -a \
blender --factory-startup --python "$START_SCRIPT" \
>> "$LOG_FILE" 2>&1 &
disown
# Wait for the socket rather than sleeping a fixed guess.
local waited=0
while [[ $waited -lt 30 ]]; do
if port_is_served; then
echo "Serving on port ${PORT} (pid $(host_pid))"
echo "Log: $LOG_FILE"
return 0
fi
command sleep 1
waited=$((waited + 1))
done
echo "Error: port ${PORT} not served after ${waited}s; see $LOG_FILE" >&2
exit 1
}
do_stop() {
local pids=()
mapfile -t pids < <(host_pids)
if [[ ${#pids[@]} -eq 0 ]]; then
echo "Not running"
return 0
fi
kill "${pids[@]}" 2>/dev/null || true
# Confirm the port is actually released rather than trusting the signal;
# a half-dead instance would make the next start a no-op.
local waited=0 remaining=()
while [[ $waited -lt 10 ]]; do
mapfile -t remaining < <(host_pids)
if ! port_is_served && [[ ${#remaining[@]} -eq 0 ]]; then
echo "Stopped Blender host (${pids[*]})"
return 0
fi
command sleep 1
waited=$((waited + 1))
done
mapfile -t remaining < <(host_pids)
[[ ${#remaining[@]} -gt 0 ]] && kill -9 "${remaining[@]}" 2>/dev/null || true
command sleep 1
if port_is_served; then
echo "Error: port ${PORT} still served after SIGKILL" >&2
exit 1
fi
echo "Stopped Blender host (forced)"
}
do_status() {
if port_is_served; then
echo "serving port=${PORT} pid=$(host_pid)"
else
echo "stopped port=${PORT}"
exit 1
fi
}
main() {
validate_requirements
case "${1:-start}" in
start) do_start ;;
stop) do_stop ;;
status) do_status ;;
restart) do_stop; command sleep 1; do_start ;;
-h|--help|help) usage ;;
*) echo "Unknown command: $1" >&2; exit 1 ;;
esac
}
main "$@"