2026-10-09 12:49:07 +02:00

175 lines
8.0 KiB
GDScript

@tool
extends EditorPlugin
## EditorPlugin entry point — thin orchestrator that delegates to PluginComposer.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const FileGuard = Modules.FileGuard
const SettingsRegistration := preload("res://addons/godot_mcp_toolkit/core/settings_registration.gd")
const OnboardingWizard := preload("res://addons/godot_mcp_toolkit/ui/onboarding_wizard.gd")
const PluginComposer := preload("res://addons/godot_mcp_toolkit/core/plugin_composer.gd")
const DockHost := preload("res://addons/godot_mcp_toolkit/core/dock_host.gd")
const ToolMenu := preload("res://addons/godot_mcp_toolkit/core/tool_menu.gd")
const DisableCleanupCoordinator := preload("res://addons/godot_mcp_toolkit/core/disable_cleanup_coordinator.gd")
const MCPJsonEnablePrompt := preload("res://addons/godot_mcp_toolkit/ui/mcp_json_enable_prompt.gd")
const AutoloadRegistration := preload("res://addons/godot_mcp_toolkit/core/autoload_registration.gd")
# The composed collaborator graph (server, dock, export plugin, watchers, debug
# bridge, user-path monitor, playtest watcher). PluginComposer.compose() builds
# it; the orchestrator drives it and calls _handle.dispose() on exit.
var _handle = null
var _tool_menu: ToolMenu = null
var _wizard: OnboardingWizard = null
func _enter_tree() -> void:
# Brand line, not a log line: printed first so the version is stamped even if startup fails.
print("Godot MCP Toolkit v%s — by NPGameDev · npgamedev.com" % get_plugin_version())
# Lifecycle phase sequence. The "why this order" narrative lives here; the
# composer owns the internal construction order of the graph it builds.
Modules.EditorAccess.set_plugin(self)
SettingsRegistration.register_all()
# Re-assert "plugin enabled ⟹ runtime autoload registered" before the graph is
# wired. _enable_plugin() only fires on the disabled→enabled toggle, so a project
# opened already-enabled with the autoload missing (out-of-band project.godot edit,
# VCS-propagated enabled flag, template) would silently run Mode A only. Placed
# early — pre-compose() — so the heal's settings_changed emit fires with zero
# listeners (the extension watcher and the other settings_changed consumers are
# wired inside compose() below).
AutoloadRegistration.ensure_registered()
# Construct + wire the whole collaborator graph (registry, server, debug
# bridge, command registrar, extensions, export plugin, log buffer, user-path
# monitor, registry registration, playtest watcher, write flow + dialog
# presenter, dock) and register in the system-wide registry.
_handle = PluginComposer.compose(self, _on_user_path_changed)
# Tools > MCP Toolkit submenu + command palette (needs the server, dock, and
# shared UI collaborators the composer just built).
_tool_menu = ToolMenu.new(
self, _handle.server(), _handle.dock(),
_handle.write_flow(), _handle.dialog_presenter())
_tool_menu.install()
# -- Per-user EditorSettings --
_register_editor_settings()
# Warn about untested future Godot versions (but don't block).
var _engine_ver := Modules.VersionUtils.get_engine_version_pair()
if not Modules.VersionUtils.is_at_most(_engine_ver, Modules.VersionUtils.GODOT_TESTED_MAX_VERSION):
push_warning(("[MCP] Godot %s detected - latest tested version is %s. "
+ "The plugin will run normally but some features may behave unexpectedly. "
+ "Please report issues at https://github.com/NPGameDev/godot-mcp-toolkit/issues")
% [_engine_ver, Modules.VersionUtils.GODOT_TESTED_MAX_VERSION])
_wizard = OnboardingWizard.new(
self, _handle.server(), _handle.write_flow(), _handle.dialog_presenter())
call_deferred("_check_onboarding")
func _check_onboarding() -> void:
_wizard.check_and_show()
# Reveal the toolkit dock (select its tab + expand the bottom panel). The
# onboarding wizard's final step calls this; delegating through DockHost keeps the
# editor↔version dock seam in one adapter instead of leaking make_visible here.
func reveal_dock() -> void:
if _handle != null:
DockHost.reveal(self, _handle.dock(), _handle.dock_host())
func _process(_delta: float) -> void:
if _handle != null:
_handle.poll_playtest()
func _on_user_path_changed() -> void:
# Static consumers that can't connect to signals themselves.
# Instance consumers (feature_settings, server) connect directly
# via bind_user_path_monitor().
OnboardingWizard.migrate_flag_after_rename()
Modules.LogBuffer.reset_tail_path()
func _exit_tree() -> void:
# Teardown symmetry — reverse order of _enter_tree's phases.
# Onboarding wizard (if still open) — teardown() frees the dialog immediately so
# it can't outlive ObjectDB's exit-time leak check.
if _wizard != null:
_wizard.teardown()
_wizard = null
# Menus + command palette.
if _tool_menu != null:
_tool_menu.uninstall()
_tool_menu = null
# Composed graph (dock, monitor, watchers, debug bridge, export plugin,
# server + registry) — disposed in reverse construction order.
if _handle != null:
_handle.dispose()
_handle = null
# Plugin reference — clear last (teardown symmetry with _enter_tree).
Modules.EditorAccess.clear_plugin()
func _enable_plugin() -> void:
# First-enable registration shares the on-load self-heal path so the two can never
# diverge — an undo-free set_setting + save that persists to project.godot, which
# is what the game reads at F5 (rationale lives in AutoloadRegistration).
AutoloadRegistration.ensure_registered()
# Offer to (re)create a missing .mcp.json — on the enable toggle only, never
# on project open. The editor adds the plugin to the tree before calling
# _enable_plugin, so the composed graph (and its write flow) already exists.
if _handle != null:
MCPJsonEnablePrompt.show_if_needed(_handle.write_flow())
func _disable_plugin() -> void:
AutoloadRegistration.unregister(self)
# Scrub the project-local mcp_toolkit/* ProjectSettings unconditionally —
# they belong to project.godot and are meaningless once the plugin is gone.
# (EditorSettings are machine-wide and need a confirm — see below.)
SettingsRegistration.unregister_all()
# Warn about an orphaned .mcp.json, then (chained off its resolution) about
# the machine-wide EditorSettings keys. The editor frees THIS plugin the
# instant this method returns (and _exit_tree runs) — before the user answers
# the prompts — so the sequence must NOT be driven by plugin-bound callbacks
# (they would fire into a freed object and silently no-op). Hand it to a
# detached coordinator that outlives the plugin and owns the dialog flow.
DisableCleanupCoordinator.new().start()
# -- EditorSettings registration (per-user, not committed to VCS) -------------
func _register_editor_settings() -> void:
var es := EditorInterface.get_editor_settings()
# [type, default, hint_string]. These live in EDITOR Settings (per-user,
# machine-wide), NOT Project Settings: the unfocused-responsive keys control a
# machine-global editor effect and are a personal battery/CPU preference, so
# they must never be committed to project.godot / VCS.
var settings := {
"mcp_toolkit/personal/dock_default_visible": [TYPE_BOOL, true, ""],
"mcp_toolkit/performance/keep_editor_responsive_unfocused": [TYPE_BOOL, true,
"Keep the editor responsive (raise its unfocused frame rate) while an MCP client is connected, so commands stay snappy when the editor is unfocused. Off uses Godot's default low-power unfocused throttle. Raises background CPU. A toggle is also in the MCP Toolkit dock."],
"mcp_toolkit/performance/unfocused_responsive_sleep_usec": [TYPE_INT, 16666,
"Unfocused process sleep in µs applied while a client is connected (lower = higher fps = snappier but more CPU). 16666 ≈ 60 fps (default); 33333 ≈ 30 fps (power-saver). Not clamped."],
}
for key in settings:
if not es.has_setting(key):
es.set_setting(key, settings[key][1])
es.set_initial_value(key, settings[key][1], false)
var info := {"name": key, "type": settings[key][0]}
if settings[key][2] != "":
info["hint"] = PROPERTY_HINT_NONE
info["hint_string"] = settings[key][2]
es.add_property_info(info)