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

347 lines
15 KiB
GDScript

@tool
extends VBoxContainer
## MCP bottom-panel dock — signal-driven status with polled runtime label.
##
## Created and bound by plugin.gd. Server status is signal-driven
## (no polling delay); a lightweight timer polls the runtime label
## during playtests so it updates without requiring server events.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const NodejsCheck = Modules.NodejsCheck
const MCPJsonWriteFlow := preload("res://addons/godot_mcp_toolkit/ui/mcp_json_write_flow.gd")
const ToolkitDialogPresenter := preload("res://addons/godot_mcp_toolkit/ui/toolkit_dialog_presenter.gd")
const DockSectionCard := preload("res://addons/godot_mcp_toolkit/ui/dock/dock_section_card.gd")
const DockStatusPanel := preload("res://addons/godot_mcp_toolkit/ui/dock/status/dock_status_panel.gd")
const DockLimitsSection := preload("res://addons/godot_mcp_toolkit/ui/dock/limits/dock_limits_section.gd")
const DockUnfocusedControl := preload("res://addons/godot_mcp_toolkit/ui/dock/limits/dock_unfocused_control.gd")
const DockAuditSection := preload("res://addons/godot_mcp_toolkit/ui/dock/security/dock_audit_section.gd")
const DockMcpJsonPanel := preload("res://addons/godot_mcp_toolkit/ui/dock/mcp/dock_mcp_json_panel.gd")
# Toast severity constants (match EditorToaster.Severity).
const _TOAST_INFO := 0
const _TOAST_WARNING := 1
const _TOAST_ERROR := 2
var _server: Node = null
var _audit_path: String = ""
# Server-status section — listening/peer/runtime/LSP/activity labels (own sub-panel).
var _status_panel: DockStatusPanel = null
# .mcp.json health surface — shared missing/malformed/read-only warning panel +
# the tri-mode footer button + read-only cache + write/open/fix flow (own sub-panel).
var _mcp_json_panel: DockMcpJsonPanel = null
# Unfocused-responsive mode — opt-in toggle + 3-state indicator (own sub-panel).
var _unfocused_control: DockUnfocusedControl = null
# Audit Log section — settings + view/clear + the lazy log viewer (own sub-panel).
var _audit_section: DockAuditSection = null
# Node.js warning.
var _nodejs_status_warning: Label = null
# Injected cross-cutting collaborators (composer-owned): the shared .mcp.json
# write flow and the editor-global dialog presenter. The dock CONSUMES them —
# footer buttons + the .mcp.json panel — exactly like the Tools menu and the
# onboarding wizard do; it never owns these behaviors.
var _write_flow: MCPJsonWriteFlow = null
var _dialog_presenter: ToolkitDialogPresenter = null
# Lightweight timer for runtime-status polling during playtests.
var _runtime_timer: Timer = null
func bind(
server: Node, audit_path: String,
write_flow: MCPJsonWriteFlow, dialog_presenter: ToolkitDialogPresenter) -> void:
_server = server
_audit_path = audit_path
_write_flow = write_flow
_dialog_presenter = dialog_presenter
_server.client_connected.connect(_on_client_connected)
_server.client_disconnected.connect(_on_client_disconnected)
_server.command_received.connect(_on_command_received)
# LSP verdict arrives via editor.set_lsp_status (a command, not a dock signal);
# refresh exactly when the server sets it, so the label is never stale.
_server.lsp_status_changed.connect(_on_lsp_status_changed)
# Listen state (fresh bind, pinned/band conflict, port-config error) — repaint
# the instant the server's state changes, in addition to the initial pull below.
_server.port_status_changed.connect(_on_port_status_changed)
_refresh_status()
func _ready() -> void:
_build_ui()
# bind() runs before _ready (node not yet in tree), so its refresh
# calls exit early on null widgets. Re-run now that UI exists.
_refresh_status()
# One 1s timer, fanned out in _on_runtime_timer_timeout to the runtime label
# (playtest port discovery / playtest end) and the .mcp.json panel's file-validity
# refresh; read-only stays server-synced (see _on_client_connected), never polled.
_runtime_timer = Timer.new()
_runtime_timer.wait_time = 1.0
_runtime_timer.timeout.connect(_on_runtime_timer_timeout)
add_child(_runtime_timer)
_runtime_timer.start()
# ---------------------------------------------------------------------------
# UI construction (programmatic — all dynamic content)
# ---------------------------------------------------------------------------
func _build_ui() -> void:
var scale := EditorInterface.get_editor_scale()
add_theme_constant_override("separation", int(4 * scale))
# == Status section (compact, pinned at top) ==============================
var status_section := DockSectionCard.make_section("Server Status")
status_section.size_flags_vertical = 0 # fixed height
add_child(status_section)
var sc: VBoxContainer = status_section.get_meta("content")
# Server-status labels (listening/peer/runtime/LSP/activity) — own sub-panel.
# The dock routes server signals to it for label updates; it reads the server.
_status_panel = DockStatusPanel.new(_server)
sc.add_child(_status_panel)
# .mcp.json health panel — the shared missing/malformed/read-only warning sits
# right after the server status row (prominent); its tri-mode button lives in
# the footer (placement = orchestrator) but is driven by this panel (meaning).
# Create the button now so the panel can bind it; the footer positions it below.
# The warning panel is hosted inside the status panel (between the status row and
# the runtime label) — placement is the status panel's, behavior stays here.
var mcp_json_btn := Button.new()
mcp_json_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_mcp_json_panel = DockMcpJsonPanel.new(mcp_json_btn, Callable(self, "_toast"), _write_flow)
_status_panel.insert_warning_panel(_mcp_json_panel)
# Node.js availability — shared detection.
var node_check := NodejsCheck.check()
var nodejs_msg := ""
if not node_check["found"]:
var _path_hint := ""
if OS.get_name() == "Windows":
_path_hint = "\nIf Node.js is installed, ensure it is on your system PATH."
elif OS.get_name() == "macOS":
# A version-manager Node may not be on PATH for an app launched from
# Finder/Dock; launching from a terminal inherits your shell PATH.
_path_hint = ("\nIf Node.js is installed, ensure it is on your PATH. A "
+ "version-manager Node (nvm/fnm/Homebrew) may need you to launch the "
+ "editor and MCP client from a terminal, or install Node from "
+ "https://nodejs.org so it lands on the default PATH.")
nodejs_msg = ("Node.js not found — the MCP server bridge requires "
+ "Node.js 22+. Download it from https://nodejs.org" + _path_hint)
elif not node_check["meets_minimum"]:
nodejs_msg = ("Node.js %s found but 22+ is required. "
+ "Update from https://nodejs.org") % str(node_check["version"])
_nodejs_status_warning = Label.new()
_nodejs_status_warning.text = nodejs_msg
_nodejs_status_warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_nodejs_status_warning.add_theme_color_override("font_color", Color(1.0, 0.6, 0.3))
_nodejs_status_warning.add_theme_font_size_override("font_size", 11)
_nodejs_status_warning.visible = nodejs_msg != ""
sc.add_child(_nodejs_status_warning)
# Unfocused-responsive mode — opt-in toggle + 3-state indicator sub-panel.
# (Lives in Editor Settings, not Project Settings — a per-user, machine-wide
# preference that must never land in project.godot / VCS.) The panel
# owns the setting I/O + indicator; the dock routes refreshes to it. Applying
# the setting (conflict-aware/crash-safe restore) is server-side; the panel
# calls the bound server directly, so no signal back to the dock is needed.
_unfocused_control = DockUnfocusedControl.new(_server)
sc.add_child(_unfocused_control)
# == Collapsible sections (titles always visible) =========================
var sections_vbox := VBoxContainer.new()
sections_vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL
sections_vbox.add_theme_constant_override("separation", int(2 * scale))
add_child(sections_vbox)
# -- Audit Log section (collapsed by default) -----------------------------
# The panel owns the audit settings + view/clear buttons + the lazy log
# viewer; the dock injects its toast so the panel needs no toaster dependency.
var ac := DockSectionCard.make_collapsible(sections_vbox, "Audit Log", false)
_audit_section = DockAuditSection.new(_audit_path, Callable(self, "_toast"))
ac.add_child(_audit_section)
# -- Security & Response Limits section (collapsed by default) ------------
var lc := DockSectionCard.make_collapsible(sections_vbox, "Security & Response Limits", false)
var limits_section := DockLimitsSection.new()
limits_section.regenerate_token_requested.connect(_on_regen_token)
lc.add_child(limits_section)
# == Footer (pinned at bottom) ============================================
var footer := PanelContainer.new()
footer.add_theme_stylebox_override("panel", DockSectionCard.make_section_style())
footer.size_flags_vertical = Control.SIZE_SHRINK_END
add_child(footer)
var footer_row := HBoxContainer.new()
footer.add_child(footer_row)
var skills_btn := Button.new()
skills_btn.text = "Companion Skills"
skills_btn.tooltip_text = "Open Companion Skills folder"
skills_btn.pressed.connect(_open_companion_skills)
skills_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
footer_row.add_child(skills_btn)
var extensions_btn := Button.new()
extensions_btn.text = "Extensions"
extensions_btn.tooltip_text = "Browse MCP Toolkit extensions"
extensions_btn.pressed.connect(_on_extensions_pressed)
extensions_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
footer_row.add_child(extensions_btn)
# Built above (status section) so _mcp_json_panel could bind it; the footer owns
# only its placement. Label/tooltip/colour/action are driven by the panel.
footer_row.add_child(mcp_json_btn)
var info_btn := Button.new()
info_btn.text = "Info / Help"
info_btn.pressed.connect(_on_info_pressed)
info_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
footer_row.add_child(info_btn)
# ---------------------------------------------------------------------------
# Signal handlers — server events (no polling)
# ---------------------------------------------------------------------------
func _on_client_connected(peer_count: int) -> void:
# Status labels live in the status panel; the dock keeps the toast + the
# fan-out to the other sub-panels (it knows which panel reacts to which event).
if _status_panel != null:
_status_panel.set_peer_count(peer_count)
_status_panel.set_activity("Last activity: client connected")
_toast("MCP client connected (%d peer%s)" % [
peer_count, "" if peer_count == 1 else "s"])
# A (re)connecting MCP server has just read GODOT_MCP_READ_ONLY from its env, so
# this is exactly when read-only takes effect — sync the badge to match it.
# Caveat: an already-connected server keeps its launch-time read-only and does
# NOT switch on a later .mcp.json edit (the server reads the env once at launch).
# The badge reflects the latest connect's .mcp.json; older still-running servers
# (multi-client) won't switch until they themselves reconnect.
if _mcp_json_panel != null:
_mcp_json_panel.sync_read_only_state()
if _unfocused_control != null:
_unfocused_control.refresh()
func _on_client_disconnected(peer_count: int) -> void:
if _status_panel != null:
_status_panel.set_peer_count(peer_count)
_status_panel.set_activity("Last activity: client disconnected")
if peer_count == 0:
_toast("MCP client disconnected")
if _unfocused_control != null:
_unfocused_control.refresh()
func _on_command_received(method: String) -> void:
if _status_panel != null:
_status_panel.set_activity("Last activity: %s" % method)
# LSP verdict signal — route to the status panel's LSP label (server-driven,
# never stale). Separate handler so bind() can connect before _build_ui exists.
func _on_lsp_status_changed() -> void:
if _status_panel != null:
_status_panel.refresh_lsp()
# Listen-state signal (fresh bind / conflict raised or cleared / config error) —
# repaint the status panel, which derives the status-row style and the
# not-listening warning from the server in one pass. Separate handler so bind()
# can connect before _build_ui exists.
func _on_port_status_changed() -> void:
if _status_panel != null:
_status_panel.refresh()
# 1s poll — one timer fans out to the runtime label (playtest port discovery /
# playtest end) AND the .mcp.json panel's file-validity refresh (file presence /
# malformed → button mode + warning). Both are live FACTs safe to poll; read-only
# stays server-synced (see _on_client_connected), never on this timer.
func _on_runtime_timer_timeout() -> void:
if _status_panel != null:
_status_panel.refresh_runtime()
if _mcp_json_panel != null:
_mcp_json_panel.refresh()
# ---------------------------------------------------------------------------
# Status refresh — thin fan-out (the orchestrator decides what refreshes when)
# ---------------------------------------------------------------------------
func _refresh_status() -> void:
if _status_panel != null:
_status_panel.refresh()
if _mcp_json_panel != null:
_mcp_json_panel.sync_read_only_state()
if _unfocused_control != null:
_unfocused_control.refresh()
# ---------------------------------------------------------------------------
# Audit log popup
# ---------------------------------------------------------------------------
# Thin delegator — the audit dialog now lives in _audit_section. Kept public so
# the Tools menu (tool_menu.gd) can still open the log via the dock.
func show_audit_dialog() -> void:
if _audit_section != null:
_audit_section.show_dialog()
# ---------------------------------------------------------------------------
# Token regeneration
# ---------------------------------------------------------------------------
func _on_regen_token() -> void:
if _server != null and _server.has_method("regenerate_token"):
_server.regenerate_token()
_toast("MCP token rotated")
# ---------------------------------------------------------------------------
# Companion Skills
# ---------------------------------------------------------------------------
func _open_companion_skills() -> void:
var skills_dir := "res://addons/godot_mcp_toolkit/CompanionSkills"
var global_path := ProjectSettings.globalize_path(skills_dir)
OS.shell_open(global_path)
# ---------------------------------------------------------------------------
# Footer dialogs — thin consumers of the injected dialog presenter
# ---------------------------------------------------------------------------
func _on_extensions_pressed() -> void:
if _dialog_presenter != null:
_dialog_presenter.show_extension_catalog()
func _on_info_pressed() -> void:
if _dialog_presenter != null:
_dialog_presenter.show_info(_server)
# ---------------------------------------------------------------------------
# Toast helper
# ---------------------------------------------------------------------------
func _toast(msg: String, severity: int = _TOAST_INFO, tooltip_text: String = "") -> void:
if severity >= _TOAST_WARNING:
push_warning("[MCP] %s" % msg)
else:
print("[MCP] %s" % msg)
# EditorToaster available in Godot 4.4+ (dynamic dispatch for compat).
var toaster = Modules.EditorAccess.get_toaster()
if toaster != null:
toaster.push_toast(msg, severity, tooltip_text)