feat(@projects/@magic-civilization): claude-player-mcp rendered tools — magic_civ_screenshot / open_screen (p2-86 phase 2)

- render_client.ts: TCP client that spawns the rendered game via
  render-driver-server.sh, then sends screenshot / open_screen / ping correlated
  by id (distinct from the headless stdin HarnessClient).
- scripts/render-driver-server.sh: boots the REAL game (MC_AUTO_START +
  MC_MCP_RENDER, gl_compatibility, not --headless) so the driver captures real
  frames; client connects on MC_MCP_PORT.
- index.ts: magic_civ_screenshot + magic_civ_open_screen tools, render-client
  holder + cleanup.

Verified end-to-end through the BUILT TypeScript (no Claude restart needed):
RenderClient -> game -> TCP ping {ok}, screenshot -> a real 3420x1923 PNG of the
live world map. dist/ is gitignored (built locally per .mcp.json); the tools
surface in a Claude session after the next restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-18 21:09:10 -05:00
parent 6721f3f2f3
commit 70537bc0d1
3 changed files with 351 additions and 1 deletions

54
scripts/render-driver-server.sh Executable file
View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Rendered-game launch for the MCP render driver (p2-86).
#
# Unlike player-api-server.sh (a --headless state bench), this boots the REAL
# game WITH rendering so the driver's `screenshot` command captures real frames.
# MC_AUTO_START=1 skips the menu into an interactive seeded game;
# MC_MCP_RENDER=1 activates mcp_render_driver.gd, which listens on MC_MCP_PORT
# (default 8787). The client (RenderClient) connects over TCP — stdout is not
# used for the protocol (Godot block-buffers windowed stdout).
#
# Env:
# MC_MCP_PORT — driver TCP port (default 8787)
# MC_AUTOSTART_SEED — game seed (default 42)
# MC_AUTOSTART_PLAYERS / MC_AUTOSTART_MAP_SIZE — optional overrides
#
# Linux note: rendered mode needs a display server (weston/xvfb on a GPU node,
# per MAGIC_CIV_CLOUD_HANDOFF.md). macOS renders directly via the windowing
# system. Headless state play is unchanged (player-api-server.sh).
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
: "${MC_MCP_PORT:=8787}"
: "${MC_AUTOSTART_SEED:=42}"
export MC_AUTO_START=1 MC_MCP_RENDER=1 MC_MCP_PORT MC_AUTOSTART_SEED \
MC_AUTOSTART_PLAYERS MC_AUTOSTART_MAP_SIZE
case "$(uname -s)" in
Darwin)
GODOT_BIN="${GODOT_BIN:-godot}"
if ! command -v "$GODOT_BIN" >/dev/null 2>&1; then
echo "ERROR: no godot binary on PATH (set GODOT_BIN or 'brew install godot')." >&2
exit 1
fi
exec "$GODOT_BIN" \
--path "$PROJECT_DIR/src/game" \
--rendering-method gl_compatibility
;;
*)
# Linux: flatpak Godot. Rendered mode requires a display (weston/xvfb);
# the caller is responsible for providing WAYLAND_DISPLAY / DISPLAY.
exec flatpak run --user \
--env=MC_AUTO_START=1 \
--env=MC_MCP_RENDER=1 \
--env=MC_MCP_PORT="$MC_MCP_PORT" \
--env=MC_AUTOSTART_SEED="$MC_AUTOSTART_SEED" \
org.godotengine.Godot \
--path "$PROJECT_DIR/src/game" \
--rendering-method gl_compatibility
;;
esac

View file

