feat(@projects/@magic-civilization): rendered MCP driver — TCP screenshot/open_screen (p2-86 phase 1)

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-18 20:29:51 -05:00
parent 12a4cf4269
commit 5c06a9d923
2 changed files with 158 additions and 0 deletions

View file

@ -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":"<abs>","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())

View file

@ -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]