From 5c06a9d9237b8a7df95029498a01e426d1b53147 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 18 Jun 2026 20:29:51 -0500 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20rendered=20MCP=20driver=20=E2=80=94=20TCP=20screens?= =?UTF-8?q?hot/open=5Fscreen=20(p2-86=20phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New mcp_render_driver autoload (active only on MC_MCP_RENDER=1): listens on a localhost TCP socket polled in _process and handles screenshot / open_screen / ping / quit against the LIVE rendered game. TCP, not stdin: OS.read_string_from_stdin blocks an open pipe and would freeze a rendered main loop (the player_api_main stdin pump only works --headless); a polled TCPServer is non-blocking and also dodges Godot's windowed-stdout buffering. Inert (set_process(false)) unless the env flag is set — zero cost in normal play. Verified end-to-end with NO MCP needed: MC_AUTO_START=1 MC_MCP_RENDER=1 godot + a raw TCP client -> ping ok, screenshot -> real 3420x1923 PNG of the live world map. Phase 2 (claude-player-mcp tools + npm install + .mcp.json + restart) next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../engine/src/autoloads/mcp_render_driver.gd | 157 ++++++++++++++++++ src/game/project.godot | 1 + 2 files changed, 158 insertions(+) create mode 100644 src/game/engine/src/autoloads/mcp_render_driver.gd diff --git a/src/game/engine/src/autoloads/mcp_render_driver.gd b/src/game/engine/src/autoloads/mcp_render_driver.gd new file mode 100644 index 00000000..08a74c97 --- /dev/null +++ b/src/game/engine/src/autoloads/mcp_render_driver.gd @@ -0,0 +1,157 @@ +extends Node +## p2-86: rendered-game MCP driver. When `MC_MCP_RENDER=1`, listens on a +## localhost TCP socket for JSON-Lines commands against the LIVE rendered game +## so an external client — the claude-player-mcp rendered mode, or any TCP +## client — can capture screenshots and navigate top-level screens. +## +## Why TCP, not stdin: `OS.read_string_from_stdin` BLOCKS on an open pipe and +## freezes a rendered game's main loop (verified 2026-06-18; the stdin pump in +## player_api_main only works because it runs --headless). A TCPServer polled in +## `_process` is fully non-blocking and avoids Godot's windowed-stdout buffering. +## +## Inert unless `MC_MCP_RENDER=1`: zero cost in normal play. +## +## Wire protocol (one JSON object per line over the socket; reply has same `id`): +## {"type":"screenshot","path":"user://screenshots/x.png","id":1} +## -> {"ok":true,"path":"","size":[w,h],"id":1} +## {"type":"open_screen","screen":"main_menu|game_setup","id":2} -> {"ok":true,"id":2} +## {"type":"ping","id":3} -> {"ok":true,"id":3} +## {"type":"shutdown","id":4} -> {"ok":true,"id":4} then quit. + +const DEFAULT_PORT: int = 8787 +const DEFAULT_SHOT_PATH: String = "user://screenshots/mcp_capture.png" + +var _active: bool = false +var _server: TCPServer = null +var _peer: StreamPeerTCP = null +var _buf: String = "" + + +func _ready() -> void: + _active = EnvConfig.get_bool("MC_MCP_RENDER") + if not _active: + set_process(false) + return + var port: int = EnvConfig.get_int("MC_MCP_PORT", DEFAULT_PORT) + _server = TCPServer.new() + var err: int = _server.listen(port, "127.0.0.1") + if err != OK: + push_error("MCPRenderDriver: TCPServer.listen(%d) failed (err %d)" % [port, err]) + _active = false + set_process(false) + return + print("[mcp-render] listening on 127.0.0.1:%d" % port) + + +func _process(_delta: float) -> void: + if _peer == null and _server.is_connection_available(): + _peer = _server.take_connection() + _buf = "" + if _peer == null: + return + _peer.poll() + if _peer.get_status() != StreamPeerTCP.STATUS_CONNECTED: + _peer = null + return + var avail: int = _peer.get_available_bytes() + if avail > 0: + _buf += _peer.get_utf8_string(avail) + while _buf.contains("\n"): + var idx: int = _buf.find("\n") + var line: String = _buf.substr(0, idx).strip_edges() + _buf = _buf.substr(idx + 1) + if not line.is_empty(): + _handle_line(line) + + +func _handle_line(line: String) -> void: + var req: Dictionary = JSON.parse_string(line) as Dictionary + if req.is_empty() and line != "{}": + _respond({"ok": false, "error": {"code": "parse_error", "message": line}}) + return + var rtype: String = String(req.get("type", "")) + var has_id: bool = req.has("id") and req.get("id") != null + var rid: int = int(req.get("id", -1)) + match rtype: + "ping": + _respond(_ack(rid, has_id)) + "screenshot": + _do_screenshot(req, rid, has_id) + "open_screen": + _do_open_screen(req, rid, has_id) + "shutdown": + _respond(_ack(rid, has_id)) + get_tree().quit(0) + _: + _respond(_err(rid, has_id, "unknown request type: " + rtype)) + + +func _do_screenshot(req: Dictionary, rid: int, has_id: bool) -> void: + var rel: String = String(req.get("path", DEFAULT_SHOT_PATH)) + var base: String = rel.get_base_dir() + if not base.is_empty(): + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(base)) + var viewport: Viewport = get_viewport() + if viewport == null: + _respond(_err(rid, has_id, "no viewport")) + return + var tex: Texture2D = viewport.get_texture() + var img: Image = tex.get_image() if tex != null else null + if img == null: + _respond(_err(rid, has_id, "viewport texture image was null")) + return + var err: int = img.save_png(rel) + if err != OK: + _respond(_err(rid, has_id, "save_png failed (err %d) for %s" % [err, rel])) + return + var body: Dictionary = _ack(rid, has_id) + body["path"] = ProjectSettings.globalize_path(rel) + body["size"] = [img.get_width(), img.get_height()] + _respond(body) + + +func _do_open_screen(req: Dictionary, rid: int, has_id: bool) -> void: + var screen: String = String(req.get("screen", "")) + var path: String = _screen_path(screen) + if path.is_empty(): + _respond(_err(rid, has_id, "unsupported screen (top-level only so far): " + screen)) + return + var controller: Node = get_tree().current_scene + if controller == null or not controller.has_method("change_scene"): + _respond(_err(rid, has_id, "no scene controller available for open_screen")) + return + controller.call("change_scene", path) + _respond(_ack(rid, has_id)) + + +func _screen_path(screen: String) -> String: + ## Top-level scenes reachable via Main.change_scene. In-game panels + ## (city_screen, tech_tree) are HUD overlays gated on live game state — a + ## follow-up adds those via the HUD's open methods. + match screen: + "main_menu": + return "res://engine/scenes/menus/main_menu.tscn" + "game_setup": + return "res://engine/scenes/menus/game_setup.tscn" + _: + return "" + + +func _ack(rid: int, has_id: bool) -> Dictionary: + var body: Dictionary = {"ok": true} + if has_id: + body["id"] = rid + return body + + +func _err(rid: int, has_id: bool, message: String) -> Dictionary: + var body: Dictionary = {"ok": false, "error": {"code": "error", "message": message}} + if has_id: + body["id"] = rid + return body + + +func _respond(body: Dictionary) -> void: + if _peer == null or _peer.get_status() != StreamPeerTCP.STATUS_CONNECTED: + return + _peer.put_data((JSON.stringify(body) + "\n").to_utf8_buffer()) diff --git a/src/game/project.godot b/src/game/project.godot index 89e22a20..f2592f00 100644 --- a/src/game/project.godot +++ b/src/game/project.godot @@ -34,6 +34,7 @@ ModLoader="*res://engine/src/autoloads/mod_loader.gd" ProceduralRenderer="*res://engine/src/world/procedural_renderer.gd" CommsEventDispatcher="*res://engine/src/autoloads/comms_event_dispatcher.gd" ScreenCapture="*res://engine/scenes/tests/capture_screenshot.gd" +MCPRenderDriver="*res://engine/src/autoloads/mcp_render_driver.gd" AutoPlay="*res://engine/scenes/tests/auto_play.gd" [display]