Skip to content

Remote Console MCP + human-is-playing guard — design

Remote Console MCP + human-is-playing guard — design

Section titled “Remote Console MCP + human-is-playing guard — design”

Approved by Chris 2026-10-08. Agents currently drive the Remote Playtest Console with throwaway curl helpers (GET /status, POST /cmd, POST /watch). On 2026-10-08 a FunZo perf re-check nearly took over a run Chris was playing on BUILD 286; it was only noticed because HP dropped from 96 to 24 between two status reads. Perf A/Bs were also inconclusive: status fps is one instantaneous sample, swinging by about ±20. This design gives agents a proper tool surface, and puts a structural guard on the device’s run that does not depend on every agent remembering to check.

  • The MCP is local stdio, in tools/console_mcp/ and registered in the repo .mcp.json. It is not a remote /mcp route on the Worker. Only local sessions (Chris’s and Toby’s machines) can use it.
  • The guard is enforced in both the MCP and the Worker /cmd, so an agent’s raw curl hits the same wall.
  • The guard is an allowlist. Every command kind is guarded unless it is on the safe list, so a command added later is guarded by default.
  • A human is playing if their last input was ≤ 120 s ago.
  • ab_test lives in the MCP, not in a separate skill.
  • InputRouter.last_human_input_ms: int = -1 is stamped in _input by any real input:

    • a joypad button (any pad, the Siri Remote included);
    • a joypad stick push past PAD_WAKE;
    • a key press;
    • a touch or a screen drag;
    • a mouse button.

    Mouse motion alone does not count, because a cursor resting on a Mac window must not hold the guard. The timestamp uses the same clock_fn injection as last_pad_activity_ms.

  • last_pad_activity_ms is unchanged. PadLiveness keeps its pad-only semantics.

  • The rule for what counts is a pure static function, is_human_input(event, joy_name) -> bool, so GUT can test it without the scene tree.

/status additions (main.build_status, both the run branch and the menu branch)

Section titled “/status additions (main.build_status, both the run branch and the menu branch)”
  • human: {input_age_ms: int, autopilot: bool}
    • input_age_ms is -1 when there has been no input since boot. Otherwise it is now minus last_human_input_ms.
    • autopilot is main._auto; it is false at the menu. Attract-mode AI play is not human.
  • perf: {draws, proc_ms, funzones, webs}, run branch only:
    • draws is RenderingServer.get_rendering_info(RENDERING_INFO_TOTAL_DRAW_CALLS_IN_FRAME);
    • proc_ms is Performance.TIME_PROCESS × 1000;
    • funzones is sim.funzones.size();
    • webs is sim.webs.size().
  • qm: {auto, tier}, the QualityManager’s live auto flag and tier.

No command can currently turn the QualityManager’s adaptive mode back on remotely: graphics, a Debug Settings row press and QualityManager itself all flip qm.auto to false one-way, so ab_test cannot honour “never leave qm.auto flipped”. The fix is a new dev command, {kind:"qm_auto", on:bool}, which sets quality_manager.auto; it is on the safe list.

debug_settings.graphics_state() stays empty until something seeds it, so flags.gfx:* cannot tell agents a toggle’s live default (for example, webs_lite defaults ON). The state is seeded at boot from the live values, so every graphics effect key appears in flags from the first status post on.

2. Worker guard (control/src/guard.js, imported by worker.js and the MCP)