@ -21,6 +21,7 @@ import { dirname, resolve as resolvePath } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { HarnessClient } from "./harness.js"; import { HarnessClient } from "./harness.js";
import { RenderClient } from "./render_client.js";
import type { PlayerAction, Response } from "./types.js"; import type { PlayerAction, Response } from "./types.js";
const HERE = dirname(fileURLToPath(import.meta.url)); const HERE = dirname(fileURLToPath(import.meta.url));
@ -30,6 +31,11 @@ const DEFAULT_SERVER_SCRIPT = resolvePath(
"scripts", "scripts",
"player-api-server.sh", "player-api-server.sh",
); );
const DEFAULT_RENDER_SCRIPT = resolvePath(
PROJECT_ROOT,
"scripts",
"render-driver-server.sh",
);
function emitStderr(line: string): void { function emitStderr(line: string): void {
process.stderr.write(line + "\n"); process.stderr.write(line + "\n");
@ -39,6 +45,15 @@ function serverScript(): string {
return process.env["CP_SERVER"] ?? DEFAULT_SERVER_SCRIPT; return process.env["CP_SERVER"] ?? DEFAULT_SERVER_SCRIPT;
} }
function renderScript(): string {
return process.env["CP_RENDER_SERVER"] ?? DEFAULT_RENDER_SCRIPT;
}
function renderPort(): number {
const p = Number.parseInt(process.env["MC_MCP_PORT"] ?? "8787", 10);
return Number.isNaN(p) ? 8787 : p;
}
function harnessEnv(): Record<string, string> { function harnessEnv(): Record<string, string> {
const passthrough = [ const passthrough = [
"CP_SEED", "CP_SEED",
@ -105,6 +120,43 @@ const END_TURN_TOOL = {
}, },
}; };
const SCREENSHOT_TOOL = {
name: "magic_civ_screenshot",
description:
"Capture a PNG screenshot of the LIVE rendered Magic Civilization game. The " +
"rendered driver auto-starts an interactive seeded game on first use. Returns " +
"the absolute saved path and pixel [width,height]. Optional `path` (user://, " +
"res://, or absolute) overrides the default save location.",
inputSchema: {
type: "object" as const,
properties: {
path: {
type: "string",
description: "Optional save path; defaults to user://screenshots/mcp_capture.png.",
},
},
required: [],
},
};
const OPEN_SCREEN_TOOL = {
name: "magic_civ_open_screen",
description:
"Navigate the rendered game to a top-level screen before screenshotting. " +
"Supported screens: 'main_menu', 'game_setup'. (In-game panels such as the " +
"city screen are a follow-up.)",
inputSchema: {
type: "object" as const,
properties: {
screen: {
type: "string",
description: "Target screen id: 'main_menu' | 'game_setup'.",
},
},
required: ["screen"],
},
};
interface HarnessHolder { interface HarnessHolder {
client: HarnessClient | null; client: HarnessClient | null;
} }
@ -140,6 +192,28 @@ function getOrCreateClient(): HarnessClient {
return client; return client;
} }
interface RenderHolder {
client: RenderClient | null;
}
const renderHolder: RenderHolder = { client: null };
function getOrCreateRenderClient(): RenderClient {
if (renderHolder.client !== null && !renderHolder.client.isClosed) {
return renderHolder.client;
}
if (renderHolder.client !== null) {
emitStderr("previous render client closed; respawning");
} else {
emitStderr(`spawning render driver: ${renderScript()} (port ${renderPort()})`);
}
renderHolder.client = new RenderClient({
serverScript: renderScript(),
port: renderPort(),
});
return renderHolder.client;
}
function responsePayload(r: Response): string { function responsePayload(r: Response): string {
if (r.ok) { if (r.ok) {
return JSON.stringify( return JSON.stringify(
@ -176,6 +250,29 @@ async function handleEndTurn(): Promise<string> {
return responsePayload(r); return responsePayload(r);
} }
async function handleScreenshot(args: unknown): Promise<string> {
const path =
typeof args === "object" && args !== null
? (args as { path?: unknown }).path
: undefined;
const client = getOrCreateRenderClient();
const r = await client.screenshot(typeof path === "string" ? path : undefined);
return JSON.stringify(r, null, 2);
}
async function handleOpenScreen(args: unknown): Promise<string> {
if (typeof args !== "object" || args === null) {
throw new Error("magic_civ_open_screen requires {screen: string}");
}
const screen = (args as { screen?: unknown }).screen;
if (typeof screen !== "string") {
throw new Error("magic_civ_open_screen requires {screen: string}");
}
const client = getOrCreateRenderClient();
const r = await client.openScreen(screen);
return JSON.stringify(r, null, 2);
}
async function main(): Promise<void> { async function main(): Promise<void> {
const server = new Server( const server = new Server(
{ name: "claude-player-mcp", version: "0.1.0" }, { name: "claude-player-mcp", version: "0.1.0" },
@ -183,7 +280,9 @@ async function main(): Promise<void> {
); );
server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: [VIEW_TOOL, ACT_TOOL, END_TURN_TOOL] }; return {
tools: [VIEW_TOOL, ACT_TOOL, END_TURN_TOOL, SCREENSHOT_TOOL, OPEN_SCREEN_TOOL],
};
}); });
server.setRequestHandler(CallToolRequestSchema, async (request) => { server.setRequestHandler(CallToolRequestSchema, async (request) => {
@ -200,6 +299,12 @@ async function main(): Promise<void> {
case "magic_civ_end_turn": case "magic_civ_end_turn":
text = await handleEndTurn(); text = await handleEndTurn();
break; break;
case "magic_civ_screenshot":
text = await handleScreenshot(args);
break;
case "magic_civ_open_screen":
text = await handleOpenScreen(args);
break;
default: default:
throw new Error(`unknown tool: ${name}`); throw new Error(`unknown tool: ${name}`);
} }
@ -225,6 +330,10 @@ async function main(): Promise<void> {
await holder.client.shutdown(); await holder.client.shutdown();
holder.client = null; holder.client = null;
} }
if (renderHolder.client !== null) {
renderHolder.client.close();
renderHolder.client = null;
}
}; };
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
cleanup().finally(() => process.exit(0)); cleanup().finally(() => process.exit(0));

View file

@ -0,0 +1,187 @@
// TCP client for the rendered-game MCP driver (`mcp_render_driver.gd`, p2-86).
//
// Spawns the REAL rendered game via `scripts/render-driver-server.sh` (which
// boots with MC_AUTO_START=1 MC_MCP_RENDER=1), waits for the driver's localhost
// TCP port to open, then sends JSON-Lines commands correlated by `id`.
//
// Distinct from HarnessClient: that drives the headless state bench over
// stdin/stdout (view/act/end_turn); this drives the rendered game over TCP
// (screenshot/open_screen) because OS.read_string_from_stdin blocks a rendered
// main loop and Godot block-buffers windowed stdout.
import { spawn, type ChildProcess } from "node:child_process";
import { connect, type Socket } from "node:net";
export interface RenderClientOptions {
/** Absolute path to render-driver-server.sh. */
serverScript: string;
/** Driver TCP port (must match MC_MCP_PORT passed to the game). */
port: number;
host?: string;
env?: Record<string, string>;
/** Max wait for the game to boot + the driver port to open. */
startTimeoutMs?: number;
/** Per-request timeout. Screenshots include a save, so keep this generous. */
requestTimeoutMs?: number;
}
interface Pending {
resolve: (v: Record<string, unknown>) => void;
reject: (e: Error) => void;
timer: NodeJS.Timeout;
}
export class RenderClient {
private readonly opts: Required<RenderClientOptions>;
private child: ChildProcess | null = null;
private sock: Socket | null = null;
private buf = "";
private readonly pending = new Map<number, Pending>();
private nextId = 1;
private readyPromise: Promise<void> | null = null;
private closed = false;
constructor(opts: RenderClientOptions) {
this.opts = {
host: "127.0.0.1",
env: {},
startTimeoutMs: 45_000,
requestTimeoutMs: 30_000,
...opts,
};
}
get isClosed(): boolean {
return this.closed;
}
private ensureReady(): Promise<void> {
if (this.readyPromise === null) {
this.readyPromise = this.start();
}
return this.readyPromise;
}
private start(): Promise<void> {
this.child = spawn(this.opts.serverScript, [], {
env: {
...process.env,
...this.opts.env,
MC_MCP_PORT: String(this.opts.port),
},
// Ignore the game's stdin/stdout (we talk TCP); surface its stderr.
stdio: ["ignore", "ignore", "inherit"],
});
this.child.on("exit", () => {
this.closed = true;
this.failAll(new Error("rendered game process exited"));
});
return this.connectWithRetry();
}
private connectWithRetry(): Promise<void> {
const deadline = Date.now() + this.opts.startTimeoutMs;
return new Promise<void>((resolve, reject) => {
const attempt = (): void => {
const s = connect({ host: this.opts.host, port: this.opts.port });
s.once("connect", () => {
this.sock = s;
s.setEncoding("utf8");
s.on("data", (chunk: string) => this.onData(chunk));
s.on("close", () => {
this.sock = null;
});
resolve();
});
s.once("error", () => {
s.destroy();
if (Date.now() > deadline) {
reject(new Error("render driver TCP port never opened"));
return;
}
setTimeout(attempt, 500);
});
};
attempt();
});
}
private onData(chunk: string): void {
this.buf += chunk;
let idx = this.buf.indexOf("\n");
while (idx >= 0) {
const line = this.buf.slice(0, idx).trim();
this.buf = this.buf.slice(idx + 1);
if (line.length > 0) {
let obj: Record<string, unknown> | null = null;
try {
obj = JSON.parse(line) as Record<string, unknown>;
} catch {
obj = null;
}
if (obj !== null && typeof obj["id"] === "number") {
const pend = this.pending.get(obj["id"] as number);
if (pend !== undefined) {
this.pending.delete(obj["id"] as number);
clearTimeout(pend.timer);
pend.resolve(obj);
}
}
}
idx = this.buf.indexOf("\n");
}
}
private send(body: Record<string, unknown>): Promise<Record<string, unknown>> {
return this.ensureReady().then(
() =>
new Promise<Record<string, unknown>>((resolve, reject) => {
if (this.sock === null) {
reject(new Error("render client not connected"));
return;
}
const id = this.nextId++;
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`render request ${id} (${String(body["type"])}) timed out`));
}, this.opts.requestTimeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.sock.write(JSON.stringify({ ...body, id }) + "\n");
}),
);
}
ping(): Promise<Record<string, unknown>> {
return this.send({ type: "ping" });
}
screenshot(path?: string): Promise<Record<string, unknown>> {
return this.send(path === undefined ? { type: "screenshot" } : { type: "screenshot", path });
}
openScreen(screen: string): Promise<Record<string, unknown>> {
return this.send({ type: "open_screen", screen });
}
/** Stop the rendered game and tear down the socket. */
close(): void {
this.closed = true;
this.failAll(new Error("render client closed"));
if (this.sock !== null) {
this.sock.destroy();
this.sock = null;
}
if (this.child !== null) {
this.child.kill();
this.child = null;
}
}
private failAll(err: Error): void {
for (const pend of this.pending.values()) {
clearTimeout(pend.timer);
pend.reject(err);
}
this.pending.clear();
}
}