breakpoint-mcp 1.23.0 → 1.24.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/dist/config.js +29 -4
- package/dist/index.js +1 -1
- package/dist/peers.js +5 -18
- package/dist/ports.js +68 -0
- package/dist/recipes.js +3 -3
- package/dist/tools/cli.js +15 -2
- package/dist/tools/processes.js +18 -3
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import { pathToFileURL } from "node:url";
|
|
2
2
|
import { parsePrivilegedGroups } from "./capabilities.js";
|
|
3
|
+
/**
|
|
4
|
+
* A TCP port from the environment, falling back to `fallback` when the variable
|
|
5
|
+
* is absent, empty, non-numeric or out of range.
|
|
6
|
+
*
|
|
7
|
+
* `?? "9081"` alone does not cover this: an env var set to `""` or `"nope"`
|
|
8
|
+
* reaches `Number.parseInt` and yields `NaN`, which then propagates into every
|
|
9
|
+
* dial and bind as a port nothing can use. The addon has guarded this since it
|
|
10
|
+
* shipped — `runtime_bridge.gd:75` requires `is_valid_int()` before overriding
|
|
11
|
+
* and otherwise keeps the default — and the host simply never matched it. The
|
|
12
|
+
* mismatch was harmless while a bad port merely failed to connect; it stopped
|
|
13
|
+
* being harmless once `godot_run_managed` began *refusing* on an unbindable
|
|
14
|
+
* port, which would have read as "127.0.0.1:NaN is already bound".
|
|
15
|
+
*/
|
|
16
|
+
function port(raw, fallback) {
|
|
17
|
+
// Deliberately NOT Number.parseInt on its own: it stops at the first
|
|
18
|
+
// non-digit, so "80a80" parses to 80. The host would then dial port 80 while
|
|
19
|
+
// the addon — whose `is_valid_int()` rejects the whole string — bound the
|
|
20
|
+
// default, which is precisely the host/addon disagreement this guard exists
|
|
21
|
+
// to prevent. The pattern mirrors GDScript's `is_valid_int()`.
|
|
22
|
+
const t = (raw ?? "").trim();
|
|
23
|
+
if (!/^[+-]?\d+$/.test(t))
|
|
24
|
+
return fallback;
|
|
25
|
+
const n = Number(t);
|
|
26
|
+
return Number.isInteger(n) && n >= 0 && n <= 65535 ? n : fallback;
|
|
27
|
+
}
|
|
3
28
|
export function loadConfig() {
|
|
4
29
|
const projectPath = process.env.GODOT_PROJECT ?? process.cwd();
|
|
5
30
|
// The C# project defaults to the main project, but is usually pointed at a
|
|
@@ -10,10 +35,10 @@ export function loadConfig() {
|
|
|
10
35
|
projectPath,
|
|
11
36
|
projectUri: pathToFileURL(projectPath).href,
|
|
12
37
|
bridgeHost: process.env.BREAKPOINT_BRIDGE_HOST ?? "127.0.0.1",
|
|
13
|
-
bridgePort:
|
|
38
|
+
bridgePort: port(process.env.BREAKPOINT_BRIDGE_PORT, 9080),
|
|
14
39
|
bridgeTimeoutMs: Number.parseInt(process.env.BREAKPOINT_BRIDGE_TIMEOUT_MS ?? "15000", 10),
|
|
15
40
|
lspHost: process.env.GODOT_LSP_HOST ?? "127.0.0.1",
|
|
16
|
-
lspPort:
|
|
41
|
+
lspPort: port(process.env.GODOT_LSP_PORT, 6005),
|
|
17
42
|
lspTimeoutMs: Number.parseInt(process.env.GODOT_LSP_TIMEOUT_MS ?? "15000", 10),
|
|
18
43
|
csLspCmd: process.env.GODOT_CSLSP_CMD ?? "OmniSharp",
|
|
19
44
|
csLspArgs: (process.env.GODOT_CSLSP_ARGS ?? "-lsp").split(/\s+/).filter(Boolean),
|
|
@@ -21,7 +46,7 @@ export function loadConfig() {
|
|
|
21
46
|
csLspProjectUri: pathToFileURL(csLspProjectPath).href,
|
|
22
47
|
csLspTimeoutMs: Number.parseInt(process.env.GODOT_CSLSP_TIMEOUT_MS ?? "30000", 10),
|
|
23
48
|
dapHost: process.env.GODOT_DAP_HOST ?? "127.0.0.1",
|
|
24
|
-
dapPort:
|
|
49
|
+
dapPort: port(process.env.GODOT_DAP_PORT, 6006),
|
|
25
50
|
dapTimeoutMs: Number.parseInt(process.env.GODOT_DAP_TIMEOUT_MS ?? "20000", 10),
|
|
26
51
|
dapSetVarTimeoutMs: Number.parseInt(process.env.GODOT_DAP_SETVAR_TIMEOUT_MS ?? "8000", 10),
|
|
27
52
|
dapEvaluateTimeoutMs: Number.parseInt(process.env.GODOT_DAP_EVALUATE_TIMEOUT_MS ?? "8000", 10),
|
|
@@ -36,7 +61,7 @@ export function loadConfig() {
|
|
|
36
61
|
csDapSetVarTimeoutMs: Number.parseInt(process.env.GODOT_CSDAP_SETVAR_TIMEOUT_MS ?? "8000", 10),
|
|
37
62
|
csDapEvaluateTimeoutMs: Number.parseInt(process.env.GODOT_CSDAP_EVALUATE_TIMEOUT_MS ?? "8000", 10),
|
|
38
63
|
runtimeHost: process.env.BREAKPOINT_RUNTIME_HOST ?? "127.0.0.1",
|
|
39
|
-
runtimePort:
|
|
64
|
+
runtimePort: port(process.env.BREAKPOINT_RUNTIME_PORT, 9081),
|
|
40
65
|
runtimeTimeoutMs: Number.parseInt(process.env.BREAKPOINT_RUNTIME_TIMEOUT_MS ?? "15000", 10),
|
|
41
66
|
// Group J: asset generation is OFF by default (backend "none" → tools degrade).
|
|
42
67
|
assetGenBackend: process.env.BREAKPOINT_ASSETGEN_BACKEND ?? "none",
|
package/dist/index.js
CHANGED
|
@@ -36,7 +36,7 @@ async function main() {
|
|
|
36
36
|
// so long jobs (export/import/headless script) support poll/await/cancel.
|
|
37
37
|
// D3: also advertise resources.subscribe so clients can subscribe to
|
|
38
38
|
// godot://… resources and receive notifications/resources/updated.
|
|
39
|
-
const server = new McpServer({ name: "breakpoint-mcp", version: "1.
|
|
39
|
+
const server = new McpServer({ name: "breakpoint-mcp", version: "1.24.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
|
|
40
40
|
// B1: enforce frozen output schemas on every structured tool. Must run before
|
|
41
41
|
// any register*Tools call — it wraps server.registerTool.
|
|
42
42
|
applyOutputSchemas(server);
|
package/dist/peers.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import net from "node:net";
|
|
2
1
|
import { BridgeClient, BridgeError } from "./bridge.js";
|
|
3
2
|
import { ProcessRegistry } from "./tools/processes.js";
|
|
4
3
|
import { ensureProjectSecret, readProjectSecret } from "./secret.js";
|
|
5
4
|
import { log } from "./logger.js";
|
|
5
|
+
import { portFree } from "./ports.js";
|
|
6
6
|
/**
|
|
7
7
|
* Hard ceiling on simultaneously live peers.
|
|
8
8
|
*
|
|
@@ -15,20 +15,6 @@ import { log } from "./logger.js";
|
|
|
15
15
|
export const MAX_PEERS = 4;
|
|
16
16
|
/** How far above the default runtime port the allocator will scan. */
|
|
17
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
18
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
33
19
|
export class PeerRegistry {
|
|
34
20
|
cfg;
|
|
@@ -83,12 +69,13 @@ export class PeerRegistry {
|
|
|
83
69
|
* runtime port so a peer never collides with a developer's already-running
|
|
84
70
|
* game. Ports held by live peers are skipped even if the probe would pass.
|
|
85
71
|
*
|
|
86
|
-
*
|
|
87
|
-
* take the port between the probe and the child's bind. Allocation is
|
|
72
|
+
* The probe is TOCTOU by construction (see `ports.ts`), but allocation is
|
|
88
73
|
* sequential and the window is sub-millisecond, and the failure mode is
|
|
89
74
|
* benign — the child logs "could not listen" and the readiness wait below
|
|
90
75
|
* reports it with that line attached, rather than the peer silently
|
|
91
|
-
* half-existing.
|
|
76
|
+
* half-existing. The DEFAULT runtime port has no such readiness wait, which
|
|
77
|
+
* is why `godot_run_managed` and `godot_run_project` refuse a held port
|
|
78
|
+
* outright rather than reporting it after the fact.
|
|
92
79
|
*/
|
|
93
80
|
async allocatePorts(count) {
|
|
94
81
|
const taken = new Set([this.cfg.runtimePort, ...this.live().map((p) => p.port)]);
|
package/dist/ports.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
/**
|
|
3
|
+
* Loopback port probing, shared by the two callers that must not collide.
|
|
4
|
+
*
|
|
5
|
+
* It lives in its own module because `peers.ts` imports `ProcessRegistry` from
|
|
6
|
+
* `tools/processes.ts`, so `processes.ts` importing the probe back out of
|
|
7
|
+
* `peers.ts` would close an import cycle. Nothing here imports anything local.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Is `port` bindable on `host` right now?
|
|
11
|
+
*
|
|
12
|
+
* Probe-then-release is a TOCTOU window by construction: another process could
|
|
13
|
+
* take the port between this answer and whoever acts on it. Every caller here
|
|
14
|
+
* treats a `true` as "nothing is holding it as of now", never as a lease.
|
|
15
|
+
*
|
|
16
|
+
* SO_REUSEADDR (which Node sets by default) does NOT let two live listeners
|
|
17
|
+
* share an address on Linux or macOS — that needs SO_REUSEPORT, which Node does
|
|
18
|
+
* not set — so a bound port answers `false` rather than being silently joinable.
|
|
19
|
+
*/
|
|
20
|
+
export function portFree(host, port) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const srv = net.createServer();
|
|
23
|
+
srv.once("error", () => resolve(false));
|
|
24
|
+
srv.once("listening", () => srv.close(() => resolve(true)));
|
|
25
|
+
try {
|
|
26
|
+
srv.listen(port, host);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
resolve(false);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The refusal text shared by `godot_run_managed` and `godot_run_project`.
|
|
35
|
+
*
|
|
36
|
+
* Both tools start a game whose runtime autoload binds `BREAKPOINT_RUNTIME_PORT`
|
|
37
|
+
* (`runtime_bridge.gd:74`). When that port is already held, the autoload's
|
|
38
|
+
* `listen()` returns non-OK, it `push_error`s, and **the game keeps running
|
|
39
|
+
* without a bridge** — while the host's runtime client, which dials the same
|
|
40
|
+
* fixed port, connects to whichever process got there first. Every subsequent
|
|
41
|
+
* `runtime_*` call then answers confidently about the WRONG process, and `ping`
|
|
42
|
+
* carries no pid or boot nonce that could tell them apart.
|
|
43
|
+
*
|
|
44
|
+
* That is why this refuses rather than warns: a determinism feature returning a
|
|
45
|
+
* correct-looking answer from the wrong game is worse than one that will not
|
|
46
|
+
* start.
|
|
47
|
+
*
|
|
48
|
+
* Note what is deliberately NOT offered as a remedy: stopping peers. A peer can
|
|
49
|
+
* never hold this port — `allocatePorts` seeds its `taken` set with
|
|
50
|
+
* `cfg.runtimePort` and scans from `runtimePort + 1`, so whatever holds it is
|
|
51
|
+
* something else. `runtime_spawn_peers` is named below as the way to drive
|
|
52
|
+
* several games at once, which is true; `runtime_peer_stop` as a way to free
|
|
53
|
+
* THIS port would not be, and a remedy that cannot work is worse than one fewer
|
|
54
|
+
* suggestion.
|
|
55
|
+
*/
|
|
56
|
+
export function portConflictMessage(host, port) {
|
|
57
|
+
return (`${host}:${port} is already bound, so a game started now could not host the runtime bridge. ` +
|
|
58
|
+
`Its autoload would fail to listen and keep running anyway, and every runtime_* call would ` +
|
|
59
|
+
`silently address the process that already holds the port instead of the one you just started. ` +
|
|
60
|
+
`Stop the running game first — godot_stop if godot_run_managed started it, otherwise quit it in ` +
|
|
61
|
+
`the game window or end the debug session that launched it (a detached godot_run_project game is ` +
|
|
62
|
+
`not stoppable by any tool) — or point this server at a free port with ` +
|
|
63
|
+
`BREAKPOINT_RUNTIME_PORT. Pass allow_port_conflict:true to start it anyway — reasonable only if ` +
|
|
64
|
+
`you want the process for its console output or its side effects and will not use any runtime_* ` +
|
|
65
|
+
`tool against it. To drive more than one game at once, use runtime_spawn_peers, which allocates ` +
|
|
66
|
+
`a distinct port per peer.`);
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=ports.js.map
|
package/dist/recipes.js
CHANGED
|
@@ -114,8 +114,8 @@ export function registerRecipes(server) {
|
|
|
114
114
|
return recipe("Golden-image regression check for a scene.", [
|
|
115
115
|
`Goal: lock a scene's look to a golden image at ${ref} and catch visual drift.`,
|
|
116
116
|
"",
|
|
117
|
-
`1. Establish the golden (first run only):
|
|
118
|
-
`2. Regression run (every time after):
|
|
117
|
+
`1. Establish the golden (first run only): godot_run_managed, drive the game to the state under test (runtime_inject_input / runtime_call_method), then runtime_screenshot and save the frame to ${ref}. Commit it (vcs_add) so it's the reference, then godot_stop — this recipe runs the game more than once, and a game left running holds the runtime bridge port, so the next run is refused.`,
|
|
118
|
+
`2. Regression run (every time after): godot_run_managed, reproduce the same state, then runtime_screenshot_diff against ${ref} with tolerance ${tol} (pass a region to ignore volatile HUD areas). ok:true means no meaningful drift. godot_stop when the diff is in hand.`,
|
|
119
119
|
"3. On failure: the diff returns diff_ratio + differing_pixels — don't just eyeball it. runtime_get_tree / runtime_assert_node_state to find the node that changed, or dbg_* to break where the offending value is set. Update the golden deliberately (re-do step 1) only once you've confirmed the change is intended.",
|
|
120
120
|
"",
|
|
121
121
|
"The whole diff runs engine-side (Image), so there's no OCR/image dependency and it stays read-only and ungated.",
|
|
@@ -138,7 +138,7 @@ export function registerRecipes(server) {
|
|
|
138
138
|
`1. Understand: gd_document_symbols for the file's shape; gd_definition / gd_references before touching a symbol so you know every call site; find_usages across the project for wider blast radius.`,
|
|
139
139
|
"2. Edit with help: while writing, use gd_completion and gd_hover to get exact API names and signatures (this is where a generic 'call any method' tool hallucinates — the LSP gives you the truth). gd_rename to rename safely across the project.",
|
|
140
140
|
"3. Gate: run gd_diagnostics and do not proceed until it's clean. gd_code_action for quick-fixes it suggests.",
|
|
141
|
-
"4. Prove behavior:
|
|
141
|
+
"4. Prove behavior: godot_run_managed and a runtime_assert_* that the change did what you intended, then godot_stop — leaving it running holds the runtime bridge port and the next run of this recipe is refused. If the assert didn't pass, dbg_* into it.",
|
|
142
142
|
"",
|
|
143
143
|
"Every mutation is on the undo stack — editor_undo reverts a bad edit instantly.",
|
|
144
144
|
"",
|
package/dist/tools/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { log } from "../logger.js";
|
|
5
5
|
import { registerTaskTool } from "../tasks.js";
|
|
6
6
|
import { ok } from "./lsp-common.js";
|
|
7
|
+
import { portFree, portConflictMessage } from "../ports.js";
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
/** Run the Godot binary to completion, capturing stdout/stderr. */
|
|
9
10
|
async function runCaptured(cfg, args, timeoutMs, signal) {
|
|
@@ -62,11 +63,23 @@ export function registerCliTools(server, cfg) {
|
|
|
62
63
|
});
|
|
63
64
|
server.registerTool("godot_run_project", {
|
|
64
65
|
title: "Run project",
|
|
65
|
-
description: "Run the project (detached). Optionally start from a specific scene path (res://...). Returns the process id."
|
|
66
|
+
description: "Run the project (detached). Optionally start from a specific scene path (res://...). Returns the process id. " +
|
|
67
|
+
"Refuses if the runtime bridge port is already bound — the new game could not host the bridge, and every " +
|
|
68
|
+
"runtime_* call would address the process already holding the port. Use runtime_spawn_peers to drive more " +
|
|
69
|
+
"than one game at once.",
|
|
66
70
|
inputSchema: {
|
|
67
71
|
scene: z.string().optional().describe("Optional scene to run, e.g. res://levels/test.tscn"),
|
|
72
|
+
allow_port_conflict: z
|
|
73
|
+
.boolean()
|
|
74
|
+
.optional()
|
|
75
|
+
.describe("Start even though the runtime bridge port is already bound (default false). The new game's runtime " +
|
|
76
|
+
"bridge will NOT be reachable — use only when you want the process for its side effects and will not " +
|
|
77
|
+
"call any runtime_* tool against it."),
|
|
68
78
|
},
|
|
69
|
-
}, async ({ scene }) => {
|
|
79
|
+
}, async ({ scene, allow_port_conflict }) => {
|
|
80
|
+
if (!allow_port_conflict && !(await portFree(cfg.runtimeHost, cfg.runtimePort))) {
|
|
81
|
+
return { isError: true, content: [{ type: "text", text: portConflictMessage(cfg.runtimeHost, cfg.runtimePort) }] };
|
|
82
|
+
}
|
|
70
83
|
const args = ["--path", cfg.projectPath];
|
|
71
84
|
if (scene)
|
|
72
85
|
args.push(scene);
|
package/dist/tools/processes.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { log } from "../logger.js";
|
|
4
4
|
import { ok } from "./lsp-common.js";
|
|
5
|
+
import { portFree, portConflictMessage } from "../ports.js";
|
|
5
6
|
const LINE_CAP = 5000;
|
|
6
7
|
/**
|
|
7
8
|
* Runs Godot as a MANAGED child (piped stdio) so the host captures ALL stdout/
|
|
@@ -83,9 +84,23 @@ export function registerProcessTools(server, cfg) {
|
|
|
83
84
|
server.registerTool("godot_run_managed", {
|
|
84
85
|
title: "Run project (managed, captured output)",
|
|
85
86
|
description: "Run the project as a managed child process with captured stdout/stderr, so godot_output can read ALL print()/error output. " +
|
|
86
|
-
"Returns a process id. Use this instead of godot_run_project when you want the game's console log."
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
"Returns a process id. Use this instead of godot_run_project when you want the game's console log. " +
|
|
88
|
+
"Refuses if the runtime bridge port is already bound — the new game could not host the bridge, and every " +
|
|
89
|
+
"runtime_* call would address the process already holding the port. Use runtime_spawn_peers to drive more " +
|
|
90
|
+
"than one game at once.",
|
|
91
|
+
inputSchema: {
|
|
92
|
+
scene: z.string().optional().describe("Optional res:// scene to run"),
|
|
93
|
+
allow_port_conflict: z
|
|
94
|
+
.boolean()
|
|
95
|
+
.optional()
|
|
96
|
+
.describe("Start even though the runtime bridge port is already bound (default false). The new game's runtime " +
|
|
97
|
+
"bridge will NOT be reachable — use only when you want the process for its console output or side " +
|
|
98
|
+
"effects and will not call any runtime_* tool against it."),
|
|
99
|
+
},
|
|
100
|
+
}, async ({ scene, allow_port_conflict }) => {
|
|
101
|
+
if (!allow_port_conflict && !(await portFree(cfg.runtimeHost, cfg.runtimePort))) {
|
|
102
|
+
return { isError: true, content: [{ type: "text", text: portConflictMessage(cfg.runtimeHost, cfg.runtimePort) }] };
|
|
103
|
+
}
|
|
89
104
|
const m = registry.run(cfg, scene ? [scene] : []);
|
|
90
105
|
return ok({ id: m.id, pid: m.child.pid ?? null, running: true, scene: scene ?? null });
|
|
91
106
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakpoint-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"description": "MCP server exposing Godot to AI coding assistants over the Model Context Protocol — developed and tested with Claude — across four planes (CLI, live editor, LSP+DAP, runtime) with elicitation-gated destructive tools, long jobs on the formal MCP task model, captured console output, and MCP resources.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|