Section titled “2. Worker guard (control/src/guard.js, imported by worker.js and the MCP)”
  • SAFE_KINDS: graphics, background, web_look, weapon_look, set_tunables, audio_bus, haptic_play, qm_auto. Every other cmd.kind is guarded.
  • HUMAN_WINDOW_MS = 120000, STATUS_FRESH_MS = 30000.
  • guardVerdict(cmd, statusRow, now, body) -> {ok, reason} is a pure function. It refuses a guarded command when any of these holds:
    1. human.input_age_ms is between 0 and HUMAN_WINDOW_MS, inclusive ("human_playing");
    2. the status row is older than STATUS_FRESH_MS, or missing ("status_unknown");
    3. the status blob has no human key, which covers older builds ("status_unknown").
  • It passes whenever body.override === true or body.source === "panel". The console panel’s two /cmd fetches (panel/client.js, panel/live.js) send source: "panel", because that is Chris at the keyboard.
  • /cmd answers a refusal with 409, {ok:false, error:"guarded", reason, input_age_ms}.
  • The guard exists to catch accidents, not attacks: anyone holding the key can send override.

Node, using @modelcontextprotocol/sdk over stdio. It reads BULLET_HEAVEN_CONTROL_KEY from the environment; the .mcp.json command sources ~/.secrets, so the key never appears in a file. The base URL comes from CONSOLE_URL, defaulting to the deployed Worker. The HTTP layer is injectable so tests can run against a fake Worker.

Tool Behaviour
sessions /sessions, plus human_playing per device (needs one /status read per device).
status({code, full?}) /status. It is trimmed by default: config.published and weapons detail are dropped, and flags is filtered to gfx:*/dev:*. full: true returns the raw blob.
send_command({code, cmd, override?, reason?}) Checks guardVerdict locally first, then sends /cmd. override: true requires a reason and is logged to stderr and returned in the result.
telemetry_query({sql}) Runs wrangler d1 execute bullet-heaven-telemetry --remote --json from telemetry/, with CLOUDFLARE_ACCOUNT_ID set. It accepts a single SELECT or WITH … SELECT statement only; any other statement, or more than one, is rejected before anything runs. Results are capped at 200 rows.
ab_test({code, toggle, rounds=3, seconds_per_side=30, override?}) The device perf bisect (below).
  1. Refuse when any of these holds:
    • a human is present (unless override);
    • the device is not in a run;
    • rounds < 3;
    • the toggle is not in flags (an unknown live default).
  2. Keep the device watched: POST /watch every 20 s for the whole test, so status arrives at 1 Hz.
  3. Read the baseline: the toggle’s live value A and qm.auto.
  4. Run the rounds. For each round, run the sides in ABBA order (A, B, B, A). For each side:
    • send the graphics command;
    • wait 3 s to settle;
    • sample /status fps every 1 s for seconds_per_side;
    • keep the mean.
  5. Restore, in a finally block: set the toggle back to A. If qm.auto was true, send qm_auto {on:true}. Then re-read status and verify both, and report a failed restore loudly in the result.
  6. Report:
    • the per-side mean ± stddev and n;
    • the delta;
    • conclusive: false when the two sides’ ±1 stddev ranges overlap;
    • a scene_drift warning when alive / funzones / webs changed by more than 25% between the first and last rounds.
  1. Game build with the human field.
  2. Dev install on the devices.
  3. Worker guard deploy.

Deploying the guard first would refuse every guarded command sent by agents to devices still running older builds. The panel is not affected, because it sends source: "panel". A console Worker redeploy also ships the pending main changes (FZ_TILE_PX and its What’s new item).

  • GUT:
    • is_human_input across event types (touch, key, pad, the Siri Remote, drift below PAD_WAKE, mouse motion);
    • the last_human_input_ms stamp;
    • build_status carries human / perf / qm in both branches;
    • the boot-seeded gfx:* keys;
    • qm_auto sets quality_manager.auto both ways.
  • node --test in control/: guardVerdict cases (safe kind, guarded kind with a fresh human, a stale status, a missing human key, override, panel) and a 409 from /cmd.
  • node --test in tools/console_mcp/, against a fake Worker:
    • the ABBA order and averaging;
    • restore on success and on an injected error;
    • refusal while a human is present;
    • the SELECT-only filter;
    • the trim.
  • On a real device, after the install: one live ab_test of a known no-op toggle (expect inconclusive) and one of Zone Detail in a FunZo scene (expect a conclusive delta).