breakpoint-mcp 1.23.0 → 1.25.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 +102 -0
- package/dist/recipes.js +3 -3
- package/dist/tools/cli.js +15 -2
- package/dist/tools/csdap.js +37 -3
- package/dist/tools/dap.js +30 -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.25.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,102 @@
|
|
|
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 every tool that starts a game.
|
|
35
|
+
*
|
|
36
|
+
* All of them launch a project whose runtime autoload binds
|
|
37
|
+
* `BREAKPOINT_RUNTIME_PORT` (`runtime_bridge.gd:74` reads it, `:79` binds it).
|
|
38
|
+
* When that port is already held, the autoload's `listen()` returns non-OK, it
|
|
39
|
+
* `push_error`s, and **the game keeps running without a bridge** — while the
|
|
40
|
+
* host's runtime client, which dials the same fixed port, connects to whichever
|
|
41
|
+
* process got there first. Every subsequent `runtime_*` call then answers
|
|
42
|
+
* confidently about the WRONG process, and `ping` carries no pid or boot nonce
|
|
43
|
+
* that could tell them apart.
|
|
44
|
+
*
|
|
45
|
+
* That is why this refuses rather than warns: a determinism feature returning a
|
|
46
|
+
* correct-looking answer from the wrong game is worse than one that will not
|
|
47
|
+
* start.
|
|
48
|
+
*
|
|
49
|
+
* Note what is deliberately NOT offered as a remedy: stopping peers. A peer can
|
|
50
|
+
* never hold this port — `allocatePorts` seeds its `taken` set with
|
|
51
|
+
* `cfg.runtimePort` and scans from `runtimePort + 1`, so whatever holds it is
|
|
52
|
+
* something else. `runtime_spawn_peers` is named below as the way to drive
|
|
53
|
+
* several games at once, which is true; `runtime_peer_stop` as a way to free
|
|
54
|
+
* THIS port would not be, and a remedy that cannot work is worse than one fewer
|
|
55
|
+
* suggestion.
|
|
56
|
+
*
|
|
57
|
+
* The `debugger` variant differs in one way that matters. A DAP session is
|
|
58
|
+
* addressed by SESSION, not by port, so `dbg_*` keeps working perfectly against
|
|
59
|
+
* a second game even with the port held — only `runtime_*` is corrupted. The
|
|
60
|
+
* override is therefore a legitimate everyday choice on that plane rather than a
|
|
61
|
+
* last resort, and the text says so instead of over-warning. Over-warning is how
|
|
62
|
+
* a check earns the reputation that gets it disabled.
|
|
63
|
+
*
|
|
64
|
+
* What NEITHER variant may do is claim to know WHAT is holding the port. All the
|
|
65
|
+
* probe learns is that something is. The holder may be a `godot_run_managed`
|
|
66
|
+
* child (`godot_stop` clears it), a game already under the debugger (`dbg_attach`
|
|
67
|
+
* reaches it), or a window the developer opened themselves (only they can close
|
|
68
|
+
* it) — and the two planes contend for the SAME port, so a debugger refusal is
|
|
69
|
+
* often about a run-plane holder and vice versa. An earlier draft of the
|
|
70
|
+
* debugger text asserted "a debugger-launched game is owned by the editor, so no
|
|
71
|
+
* tool here can stop it" and withheld `godot_stop` on that basis; it was wrong
|
|
72
|
+
* whenever the holder was a managed child, which is the commonest case of all.
|
|
73
|
+
* Both variants now list every remedy with the condition under which it applies,
|
|
74
|
+
* and assert nothing about which one is live.
|
|
75
|
+
*/
|
|
76
|
+
export function portConflictMessage(host, port, launcher = "run") {
|
|
77
|
+
const why = `${host}:${port} is already bound, so a game started now could not host the runtime bridge. ` +
|
|
78
|
+
`Its autoload would fail to listen and keep running anyway, and every runtime_* call would ` +
|
|
79
|
+
`silently address the process that already holds the port instead of the one you just started. `;
|
|
80
|
+
if (launcher === "debugger") {
|
|
81
|
+
return (why +
|
|
82
|
+
`Deal with whatever is already holding it — which remedy applies depends on what it is: ` +
|
|
83
|
+
`godot_stop if godot_run_managed started it, dbg_attach / cs_dbg_attach to debug the running ` +
|
|
84
|
+
`game instead of launching a second one (that works only if it is already under the ` +
|
|
85
|
+
`debugger — a plain godot_run_project or godot_run_managed game is not), or quit it in the ` +
|
|
86
|
+
`game window or the editor. You can also point this server at a free port with ` +
|
|
87
|
+
`BREAKPOINT_RUNTIME_PORT. Pass allow_port_conflict:true to launch anyway: breakpoints, ` +
|
|
88
|
+
`stepping and variable inspection will all work normally, because a DAP session is addressed ` +
|
|
89
|
+
`by session rather than by port — but every runtime_* call would go to the process holding ` +
|
|
90
|
+
`the port, not to the game you just launched. To drive more than one game at once, use ` +
|
|
91
|
+
`runtime_spawn_peers, which allocates a distinct port per peer.`);
|
|
92
|
+
}
|
|
93
|
+
return (why +
|
|
94
|
+
`Stop the running game first — godot_stop if godot_run_managed started it, otherwise quit it in ` +
|
|
95
|
+
`the game window or end the debug session that launched it (a detached godot_run_project game is ` +
|
|
96
|
+
`not stoppable by any tool) — or point this server at a free port with ` +
|
|
97
|
+
`BREAKPOINT_RUNTIME_PORT. Pass allow_port_conflict:true to start it anyway — reasonable only if ` +
|
|
98
|
+
`you want the process for its console output or its side effects and will not use any runtime_* ` +
|
|
99
|
+
`tool against it. To drive more than one game at once, use runtime_spawn_peers, which allocates ` +
|
|
100
|
+
`a distinct port per peer.`);
|
|
101
|
+
}
|
|
102
|
+
//# 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/csdap.js
CHANGED
|
@@ -3,6 +3,7 @@ import { DapError } from "../dap.js";
|
|
|
3
3
|
import { toFsPath } from "../paths.js";
|
|
4
4
|
import { gate } from "../confirm.js";
|
|
5
5
|
import { ok } from "./lsp-common.js";
|
|
6
|
+
import { portFree, portConflictMessage } from "../ports.js";
|
|
6
7
|
// How long step/continue wait for the program to settle (hit a breakpoint,
|
|
7
8
|
// finish a step, or terminate) before returning. On timeout the tool reports
|
|
8
9
|
// the current state — e.g. `continue` with no further breakpoint stays running.
|
|
@@ -54,12 +55,41 @@ export function registerCsDapTools(server, dap, cfg) {
|
|
|
54
55
|
args: z.array(z.string()).optional().describe("Program arguments (default: ['--path', <C# project>])"),
|
|
55
56
|
stop_on_entry: z.boolean().optional().describe("Break at entry (default false)"),
|
|
56
57
|
just_my_code: z.boolean().optional().describe("Restrict stepping/breakpoints to user code (netcoredbg justMyCode; default true)"),
|
|
58
|
+
allow_port_conflict: z
|
|
59
|
+
.boolean()
|
|
60
|
+
.optional()
|
|
61
|
+
.describe("Launch even though the runtime bridge port is already bound (default false). Only consulted when " +
|
|
62
|
+
"this looks like a Godot launch — the program is named godot, or the args carry Godot's --path " +
|
|
63
|
+
"project flag. Debugging some other .NET program is never gated. Breakpoints and stepping still " +
|
|
64
|
+
"work; runtime_* tools would talk to the process holding the port."),
|
|
57
65
|
},
|
|
58
|
-
}, async ({ program, args, stop_on_entry, just_my_code }) => {
|
|
66
|
+
}, async ({ program, args, stop_on_entry, just_my_code, allow_port_conflict }) => {
|
|
67
|
+
const resolvedProgram = program ?? cfg.csDapProgram;
|
|
68
|
+
const resolvedArgs = args ?? ["--path", cfg.csDapProjectPath];
|
|
69
|
+
// Does this launch a Godot game — i.e. one whose autoload wants the runtime
|
|
70
|
+
// port — or some other .NET program netcoredbg is being pointed at?
|
|
71
|
+
//
|
|
72
|
+
// DEFAULT TO YES, and skip the gate only when the caller has clearly aimed
|
|
73
|
+
// elsewhere. Comparing the resolved program against `cfg.csDapProgram` was
|
|
74
|
+
// the obvious-looking test and is wrong: that is equality against the
|
|
75
|
+
// DEFAULT, so passing the real Mono binary explicitly — the way config.ts
|
|
76
|
+
// documents pointing at it — skipped the gate on the mainline path this
|
|
77
|
+
// whole change exists to cover. `--path <project>` is Godot's own project
|
|
78
|
+
// flag and appears in the default args; a program not called godot and not
|
|
79
|
+
// given --path is the one case we are confident is not a game.
|
|
80
|
+
const looksLikeGodot = /godot/i.test(resolvedProgram.split(/[\\/]/).pop() ?? "") || resolvedArgs.includes("--path");
|
|
81
|
+
if (looksLikeGodot &&
|
|
82
|
+
!allow_port_conflict &&
|
|
83
|
+
!(await portFree(cfg.runtimeHost, cfg.runtimePort))) {
|
|
84
|
+
return {
|
|
85
|
+
isError: true,
|
|
86
|
+
content: [{ type: "text", text: portConflictMessage(cfg.runtimeHost, cfg.runtimePort, "debugger") }],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
59
89
|
try {
|
|
60
90
|
await dap.start("launch", {
|
|
61
|
-
program:
|
|
62
|
-
args:
|
|
91
|
+
program: resolvedProgram,
|
|
92
|
+
args: resolvedArgs,
|
|
63
93
|
cwd: cfg.csDapProjectPath,
|
|
64
94
|
stopAtEntry: stop_on_entry ?? false,
|
|
65
95
|
justMyCode: just_my_code ?? true,
|
|
@@ -70,6 +100,8 @@ export function registerCsDapTools(server, dap, cfg) {
|
|
|
70
100
|
return fail(err);
|
|
71
101
|
}
|
|
72
102
|
});
|
|
103
|
+
// NOT port-gated, deliberately — see dbg_attach: attaching to the process that
|
|
104
|
+
// already holds the port is the remedy, not the problem.
|
|
73
105
|
server.registerTool("cs_dbg_attach", {
|
|
74
106
|
title: "Attach C# debug session",
|
|
75
107
|
description: "Attach netcoredbg to an already-running .NET process (e.g. a C# Godot game launched separately) by its OS process id. " +
|
|
@@ -345,6 +377,8 @@ export function registerCsDapTools(server, dap, cfg) {
|
|
|
345
377
|
return fail(err);
|
|
346
378
|
}
|
|
347
379
|
});
|
|
380
|
+
// NOT port-gated, deliberately — see dbg_restart: the session's own game still
|
|
381
|
+
// holds the port at check time, so a probe here false-positives every restart.
|
|
348
382
|
server.registerTool("cs_dbg_restart", {
|
|
349
383
|
title: "Restart C# debug session",
|
|
350
384
|
description: "Restart the current C# debug session. Uses the DAP `restart` request when the adapter advertises `supportsRestartRequest`, " +
|
package/dist/tools/dap.js
CHANGED
|
@@ -3,6 +3,7 @@ import { DapError } from "../dap.js";
|
|
|
3
3
|
import { toFsPath } from "../paths.js";
|
|
4
4
|
import { gate } from "../confirm.js";
|
|
5
5
|
import { ok } from "./lsp-common.js";
|
|
6
|
+
import { portFree, portConflictMessage } from "../ports.js";
|
|
6
7
|
// How long step/continue wait for the program to settle (hit a breakpoint,
|
|
7
8
|
// finish a step, or terminate) before returning. On timeout the tool reports
|
|
8
9
|
// the current state — e.g. `continue` with no further breakpoint stays running.
|
|
@@ -55,12 +56,28 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
55
56
|
server.registerTool("dbg_launch", {
|
|
56
57
|
title: "Launch debug session",
|
|
57
58
|
description: "Start the game under the debugger. scene may be 'main', 'current', or a res:// scene path. " +
|
|
58
|
-
"Any breakpoints set beforehand are applied during the handshake."
|
|
59
|
+
"Any breakpoints set beforehand are applied during the handshake. " +
|
|
60
|
+
"Refuses if the runtime bridge port is already bound — the new game could not host the bridge, so " +
|
|
61
|
+
"runtime_* would address the process already holding the port. Clear the holder (godot_stop, or quit " +
|
|
62
|
+
"it), or dbg_attach onto it if it is already under the debugger, or pass allow_port_conflict " +
|
|
63
|
+
"(dbg_* is unaffected either way; only runtime_* is).",
|
|
59
64
|
inputSchema: {
|
|
60
65
|
scene: z.string().optional().describe("'main' (default), 'current', or res://scene.tscn"),
|
|
61
66
|
stop_on_entry: z.boolean().optional().describe("Break at entry (default false)"),
|
|
67
|
+
allow_port_conflict: z
|
|
68
|
+
.boolean()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Launch even though the runtime bridge port is already bound (default false). Breakpoints and " +
|
|
71
|
+
"stepping still work — a DAP session is addressed by session, not by port — but runtime_* " +
|
|
72
|
+
"tools would talk to the process holding the port, not to this game."),
|
|
62
73
|
},
|
|
63
|
-
}, async ({ scene, stop_on_entry }) => {
|
|
74
|
+
}, async ({ scene, stop_on_entry, allow_port_conflict }) => {
|
|
75
|
+
if (!allow_port_conflict && !(await portFree(cfg.runtimeHost, cfg.runtimePort))) {
|
|
76
|
+
return {
|
|
77
|
+
isError: true,
|
|
78
|
+
content: [{ type: "text", text: portConflictMessage(cfg.runtimeHost, cfg.runtimePort, "debugger") }],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
64
81
|
try {
|
|
65
82
|
await dap.start("launch", {
|
|
66
83
|
project: cfg.projectPath,
|
|
@@ -73,6 +90,9 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
73
90
|
return fail(err);
|
|
74
91
|
}
|
|
75
92
|
});
|
|
93
|
+
// NOT port-gated, deliberately: attaching to the game that already holds the
|
|
94
|
+
// port is the REMEDY dbg_launch's refusal points at. Gating it would close the
|
|
95
|
+
// exit and leave the caller with nowhere to go.
|
|
76
96
|
server.registerTool("dbg_attach", {
|
|
77
97
|
title: "Attach debug session",
|
|
78
98
|
description: "Attach to an already-running Godot debug session.",
|
|
@@ -367,6 +387,14 @@ export function registerDapTools(server, dap, cfg) {
|
|
|
367
387
|
return fail(err);
|
|
368
388
|
}
|
|
369
389
|
});
|
|
390
|
+
// NOT port-gated, deliberately, and this one is a trap worth naming: at the
|
|
391
|
+
// moment of the check the session's OWN game still holds the runtime port and
|
|
392
|
+
// is about to be terminated. A probe here would fire on the very process it is
|
|
393
|
+
// about to replace — a guaranteed false positive on the happy path, every
|
|
394
|
+
// single time. The relaunch reuses the launch parameters, so if some THIRD
|
|
395
|
+
// process holds the port the fresh game lands bridgeless exactly as before;
|
|
396
|
+
// that residue is accepted knowingly rather than traded for a check that cries
|
|
397
|
+
// wolf on every restart.
|
|
370
398
|
server.registerTool("dbg_restart", {
|
|
371
399
|
title: "Restart debug session",
|
|
372
400
|
description: "Restart the current debug session. Uses the DAP `restart` request when the adapter advertises `supportsRestartRequest`, " +
|
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.25.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",
|