breakpoint-mcp 1.19.1 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * `breakpoint-mcp tools` — export the tool surface as a stable, machine-readable
3
+ * artifact.
4
+ *
5
+ * Why this exists: third-party MCP catalogs, policy gateways, and security
6
+ * reviewers need to know what this server exposes and how risky each tool is.
7
+ * Without a published artifact their only options are to run the server and
8
+ * speak MCP at it, or to infer risk from tool NAMES. A public catalog did the
9
+ * latter in July 2026 and got it backwards in both directions — `tilemap_clear`
10
+ * filed as irreversible (it is undoable), `theme_set_color` not flagged at all
11
+ * (it rewrites a `.tres` in place). This command, plus the annotations shipped
12
+ * on `tools/list`, removes the need to guess.
13
+ *
14
+ * It reports the surface WITHOUT connecting to Godot: the registry is built
15
+ * against a recorder using the same `buildToolsets` path `index.ts` drives, with
16
+ * stub clients, and no handler is ever invoked. So it works in CI, in a
17
+ * container, and on a machine with no Godot installed.
18
+ *
19
+ * Two surfaces are reported, because the difference is the security story:
20
+ * • `full` — all 289 tools, what loads with BREAKPOINT_PRIVILEGED_GROUPS=all
21
+ * • `secure-default` — 274, what an untouched install actually advertises
22
+ * `--surface` picks which one populates `tools[]`; both counts are always in the
23
+ * header so a consumer cannot report one as the other by accident.
24
+ */
25
+ import { parseArgs } from "./args.js";
26
+ import { buildToolsets } from "../toolsets.js";
27
+ import { applyOutputSchemas } from "../schemas.js";
28
+ import { applyAnnotations, annotationsFor } from "../annotations.js";
29
+ import { TOOL_CAPABILITIES, CAPABILITY_GROUPS, GROUP_DESCRIBE } from "../capabilities.js";
30
+ import { loadConfig } from "../config.js";
31
+ import { readFileSync } from "node:fs";
32
+ import { fileURLToPath } from "node:url";
33
+ import { dirname, join } from "node:path";
34
+ /**
35
+ * Read the version from package.json at runtime rather than hardcoding it.
36
+ * Deliberate: the version already has to be bumped in `package.json`, the
37
+ * `serverInfo` literal, both `plugin.cfg`s, and the doctor/init surface strings
38
+ * — this command must not become a sixth place to forget. package.json ships in
39
+ * the npm tarball, so the read is safe in an installed package; if it ever
40
+ * isn't, an unknown version is better than a stale one.
41
+ */
42
+ function packageVersion() {
43
+ try {
44
+ const here = dirname(fileURLToPath(import.meta.url));
45
+ return JSON.parse(readFileSync(join(here, "..", "..", "package.json"), "utf8")).version ?? "unknown";
46
+ }
47
+ catch {
48
+ return "unknown";
49
+ }
50
+ }
51
+ /**
52
+ * Register the whole surface against a recorder, exactly as `index.ts` does
53
+ * (`applyOutputSchemas` → `applyAnnotations` → every `register*Tools`), and
54
+ * capture each tool's config. Capability filtering is applied afterwards rather
55
+ * than via `applyCapabilities`, so a single pass can report both surfaces.
56
+ */
57
+ function recordSurface() {
58
+ const recorded = [];
59
+ let current = "";
60
+ const push = (name, config) => {
61
+ recorded.push({ name, config, toolset: current });
62
+ return { name };
63
+ };
64
+ const server = {
65
+ registerTool: (name, config) => push(name, config),
66
+ registerResource: () => { },
67
+ experimental: {
68
+ tasks: { registerToolTask: (name, config) => push(name, config) },
69
+ },
70
+ server: { elicitInput: async () => ({ action: "decline" }) },
71
+ };
72
+ const mcp = server;
73
+ const stub = {};
74
+ applyOutputSchemas(mcp);
75
+ applyAnnotations(mcp);
76
+ for (const ts of buildToolsets({
77
+ server: mcp,
78
+ bridge: stub,
79
+ runtime: stub,
80
+ lsp: stub,
81
+ csLsp: stub,
82
+ dap: stub,
83
+ csDap: stub,
84
+ config: loadConfig(),
85
+ })) {
86
+ current = ts.id;
87
+ ts.run();
88
+ }
89
+ return recorded;
90
+ }
91
+ function toExported(r) {
92
+ const groups = [...(TOOL_CAPABILITIES[r.name] ?? [])];
93
+ const shape = r.config.inputSchema ?? {};
94
+ const params = Object.keys(shape);
95
+ return {
96
+ name: r.name,
97
+ title: typeof r.config.title === "string" ? r.config.title : null,
98
+ description: typeof r.config.description === "string" ? r.config.description : null,
99
+ toolset: r.toolset,
100
+ capabilityGroups: groups,
101
+ privileged: groups.length > 0,
102
+ confirmationGated: params.includes("confirm"),
103
+ params,
104
+ annotations: annotationsFor(r.name),
105
+ };
106
+ }
107
+ /** Build the report. Exported for the tests, which assert it against the registry. */
108
+ export function buildToolsReport(surface) {
109
+ const all = recordSurface().map(toExported);
110
+ const secure = all.filter((t) => !t.privileged);
111
+ const shown = surface === "full" ? all : secure;
112
+ return {
113
+ server: { name: "breakpoint-mcp", version: packageVersion() },
114
+ // Deliberately null, not a timestamp: this artifact is a pure function of the
115
+ // source tree, so two runs of the same build must be byte-identical (a
116
+ // consumer can diff releases; CI can assert no drift).
117
+ generatedAt: null,
118
+ surface,
119
+ counts: {
120
+ full: all.length,
121
+ secureDefault: secure.length,
122
+ readOnly: shown.filter((t) => t.annotations.readOnlyHint).length,
123
+ destructive: shown.filter((t) => t.annotations.destructiveHint).length,
124
+ idempotent: shown.filter((t) => t.annotations.idempotentHint).length,
125
+ openWorld: shown.filter((t) => t.annotations.openWorldHint).length,
126
+ confirmationGated: shown.filter((t) => t.confirmationGated).length,
127
+ privileged: all.length - secure.length,
128
+ },
129
+ capabilityGroups: CAPABILITY_GROUPS.map((g) => ({
130
+ id: g,
131
+ describe: GROUP_DESCRIBE[g],
132
+ defaultEnabled: false,
133
+ tools: all.filter((t) => t.capabilityGroups.includes(g)).map((t) => t.name).sort(),
134
+ })),
135
+ tools: [...shown].sort((a, b) => a.name.localeCompare(b.name)),
136
+ };
137
+ }
138
+ function renderText(report) {
139
+ const c = report.counts;
140
+ const lines = [
141
+ `breakpoint-mcp ${report.server.version} — ${report.surface} surface`,
142
+ "",
143
+ ` tools ${report.tools.length}` +
144
+ (report.surface === "secure-default" ? ` (of ${c.full} total; ${c.privileged} privileged, dropped)` : ""),
145
+ ` read-only ${c.readOnly}`,
146
+ ` destructive ${c.destructive}`,
147
+ ` idempotent ${c.idempotent}`,
148
+ ` open-world (egress) ${c.openWorld}`,
149
+ ` confirmation-gated ${c.confirmationGated}`,
150
+ "",
151
+ "Capability groups (all OFF by default — enable with BREAKPOINT_PRIVILEGED_GROUPS):",
152
+ ];
153
+ for (const g of report.capabilityGroups) {
154
+ lines.push(` ${g.id} (${g.tools.length} tools) — ${g.describe}`);
155
+ }
156
+ lines.push("", "Tools:");
157
+ for (const t of report.tools) {
158
+ const marks = [
159
+ t.annotations.readOnlyHint ? "read-only" : null,
160
+ t.annotations.destructiveHint ? "destructive" : null,
161
+ t.confirmationGated ? "gated" : null,
162
+ t.privileged ? `privileged:${t.capabilityGroups.join("+")}` : null,
163
+ ].filter(Boolean);
164
+ lines.push(` ${t.name.padEnd(34)} ${marks.join(" · ")}`);
165
+ }
166
+ lines.push("", "Re-run with --json for the machine-readable form.", "");
167
+ return lines.join("\n");
168
+ }
169
+ export async function runTools(argv) {
170
+ const { flags } = parseArgs(argv, ["json", "help", "h"]);
171
+ if (flags.help || flags.h) {
172
+ process.stdout.write([
173
+ "breakpoint-mcp tools — export the tool surface (name, risk annotations, capability group).",
174
+ "",
175
+ " --json Emit JSON (stable, no timestamp — safe to diff between releases).",
176
+ " --surface <which> full | secure-default (default: secure-default, what an",
177
+ " untouched install actually advertises).",
178
+ "",
179
+ "Reads no Godot install and starts no server — the registry is built statically.",
180
+ "",
181
+ ].join("\n"));
182
+ return 0;
183
+ }
184
+ const raw = typeof flags.surface === "string" ? flags.surface : "secure-default";
185
+ if (raw !== "full" && raw !== "secure-default") {
186
+ process.stderr.write(`unknown --surface "${raw}" (expected: full | secure-default)\n`);
187
+ return 2;
188
+ }
189
+ const report = buildToolsReport(raw);
190
+ process.stdout.write(flags.json ? `${JSON.stringify(report, null, 2)}\n` : renderText(report));
191
+ return 0;
192
+ }
193
+ //# sourceMappingURL=tools.js.map
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { DapClient } from "./dap.js";
12
12
  import { buildToolsets } from "./toolsets.js";
