breakpoint-mcp 1.24.0 → 1.26.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/cli/tools.js CHANGED
@@ -28,26 +28,7 @@ import { applyOutputSchemas } from "../schemas.js";
28
28
  import { applyAnnotations, annotationsFor } from "../annotations.js";
29
29
  import { TOOL_CAPABILITIES, CAPABILITY_GROUPS, GROUP_DESCRIBE } from "../capabilities.js";
30
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
- }
31
+ import { packageVersion } from "../version.js";
51
32
  /**
52
33
  * Register the whole surface against a recorder, exactly as `index.ts` does
53
34
  * (`applyOutputSchemas` → `applyAnnotations` → every `register*Tools`), and
package/dist/cslsp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { LspError } from "./lsp.js";
3
+ import { packageVersion } from "./version.js";
3
4
  /**
4
5
  * Minimal LSP client for the C#/.NET semantic plane (D4 C2). Transport-agnostic:
5
6
  * it drives any `JsonRpcChannel`, so production spawns OmniSharp over stdio
@@ -164,7 +165,7 @@ export class CsLspClient {
164
165
  workspace: { symbol: {}, workspaceFolders: true },
165
166
  },
166
167
  workspaceFolders: [{ uri: this.rootUri, name: "csharp-project" }],
167
- clientInfo: { name: "breakpoint-mcp", version: "0.2.0" },
168
+ clientInfo: { name: "breakpoint-mcp", version: packageVersion() },
168
169
  }));
169
170
  this.serverCapabilities = result?.capabilities ?? {};
170
171
  await this.notify("initialized", {});
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.24.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
39
+ const server = new McpServer({ name: "breakpoint-mcp", version: "1.26.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/lsp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { FramedConnection } from "./framing.js";
3
+ import { packageVersion } from "./version.js";
3
4
  export class LspError extends Error {
4
5
  code;
5
6
  constructor(code, message) {
@@ -165,7 +166,7 @@ export class LspClient {
165
166
  workspace: { symbol: {}, workspaceFolders: true },
166
167
  },
167
168
  workspaceFolders: [{ uri: this.rootUri, name: "godot-project" }],
168
- clientInfo: { name: "breakpoint-mcp", version: "0.2.0" },
169
+ clientInfo: { name: "breakpoint-mcp", version: packageVersion() },
169
170
  }));
170
171
  this.serverCapabilities = result?.capabilities ?? {};
171
172
  await this.notify("initialized", {});
package/dist/ports.js CHANGED
@@ -31,15 +31,16 @@ export function portFree(host, port) {
31
31
  });
32
32
  }
33
33
  /**
34
- * The refusal text shared by `godot_run_managed` and `godot_run_project`.
34
+ * The refusal text shared by every tool that starts a game.
35
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.
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.
43
44
  *
44
45
  * That is why this refuses rather than warns: a determinism feature returning a
45
46
  * correct-looking answer from the wrong game is worse than one that will not
@@ -52,11 +53,44 @@ export function portFree(host, port) {
52
53
  * several games at once, which is true; `runtime_peer_stop` as a way to free
53
54
  * THIS port would not be, and a remedy that cannot work is worse than one fewer
54
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.
55
75
  */
56
- export function portConflictMessage(host, port) {
57
- return (`${host}:${port} is already bound, so a game started now could not host the runtime bridge. ` +
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. ` +
58
78
  `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. ` +
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 +
60
94
  `Stop the running game first — godot_stop if godot_run_managed started it, otherwise quit it in ` +
61
95
  `the game window or end the debug session that launched it (a detached godot_run_project game is ` +
62
96
  `not stoppable by any tool) — or point this server at a free port with ` +
@@ -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: program ?? cfg.csDapProgram,
62
- args: args ?? ["--path", cfg.csDapProjectPath],
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`, " +
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
4
+ /**
5
+ * The package version, read at runtime rather than hardcoded.
6
+ *
7
+ * Every literal version string in the source is a place a release can forget,
8
+ * and `contract_check.py` check 14 exists because two consecutive releases did.
9
+ * The best outcome is not a gated literal but no literal at all — so anything
10
+ * that merely needs to *report* the version should call this instead of being
11
+ * added to the roster.
12
+ *
13
+ * This was not academic. `lsp.ts` and `cslsp.ts` announced
14
+ * `clientInfo: { version: "0.2.0" }` to Godot's language server and to
15
+ * OmniSharp from the initial commit until 1.26.0 — the project shipped
16
+ * twenty-odd releases telling every LSP server it was 0.2.0, and nothing
17
+ * noticed, because a literal nobody compares to anything cannot go stale
18
+ * loudly.
19
+ *
20
+ * `package.json` ships in the npm tarball, so the read is safe in an installed
21
+ * package; if it ever isn't, an unknown version is better than a stale one.
22
+ */
23
+ export function packageVersion() {
24
+ try {
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version ?? "unknown";
27
+ }
28
+ catch {
29
+ return "unknown";
30
+ }
31
+ }
32
+ //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.24.0",
3
+ "version": "1.26.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",