Skip to content

The safety model ​

Every tool carries a safety class that determines its risk and required parameters. The class is metadata on the tool (meta.safety_class), and every class change is pinned by contract tests.

The classes ​

ClassRiskExtra paramsExamples
read_onlyNonenoneget_scene_tree, scripts_read, shader_validate
mutatingReversible/additive change (UndoRedo)dry_run: bool = Falsecreate_node, set_node_property, attach_script, set_resource_property
destructiveRemoves/overwrites existing data, or an irreversible disk/project writedry_run and confirm: bool = Truedelete_node, reload_scene, close_scene, remove_audio_bus, delete_resource_file, create_resource, set_setting, cross_scene_set_property, register_autoload/unregister_autoload, disconnect_signal
runtimeControls/side-effects game executionvariesplay_scene, run_and_capture, export_project, monitor_property, stop_recording, rescan_filesystem, evaluate_expression

Classification rule ​

The class follows the effect, not the verb (issue #638):

  • Reversible, additive editor edit (UndoRedo restores it) → mutating.
  • Removes/overwrites existing data, or writes disk/project state the undo stack does not cover → destructive. That includes resource/scene file writes (create_resource, cross_scene_set_property), ProjectSettings persistence (set_setting, register_autoload/unregister_autoload), and breaking a signal connection (disconnect_signal) — even where the editor happens to undo-track the step, the agent must opt in.
  • Side-effecting observation (starts/stops a recorder, starts sampling, triggers a reimport) → runtime, not read_only.

A contract test (tests/contract/test_safety_classification.py) pins the class of every tool the audit touched, so a misclassification fails CI.

Two knobs do the work:

  • dry_run=True runs preconditions and returns what would happen. For most mutations the preview carries the same persistence verdict the real run will stamp — the tool sends the read-only cmd_node_persistence probe, and the addon answers with the same rule the real handler applies (preview and real run always agree).
  • confirm=True is required for destructive tools. Without it: PRECONDITION_FAILED: This call discards unsaved changes. Set confirm=True to proceed. [required=confirm]. The server enforces this; the addon honors the flag defensively too.

What confirm does and does not protect against. confirm is an accident guard: the agent sets it itself, so it stops a tool being called by reflex without deliberation — it is not human oversight. Real human-in-the-loop oversight is the opt-in approval gate (GODOT_MCP_APPROVAL_WEBHOOK, or the elicitation guard with GODOT_MCP_APPROVAL_REQUIRE): mutating/destructive calls — and runtime tools flagged writes_disk/executes_code (export_project, evaluate_expression) — are routed to it. With a webhook configured, an unreachable webhook now fails closed (denies) by default (issue #638); set GODOT_MCP_APPROVAL_FAIL_OPEN=true to opt into fail-open.

Preconditions ​

Checked before any side effect, and failing with a structured error — never a Python exception:

PreconditionRequired tokenRecovered by
require_bridge_connectedbridge_connectedopen Godot + enable the addon
require_active_sceneactive_sceneopen_scene / create_scene
require_node_exists(path)— (RESOURCE_NOT_FOUND)get_scene_tree to find real paths
require_godot_binarygodot_binaryinstall Godot / set GODOT_BIN
toolset version gategodot_versionupgrade the editor
game-not-break gate (input sim)game_not_breakedgodot_debugger_continue_execution

Where safety lives, and why ​

All safety logic lives in mcp_server/safety.py — never in the addon.

Reasoning:

  • The addon is the only layer that touches Godot, but it is the least trustworthy layer: it runs inside the editor process, next to user code, and is version-coupled to Godot. Keeping it a dumb, auditable executor means a corrupted addon cannot bypass the confirm gate.
  • The server owns the classes/params once, and the same rules apply across stdio, HTTP, and Docker — a safety decision made in GDScript would only protect Godot clients.
  • The split makes the surface reviewable: safety.py (one file) is where an auditor reads the whole permission model.

The classes apply in every transport mode; transport-level auth (the GODOT_MCP_AUTH_TOKEN gate on non-loopback HTTP binds) is a second, separate layer that gates who can connect at all. Listener hardening (#636) is the third: the bridge refuses browser-Origin WebSocket handshakes and the HTTP transport's Host/Origin guard is on by default, so a visited web page (WebSocket same-origin exemption, DNS rebinding) can't reach the code-executing tools — while a local process still only needs the bridge/token, since loopback is single-user by design.

Batch tools don't bypass the gates ​

A macro/batch tool is a convenience, not a privilege escalation. run_commands resolves each sub-command back to the tool that backs it and enforces that tool's policy per entry (#634): a sub-command from a disabled toolset is refused, a destructive entry needs confirm=True, params go through the path preflight, and an unknown cmd_* is refused — a batch containing a delete is presented to the approval gate as destructive, not as the outer tool's mutating class.

Path containment ​

Every file-path parameter is a ResPath (a contained res:// path) — the server rejects a missing scheme, an absolute filesystem path, or a res://../ escape at the schema boundary, before the bridge round-trip (#637). The addon's own begins_with("res://") check is not trusted for this. run_commands sub-commands, which bypass the argument schema, are re-checked by the same preflight predicate. Node paths (Player/Gun) are not res:// paths and are exempt. export_project's output_path is confined to the project dir: a target outside it is refused unless confirm=True.

Honesty as safety ​

The most dangerous failure mode is a result that reads like success but isn't. The persistence system exists for exactly that:

  • Structural edits stamp persisted with a reason + actionable hint (see persistence).
  • Batch sets above 20 nodes report undoable: false.
  • run_commands reports aborted_at / skipped_count / an unsaved-scene hint.
  • Parse checks report rescan_pending when they raced an importer scan.
  • Timeout kills are distinguished from crashes.
  • A TIMEOUT on a mutating command carries may_have_applied: true + a hint to re-read state: the editor drains commands serially, so a timed-out mutation can still run after the agent gave up (#644).

These fields are contract-pinned; skills and evals treat them as ground truth. The rule of thumb in the codebase: a result never reads as success when the effect half-failed.

UndoRedo ​

Every create/rename/delete/set operation registers with EditorUndoRedoManager — the human's undo stack covers agent actions. The documented exceptions report themselves: batch sets above 20 nodes bypass the stack for performance (undoable: false in the result), and set_setting/file-only resource authors are not undo-tracked (their contracts say so).

194 tools · 29 toolsets · contract version 1