13
13
  import { registerRecipes } from "./recipes.js";
14
14
  import { applyOutputSchemas } from "./schemas.js";
15
+ import { applyAnnotations } from "./annotations.js";
15
16
  import { applyCapabilities, droppedTools, registerCapabilitiesResource, selectPrivilegedGroups, } from "./capabilities.js";
16
17
  import { taskStore, TASK_CAPABILITIES } from "./tasks.js";
17
18
  import { RESOURCE_CAPABILITIES, registerResourceSubscriptions } from "./subscriptions.js";
@@ -35,24 +36,31 @@ async function main() {
35
36
  // so long jobs (export/import/headless script) support poll/await/cancel.
36
37
  // D3: also advertise resources.subscribe so clients can subscribe to
37
38
  // godot://… resources and receive notifications/resources/updated.
38
- const server = new McpServer({ name: "breakpoint-mcp", version: "1.19.1" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
39
+ const server = new McpServer({ name: "breakpoint-mcp", version: "1.23.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
39
40
  // B1: enforce frozen output schemas on every structured tool. Must run before
40
41
  // any register*Tools call — it wraps server.registerTool.
41
42
  applyOutputSchemas(server);
43
+ // MCP tool annotations — publish readOnly/destructive/idempotent/openWorld
44
+ // hints on every tool so clients and policy catalogs read our risk posture
45
+ // instead of guessing it from tool names. Hints only; the enforcement stays
46
+ // in applyCapabilities (below) + confirm.ts. Wraps registerTool after the
47
+ // schema wrapper, before any register*Tools call.
48
+ applyAnnotations(server);
42
49
  // Capability groups — a risk-based axis over the toolsets. Both `code-execution`
43
50
  // and `network` are OFF by default; a disabled group's tools are DROPPED at
44
51
  // registration (omitted from tools/list), so the secure-default surface is
45
- // 276 − 14 = 262 tools. Enable via BREAKPOINT_PRIVILEGED_GROUPS. Wraps
52
+ // 289 − 15 = 274 tools. Enable via BREAKPOINT_PRIVILEGED_GROUPS. Wraps
46
53
  // server.registerTool AFTER applyOutputSchemas (schema wrapper stays innermost).
47
54
  const privilegedGroups = selectPrivilegedGroups(config.privilegedGroups, (unknown) => log(`ignoring unknown BREAKPOINT_PRIVILEGED_GROUPS token(s): ${unknown.join(", ")}`));
48
55
  applyCapabilities(server, privilegedGroups);
49
56
  // The A/B/C/D planes ARE the grouping. Build the ordered toolset registry
50
57
  // (the single source of truth, shared with the registration tests) and
51
58
  // register only the selected groups. Default (BREAKPOINT_TOOLSETS unset) =
52
- // every group → the full 276-tool surface, byte-identical to before. A filter
59
+ // every group → the full 289-tool surface, byte-identical to before. A filter
53
60
  // lets a client that can't defer tools, or a user who wants a smaller default
54
61
  // menu, load only the planes a project needs (GitHub-MCP `--toolsets` style).
55
62
  let processes;
63
+ let peers;
56
64
  const toolsets = buildToolsets({
57
65
  server,
58
66
  bridge,
@@ -65,6 +73,9 @@ async function main() {
65
73
  onProcesses: (h) => {
66
74
  processes = h;
67
75
  },
76
+ onPeers: (h) => {
77
+ peers = h;
78
+ },
68
79
  });
69
80
  const enabled = selectToolsets(toolsets.map((t) => t.id), config.toolsets, (unknown) => log(`ignoring unknown BREAKPOINT_TOOLSETS token(s): ${unknown.join(", ")}`));
70
81
  for (const ts of toolsets)
@@ -85,7 +96,7 @@ async function main() {
85
96
  log(`toolsets enabled: ${[...enabled].sort().join(", ")} (${enabled.size}/${toolsets.length} groups)`);
86
97
  }
87
98
  // Recipes: a free, curated task-recipe layer exposed as MCP prompts (discoverable
88
- // via prompts/list). Adds NO tools — the 276-tool count is unchanged — and drives
99
+ // via prompts/list). Adds NO tools — the 289-tool count is unchanged — and drives
89
100
  // the enforced tools above, so it's a skill-pack layer over typed/undoable tools.
90
101
  registerRecipes(server);
91
102
  // Always-on capability affordance — never behind a toolset or a privileged
@@ -117,6 +128,8 @@ async function main() {
117
128
  dap.close();
118
129
  csDap.close();
119
130
  processes?.killAll();
131
+ // F6: headless peers are children of this server — never outlive it.
132
+ peers?.stopAll();
120
133
  process.exit(0);
121
134
  };
122
135
  process.on("SIGINT", shutdown);
@@ -130,6 +143,7 @@ function printUsage() {
130
143
  " breakpoint-mcp Start the MCP server on stdio (default; how MCP clients launch it).",
131
144
  " breakpoint-mcp init Install + enable the editor addon in a project and wire the MCP client.",
132
145
  " breakpoint-mcp doctor Check the Godot binary, the editor addon, and the four bridges.",
146
+ " breakpoint-mcp tools Export the tool surface + risk annotations (for catalogs / reviews).",
133
147
  " breakpoint-mcp --help Show this help.",
134
148
  "",
135
149
  "init options:",
@@ -147,6 +161,10 @@ function printUsage() {
147
161
  " --timeout <ms> Per-bridge connect timeout (default 1500).",
148
162
  " --json Emit the report as JSON.",
149
163
  "",
164
+ "tools options:",
165
+ " --surface <which> full | secure-default (default: secure-default, what an untouched install advertises).",
166
+ " --json Emit the surface as JSON — stable and timestamp-free, so releases can be diffed.",
167
+ "",
150
168
  "All runtime configuration is via environment variables; see the README.",
151
169
  "",
152
170
  ].join("\n"));
@@ -160,6 +178,10 @@ void (async () => {
160
178
  const { runDoctor } = await import("./cli/doctor.js");
161
179
  process.exit(await runDoctor(process.argv.slice(3)));
162
180
  }
181
+ if (sub === "tools") {
182
+ const { runTools } = await import("./cli/tools.js");
183
+ process.exit(await runTools(process.argv.slice(3)));
184
+ }
163
185
  if (sub === "init") {
164
186
  const { runInit } = await import("./cli/init.js");
165
187
  process.exit(await runInit(process.argv.slice(3)));
package/dist/peers.js ADDED
@@ -0,0 +1,238 @@
1
+ import net from "node:net";
2
+ import { BridgeClient, BridgeError } from "./bridge.js";
3
+ import { ProcessRegistry } from "./tools/processes.js";
4
+ import { ensureProjectSecret, readProjectSecret } from "./secret.js";
5
+ import { log } from "./logger.js";
6
+ /**
7
+ * Hard ceiling on simultaneously live peers.
8
+ *
9
+ * Four, not the eight a rival offers. Four headless Godot instances is already a
10
+ * heavy CI runner, the convergence cases that matter are covered at four, and
11
+ * every extra instance multiplies the flake surface of the one feature whose
12
+ * entire selling point is that it does NOT flake. Raise it on evidence, never
13
+ * speculatively.
14
+ */
15
+ export const MAX_PEERS = 4;
16
+ /** How far above the default runtime port the allocator will scan. */
17
+ const PORT_SCAN_SPAN = 200;
18
+ /** Is `port` bindable on `host` right now? */
19
+ function portFree(host, port) {
20
+ return new Promise((resolve) => {
21
+ const srv = net.createServer();
22
+ srv.once("error", () => resolve(false));
23
+ srv.once("listening", () => srv.close(() => resolve(true)));
24
+ try {
25
+ srv.listen(port, host);
26
+ }
27
+ catch {
28
+ resolve(false);
29
+ }
30
+ });
31
+ }
32
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33
+ export class PeerRegistry {
34
+ cfg;
35
+ /** Peers get their OWN process registry, so peer ids and `godot_run_managed`
36
+ * ids never share a namespace and the `runtime` toolset does not depend on
37
+ * whether the `processes` toolset is enabled. */
38
+ procs = new ProcessRegistry();
39
+ peers = new Map();
40
+ counter = 0;
41
+ constructor(cfg) {
42
+ this.cfg = cfg;
43
+ }
44
+ /** Peers that have not been stopped and whose child is still running. */
45
+ live() {
46
+ const out = [];
47
+ for (const e of this.peers.values()) {
48
+ if (e.stopped)
49
+ continue;
50
+ if (this.procs.get(e.managedId)?.exited)
51
+ continue;
52
+ out.push(e.info);
53
+ }
54
+ return out;
55
+ }
56
+ /** Every peer this registry has spawned, live or not. */
57
+ all() {
58
+ return [...this.peers.values()].map((e) => e.info);
59
+ }
60
+ /**
61
+ * The bridge client addressing one peer. Throws a BridgeError the runtime
62
+ * tools' `fail()` renders — an unknown or dead peer must read as a clear
63
+ * message naming the live ids, never as a generic "cannot reach the bridge".
64
+ */
65
+ clientFor(id) {
66
+ const e = this.peers.get(id);
67
+ if (!e) {
68
+ const ids = this.live().map((p) => p.id);
69
+ throw new BridgeError("unknown_peer", `No peer with id "${id}". Live peers: ${ids.length ? ids.join(", ") : "(none)"}. ` +
70
+ `Spawn peers with runtime_spawn_peers, or omit \`peer\` to address the default running game.`);
71
+ }
72
+ if (e.stopped)
73
+ throw new BridgeError("peer_stopped", `Peer "${id}" was stopped by runtime_peer_stop.`);
74
+ const m = this.procs.get(e.managedId);
75
+ if (m?.exited) {
76
+ const tail = this.procs.tail(e.managedId, 6).join(" | ");
77
+ throw new BridgeError("peer_exited", `Peer "${id}" exited (code ${m.exitCode})${tail ? `; last output: ${tail}` : ""}.`);
78
+ }
79
+ return e.client;
80
+ }
81
+ /**
82
+ * Allocate `count` free loopback ports, starting just above the default
83
+ * runtime port so a peer never collides with a developer's already-running
84
+ * game. Ports held by live peers are skipped even if the probe would pass.
85
+ *
86
+ * Probe-then-release is a TOCTOU window by construction: another process could
87
+ * take the port between the probe and the child's bind. Allocation is
88
+ * sequential and the window is sub-millisecond, and the failure mode is
89
+ * benign — the child logs "could not listen" and the readiness wait below
90
+ * reports it with that line attached, rather than the peer silently
91
+ * half-existing.
92
+ */
93
+ async allocatePorts(count) {
94
+ const taken = new Set([this.cfg.runtimePort, ...this.live().map((p) => p.port)]);
95
+ const ports = [];
96
+ const base = this.cfg.runtimePort + 1;
97
+ for (let port = base; port < base + PORT_SCAN_SPAN && ports.length < count; port++) {
98
+ if (taken.has(port))
99
+ continue;
100
+ if (!(await portFree(this.cfg.runtimeHost, port)))
101
+ continue;
102
+ taken.add(port);
103
+ ports.push(port);
104
+ }
105
+ if (ports.length < count) {
106
+ throw new BridgeError("no_free_port", `Could not find ${count} free loopback port(s) in ${base}–${base + PORT_SCAN_SPAN - 1}. ` +
107
+ `Stop stale peers with runtime_peer_stop{all:true}, or set BREAKPOINT_RUNTIME_PORT to a quieter range.`);
108
+ }
109
+ return ports;
110
+ }
111
+ /** Poll a peer's bridge until it answers `ping`, or the deadline passes. */
112
+ async waitReady(client, deadline) {
113
+ for (;;) {
114
+ const remaining = deadline - Date.now();
115
+ if (remaining <= 0)
116
+ return false;
117
+ try {
118
+ await client.request("ping", {}, Math.min(1000, remaining));
119
+ return true;
120
+ }
121
+ catch {
122
+ if (Date.now() >= deadline)
123
+ return false;
124
+ await sleep(100);
125
+ }
126
+ }
127
+ }
128
+ /**
129
+ * Spawn `count` headless peers and wait until each answers `ping`.
130
+ *
131
+ * Waiting is part of the contract on purpose. Godot needs ~200 ms to bind, so
132
+ * a tool that returned immediately would hand the caller peers its very next
133
+ * call cannot reach — a flake in the one feature that exists to remove flakes.
134
+ */
135
+ async spawn(opts) {
136
+ const liveCount = this.live().length;
137
+ if (liveCount + opts.count > MAX_PEERS) {
138
+ throw new BridgeError("peer_limit", `${liveCount} peer(s) already live; spawning ${opts.count} more would exceed the ceiling of ${MAX_PEERS}. ` +
139
+ `Stop some with runtime_peer_stop{all:true} first.`);
140
+ }
141
+ // Constraint 2 — mint BEFORE the first spawn so no child reaches the addon's
142
+ // unlocked mint branch. Never overwrites an existing secret.
143
+ const secret = ensureProjectSecret(this.cfg.projectPath);
144
+ if (!secret) {
145
+ log("[peers] could not read or mint the project secret; peers will mint their own (see peers.ts)");
146
+ }
147
+ const ports = await this.allocatePorts(opts.count);
148
+ const deadline = Date.now() + (opts.timeoutMs ?? 15000);
149
+ const spawned = [];
150
+ for (let i = 0; i < ports.length; i++) {
151
+ const port = ports[i];
152
+ const id = `peer-${++this.counter}`;
153
+ const extraArgs = ["--headless", ...(opts.args ?? []), ...(opts.scene ? [opts.scene] : [])];
154
+ const env = {
155
+ BREAKPOINT_RUNTIME_PORT: String(port),
156
+ BREAKPOINT_PEER_ID: id,
157
+ BREAKPOINT_PEER_INDEX: String(i),
158
+ };
159
+ if (opts.role)
160
+ env.BREAKPOINT_PEER_ROLE = opts.role;
161
+ const managed = this.procs.run(this.cfg, extraArgs, env);
162
+ const client = new BridgeClient(this.cfg.runtimeHost, port, this.cfg.runtimeTimeoutMs, `runtime peer ${id}`, "Is the peer still running? Peers are headless children of this server; a project that fails to load exits immediately.",
163
+ // The FILE, not resolveBridgeSecret: the addon's _setup_auth() does not
164
+ // read BREAKPOINT_*_SECRET, so a host-launched child authenticates with
165
+ // whatever is on disk regardless of the host's env overrides.
166
+ () => readProjectSecret(this.cfg.projectPath));
167
+ const entry = {
168
+ info: { id, port, pid: managed.child.pid ?? null, role: opts.role ?? null, ready: false },
169
+ client,
170
+ managedId: managed.id,
171
+ stopped: false,
172
+ };
173
+ this.peers.set(id, entry);
174
+ spawned.push(entry);
175
+ }
176
+ await Promise.all(spawned.map(async (e) => {
177
+ e.info.ready = await this.waitReady(e.client, deadline);
178
+ }));
179
+ const notReady = spawned.filter((e) => !e.info.ready);
180
+ if (notReady.length) {
181
+ const detail = notReady
182
+ .map((e) => {
183
+ const m = this.procs.get(e.managedId);
184
+ const why = m?.exited ? `exited (code ${m.exitCode})` : "never answered ping";
185
+ const tail = this.procs.tail(e.managedId, 6).join(" | ");
186
+ return `${e.info.id} on ${e.info.port}: ${why}${tail ? ` — ${tail}` : ""}`;
187
+ })
188
+ .join("; ");
189
+ // Leave the failed peers registered rather than reaping them: the caller
190
+ // can still runtime_peer_stop{all:true}, and a half-started peer that
191
+ // vanished from the registry is harder to reason about than one that
192
+ // reports why it is not ready.
193
+ throw new BridgeError("peer_not_ready", `${notReady.length} of ${spawned.length} peer(s) did not become ready. ${detail}. ` +
194
+ `Is the Breakpoint MCP addon enabled in this project (it registers the runtime autoload)?`);
195
+ }
196
+ return spawned.map((e) => e.info);
197
+ }
198
+ /** Stop one peer (or all). Repeating is a no-op, so the tool is idempotent. */
199
+ stop(id, all = false) {
200
+ const targets = all ? [...this.peers.keys()] : id ? [id] : [];
201
+ if (!all && id && !this.peers.has(id)) {
202
+ const ids = this.live().map((p) => p.id);
203
+ throw new BridgeError("unknown_peer", `No peer with id "${id}". Live peers: ${ids.length ? ids.join(", ") : "(none)"}.`);
204
+ }
205
+ const stopped = [];
206
+ for (const t of targets) {
207
+ const e = this.peers.get(t);
208
+ if (!e)
209
+ continue;
210
+ try {
211
+ e.client.close();
212
+ }
213
+ catch {
214
+ /* ignore */
215
+ }
216
+ try {
217
+ this.procs.get(e.managedId)?.child.kill();
218
+ }
219
+ catch {
220
+ /* ignore */
221
+ }
222
+ e.stopped = true;
223
+ stopped.push(t);
224
+ }
225
+ return stopped;
226
+ }
227
+ /** Shutdown hook — kill every peer child and close every peer socket. */
228
+ stopAll() {
229
+ try {
230
+ this.stop(undefined, true);
231
+ }
232
+ catch {
233
+ /* ignore */
234
+ }
235
+ this.procs.killAll();
236
+ }
237
+ }
238
+ //# sourceMappingURL=peers.js.map
package/dist/recipes.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * it's **free** (MIT, shipped in the server), and it sits **over typed,
8
8
  * schema-validated, undoable tools** — so the contract is executed by the
9
9
  * server, not merely described in prose the model might misapply. Recipes are
10
- * discoverable via MCP `prompts/list`; they add **no tools** (the 276-tool
10
+ * discoverable via MCP `prompts/list`; they add **no tools** (the 289-tool
11
11
  * count is unchanged) and cost nothing until a client pulls one.
12
12
  */
13
13
  import { z } from "zod";
@@ -27,6 +27,8 @@ export const RECIPE_NAMES = [
27
27
  "recipe_screenshot_regression",
28
28
  "recipe_type_safe_edit",
29
29
  "recipe_csharp_fix_and_debug",
30
+ "recipe_deterministic_playtest",
31
+ "recipe_multiplayer_scaffold_and_converge",
30
32
  ];
31
33
  export function registerRecipes(server) {
32
34
  // 1 — 2D player controller, then verify it moves.
@@ -164,5 +166,67 @@ export function registerRecipes(server) {
164
166
  SAFETY,
165
167
  ].join("\n"));
166
168
  });
169
+ // 7 — Deterministic playtesting: freeze, step exact frames, assert a golden frame.
170
+ server.registerPrompt("recipe_deterministic_playtest", {
171
+ title: "Freeze the game, step exact frames, and assert a deterministic golden frame",
172
+ description: "Turn a flaky run-screenshot-guess loop into a frame-exact, reproducible one: freeze time, advance a fixed number of frames, then assert node state and diff a golden frame.",
173
+ argsSchema: {
174
+ scene_path: z.string().optional().describe("Scene to play-test (default res://main.tscn)"),
175
+ root_path: z.string().optional().describe("Node to snapshot/assert on (default '.', the running scene root)"),
176
+ frames: z.string().optional().describe("Frames to advance per step (default 30)"),
177
+ reference_path: z.string().optional().describe("Golden PNG for the screenshot diff (default res://tests/frame.png)"),
178
+ tolerance: z.string().optional().describe("Max fraction of differing pixels for the diff (default 0.0)"),
179
+ },
180
+ }, (a) => {
181
+ const scene = str(a, "scene_path", "res://main.tscn");
182
+ const root = str(a, "root_path", ".");
183
+ const frames = str(a, "frames", "30");
184
+ const ref = str(a, "reference_path", "res://tests/frame.png");
185
+ const tol = str(a, "tolerance", "0.0");
186
+ return recipe("Deterministic, frame-exact playtesting with a golden-frame check.", [
187
+ `Goal: drive ${scene} to an exact, reproducible frame and assert on it — no wall-clock flakiness.`,
188
+ "",
189
+ `1. Boot + freeze: godot_run_project. runtime_time_scale{scale:0} to FREEZE the game (this pauses the SceneTree, so nothing advances on its own). If the run uses randomness, runtime_seed_rng{seed:...} first so the whole playtest is reproducible.`,
190
+ `2. Baseline: runtime_state_digest{root:"${root}"} to snapshot the frozen state (positions / visibility / modulate, stable-ordered) — this is your deterministic "before".`,
191
+ `3. Step: runtime_step_frames{frames:${frames}, kind:"physics"} to advance EXACTLY ${frames} frames while frozen (use kind:"both" to also tick _process each step). The game stays frozen afterwards, so your reads are stable.`,
192
+ `4. Assert: runtime_state_digest{root:"${root}"} (or runtime_assert_node_state) that the node reached the expected, frame-exact state, then runtime_screenshot_diff against ${ref} with tolerance ${tol} — because you stepped a fixed number of frames the frame is reproducible, so the diff is stable (establish ${ref} once with runtime_screenshot on the first green run, then vcs_add it).`,
193
+ `5. Iterate or thaw: repeat 3–4 to walk frame-by-frame (every step is deterministic), or runtime_time_scale{scale:1} to thaw and resume normal play. godot_stop when done. On a failed assert you are one dbg_set_breakpoints + dbg_launch away from the exact value that diverged.`,
194
+ "",
195
+ "Why this beats run-screenshot-guess: freezing plus fixed-frame stepping converts a timing-dependent check into a frame-exact, reproducible one the runtime_assert_* family can verify — the R tier of the W/R/E model, made deterministic.",
196
+ "",
197
+ SAFETY,
198
+ ].join("\n"));
199
+ });
200
+ // 8 — Multiplayer: scaffold it with mp_*, then prove independent peers converge.
201
+ server.registerPrompt("recipe_multiplayer_scaffold_and_converge", {
202
+ title: "Scaffold multiplayer with mp_*, then prove independent peers converge",
203
+ description: "Scaffold ENet + spawner / synchronizer / RPC netcode, then spawn real headless peers and assert they reach a byte-equal state — the scaffold verified rather than assumed.",
204
+ argsSchema: {
205
+ scene_path: z.string().optional().describe("Scene to scaffold and play-test (default res://main.tscn)"),
206
+ peer_count: z.string().optional().describe("Headless peers to spawn (1-4; default 3)"),
207
+ seed: z.string().optional().describe("RNG seed given to every peer (default 12345)"),
208
+ frames: z.string().optional().describe("Physics frames to step on each peer (default 30)"),
209
+ root_path: z.string().optional().describe("Node subtree to compare across peers (default '.', the running scene root)"),
210
+ },
211
+ }, (a) => {
212
+ const scene = str(a, "scene_path", "res://main.tscn");
213
+ const count = str(a, "peer_count", "3");
214
+ const seed = str(a, "seed", "12345");
215
+ const frames = str(a, "frames", "30");
216
+ const root = str(a, "root_path", ".");
217
+ return recipe("Multiplayer scaffolding with a multi-peer convergence check.", [
218
+ `Goal: scaffold multiplayer in ${scene} with the mp_* family, then prove ${count} independently-driven peers reach the SAME state.`,
219
+ "",
220
+ `1. Scaffold: scene_open ${scene}. mp_setup_enet_peer for the host/join script (mp_setup_webrtc_peer if you need WebRTC); mp_scaffold_lobby for a lobby; mp_add_spawner for nodes created at runtime and mp_add_synchronizer for replicated properties; mp_set_authority on the nodes each peer owns; mp_wire_rpc for the calls that cross the wire. Run gd_diagnostics until clean BEFORE running anything.`,
221
+ "2. Make it testable, or step 4 will diverge and be right to: keep replicated state on _physics_process (the FIXED timestep), guard every global randf() on `delta > 0`, and give idle-frame code its OWN RandomNumberGenerator. runtime_seed_rng seeds ONE stream for the whole project, and freezing does not stop callbacks firing — an unguarded draw burns that stream at wall-clock rate and desynchronises peers that are otherwise identical.",
222
+ `3. Spawn: runtime_spawn_peers{count:${count}} — real headless children of this project, each on its own loopback port (ceiling 4). Pass role:"server" to label one and branch on OS.get_environment("BREAKPOINT_PEER_ROLE"). It is code-execution-privileged, so it is absent from the default surface unless you enabled that capability group.`,
223
+ `4. Converge, IN THIS ORDER: runtime_time_scale{scale:0, peer} to FREEZE every peer first (they free-ran for different durations since spawn, so their state already differs); runtime_set_property{peer} to equalise the starting state; runtime_seed_rng{seed:${seed}, peer} with the SAME seed on each; runtime_step_frames{frames:${frames}, kind:"physics", peer} on each; then runtime_peers_digest{root:"${root}"}.`,
224
+ `5. Assert, then diagnose: converged:true is the green result. On false, diverged_at names the exact node paths that disagree — start there with runtime_get_property{peer} and runtime_get_log{peer} on the two peers that differ, runtime_assert_node_state for the value you expected, and dbg_set_breakpoints + dbg_launch when you need the frame it went wrong on. Finish with runtime_peer_stop{all:true}.`,
225
+ "",
226
+ "Scope, so a green result means what you think it does: peers are local headless children on loopback — this is testing, not hosting, and Breakpoint runs no relay, lobby or signalling server. Convergence is a same-machine claim (one OS, one engine build); it says nothing about two players on two machines.",
227
+ "",
228
+ SAFETY,
229
+ ].join("\n"));
230
+ });
167
231
  }
168
232
  //# sourceMappingURL=recipes.js.map
package/dist/schemas.js CHANGED
@@ -494,6 +494,42 @@ export const outputSchemas = {
494
494
  reference: z.string(),
495
495
  reason: z.string().optional(),
496
496
  },
497
+ // F8 additions: await/animation/node-lifecycle over the runtime bridge.
498
+ runtime_await_condition: { met: z.boolean(), polls: z.number(), elapsed_ms: z.number(), value: encodedValue },
499
+ runtime_anim_play: { playing: z.boolean(), current_animation: z.string(), speed_scale: z.number() },
500
+ runtime_anim_stop: { playing: z.boolean(), current_animation: z.string(), position: z.number() },
501
+ runtime_anim_get_state: {
502
+ playing: z.boolean(),
503
+ current_animation: z.string(),
504
+ position: z.number(),
505
+ length: z.number(),
506
+ speed_scale: z.number(),
507
+ animations: z.array(z.string()),
508
+ },
509
+ runtime_node_add: { added: z.boolean(), path: z.string(), type: z.string() },
510
+ runtime_node_remove: { removed: z.boolean(), path: z.string() },
511
+ // F4 additions: deterministic playtesting (time control / frame stepping / state digest / RNG seed).
512
+ runtime_time_scale: { previous: z.number(), current: z.number() },
513
+ runtime_step_frames: { frames_advanced: z.number(), frame_index: z.number() },
514
+ runtime_state_digest: { digest: z.record(z.record(encodedValue)), node_count: z.number() },
515
+ runtime_seed_rng: { seed: z.number() },
516
+ // F6 additions: multi-peer deterministic playtesting (spawn / stop / converge).
517
+ runtime_spawn_peers: {
518
+ peers: z.array(z.object({
519
+ id: z.string(),
520
+ port: z.number(),
521
+ pid: z.number().nullable(),
522
+ role: z.string().nullable(),
523
+ ready: z.boolean(),
524
+ })),
525
+ count: z.number(),
526
+ },
527
+ runtime_peer_stop: { stopped: z.array(z.string()) },
528
+ runtime_peers_digest: {
529
+ digests: z.array(z.object({ id: z.string(), digest: z.record(z.record(encodedValue)), node_count: z.number() })),
530
+ converged: z.boolean(),
531
+ diverged_at: z.array(z.string()).nullable(),
532
+ },
497
533
  // ---- Group K: knowledge & search ----
498
534
  // Host-side project index (tools/knowledge.ts) — no bridge/LSP; read the project files directly.
499
535
  project_search: {