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.
- package/README.md +1 -1
- package/addon/breakpoint_mcp/operations.gd +1 -1
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/runtime_bridge.gd +243 -0
- package/dist/annotations.js +225 -0
- package/dist/capabilities.js +5 -1
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/init.js +1 -1
- package/dist/cli/tools.js +193 -0
- package/dist/index.js +26 -4
- package/dist/peers.js +238 -0
- package/dist/recipes.js +65 -1
- package/dist/schemas.js +36 -0
- package/dist/secret.js +49 -0
- package/dist/tools/processes.js +25 -1
- package/dist/tools/runtime.js +467 -36
- package/dist/toolsets.js +7 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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: {
|