breakpoint-mcp 1.74.0 → 1.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The CLI's help text, in ONE place, split by subcommand.
3
+ *
4
+ * It lived in `index.ts`'s `printUsage` as a single flat array, which was fine
5
+ * while `--help` was the only thing that printed it. It stopped being fine the
6
+ * moment `breakpoint-mcp init --help` had to print the init section and nothing
7
+ * else: the alternative to this module is the same twelve lines written twice,
8
+ * and a help text that disagrees with itself is worse than one that is merely
9
+ * long. `index.ts` composes the whole document from these parts, so a flag
10
+ * documented for a subcommand is documented in the top-level help by
11
+ * construction.
12
+ *
13
+ * 🔴 THE ROSTERS BELOW ARE NOT DOCUMENTATION — THEY ARE THE PARSER'S INPUT.
14
+ * `parseArgs` takes them as `knownFlags` and reports anything else as unknown,
15
+ * so a flag added to a subcommand and not added here is refused at the command
16
+ * line rather than silently ignored. That is the whole point of keeping the
17
+ * prose and the roster in the same file: the pressure to update both is the
18
+ * same edit.
19
+ */
20
+ export const HEADLINE = "breakpoint-mcp — MCP server exposing Godot to AI coding assistants.";
21
+ export const SYNOPSIS = [
22
+ "Usage:",
23
+ " breakpoint-mcp Start the MCP server on stdio (default; how MCP clients launch it).",
24
+ " breakpoint-mcp init Install + enable the editor addon in a project and wire the MCP client.",
25
+ " breakpoint-mcp doctor Check the Godot binary, the editor addon, and the four bridges.",
26
+ " breakpoint-mcp tools Export the tool surface + risk annotations (for catalogs / reviews).",
27
+ " breakpoint-mcp --help Show this help.",
28
+ " breakpoint-mcp --version Print the installed version and exit.",
29
+ "",
30
+ "Every subcommand also accepts --help.",
31
+ ];
32
+ export const INIT_USAGE = [
33
+ "Usage: breakpoint-mcp init [options]",
34
+ "",
35
+ "Install + enable the editor addon in a Godot project and wire the MCP client.",
36
+ "",
37
+ "init options:",
38
+ " --project <dir> Target Godot project (default: $GODOT_PROJECT or the current directory).",
39
+ " --client <id> Write the MCP config for a client: claude-code | claude-desktop | cursor | windsurf | vscode.",
40
+ " --force Overwrite an addon that is already installed.",
41
+ " --dry-run Print what would change without writing anything.",
42
+ " --from-github [ref] Fetch the editor addon from GitHub at [ref] (default: this package's version tag) instead of the bundled copy.",
43
+ " --repo <owner/repo> With --from-github, the source repo (default: jlivingston-Cipher/godot-breakpoint-mcp).",
44
+ " --trust <level> secure | full. `full` enables every higher-trust group (default: secure).",
45
+ " --privileged-groups <a,b> Enable named higher-trust groups instead of all of them.",
46
+ ];
47
+ export const DOCTOR_USAGE = [
48
+ "Usage: breakpoint-mcp doctor [options]",
49
+ "",
50
+ "Check the Godot binary, the editor addon, and the four bridges.",
51
+ "",
52
+ "doctor options:",
53
+ " --project <dir> Project to check (default: $GODOT_PROJECT or the current directory).",
54
+ " --require-live[=L] Require bridges to be reachable rather than just report them.",
55
+ " Bare, it means the three OPENING THE EDITOR brings up: the editor",
56
+ " bridge, the GDScript LSP and the DAP. L is editor | runtime | all —",
57
+ " the runtime bridge lives inside the running game, so it is required",
58
+ " only by =runtime and =all.",
59
+ " --include-csharp Also probe OmniSharp / netcoredbg on PATH (the C# planes).",
60
+ " --timeout <ms> Per-bridge connect timeout (default 1500).",
61
+ " --json Emit the report as JSON.",
62
+ ];
63
+ export const TOOLS_USAGE = [
64
+ "Usage: breakpoint-mcp tools [options]",
65
+ "",
66
+ "Export the tool surface + risk annotations (for catalogs / reviews).",
67
+ "",
68
+ "tools options:",
69
+ " --surface <which> full | secure-default (default: secure-default, what an untouched install advertises).",
70
+ " --json Emit the surface as JSON — stable and timestamp-free, so releases can be diffed.",
71
+ ];
72
+ /** Every flag `init` accepts. Booleans are declared at the call site. */
73
+ export const INIT_FLAGS = [
74
+ "project",
75
+ "client",
76
+ "from-github",
77
+ "repo",
78
+ "trust",
79
+ "privileged-groups",
80
+ ];
81
+ /** Every flag `doctor` accepts. */
82
+ export const DOCTOR_FLAGS = ["project", "timeout"];
83
+ /** Every flag `tools` accepts. */
84
+ export const TOOLS_FLAGS = ["surface"];
85
+ /** The whole document, composed — what `breakpoint-mcp --help` prints. */
86
+ export function fullUsage() {
87
+ // Each subcommand block contributes only its own `<name> options:` tail; the
88
+ // per-subcommand synopsis above it would read as four competing headlines in
89
+ // a single document.
90
+ const tail = (block) => block.slice(block.findIndex((l) => l.endsWith("options:")));
91
+ return [
92
+ HEADLINE,
93
+ "",
94
+ ...SYNOPSIS,
95
+ "",
96
+ ...tail(INIT_USAGE),
97
+ "",
98
+ ...tail(DOCTOR_USAGE),
99
+ "",
100
+ ...tail(TOOLS_USAGE),
101
+ "",
102
+ "All runtime configuration is via environment variables; see the README.",
103
+ "",
104
+ ];
105
+ }
106
+ //# sourceMappingURL=usage.js.map
package/dist/index.js CHANGED
@@ -9,16 +9,18 @@ import { CsLspClient } from "./cslsp.js";
9
9
  import { CsDapClient } from "./csdap.js";
10
10
  import { StdioChannel } from "./stdio.js";
11
11
  import { DapClient } from "./dap.js";
12
+ import { fullUsage } from "./cli/usage.js";
12
13
  import { buildToolsets } from "./toolsets.js";
13
14
  import { registerRecipes } from "./recipes.js";
14
15
  import { applyWireDefaults } from "./wire-defaults.js";
15
16
  import { applyOutputSchemas } from "./schemas.js";
16
17
  import { applyAnnotations } from "./annotations.js";
17
18
  import { applyTimeoutCaveat } from "./timeout-caveat.js";
18
- import { applyCapabilities, droppedTools, registerCapabilitiesResource, selectPrivilegedGroups, } from "./capabilities.js";
19
+ import { applyCapabilities, applyDroppedToolRefusal, droppedTools, registerCapabilitiesResource, selectPrivilegedGroups, } from "./capabilities.js";
19
20
  import { taskStore, TASK_CAPABILITIES } from "./tasks.js";
20
21
  import { RESOURCE_CAPABILITIES, registerResourceSubscriptions } from "./subscriptions.js";
21
22
  import { pauseLatch, installPauseSignalHandlers } from "./pause.js";
23
+ import { applyResultCost } from "./result-cost.js";
22
24
  import { log } from "./logger.js";
23
25
  async function main() {
24
26
  const config = loadConfig();
@@ -42,7 +44,7 @@ async function main() {
42
44
  // so long jobs (export/import/headless script) support poll/await/cancel.
43
45
  // D3: also advertise resources.subscribe so clients can subscribe to
44
46
  // godot://… resources and receive notifications/resources/updated.
45
- const server = new McpServer({ name: "breakpoint-mcp", version: "1.74.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
47
+ const server = new McpServer({ name: "breakpoint-mcp", version: "1.75.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
46
48
  // 208 §7.1 — delete from the wire what the protocol already says: the SDK's
47
49
  // draft-07 `$schema` inside every input/output schema (MCP's default dialect is
48
50
  // 2020-12, and declaring another is an explicit switch a peer may reject), and
@@ -128,6 +130,17 @@ async function main() {
128
130
  // read godot://capabilities to see what exists-but-is-disabled and how to
129
131
  // enable it. Registered directly (not via the `resources` toolset).
130
132
  registerCapabilitiesResource(server, privilegedGroups);
133
+ // …and the other half of "never a silent gap", which the resource alone could
134
+ // not deliver. A dropped tool is not registered, so calling it by name reaches
135
+ // the SDK's `Tool <name> not found` — grammatical, accurate about the lookup,
136
+ // and wrong about the world. Must run AFTER every ts.run() above: the CallTool
137
+ // dispatcher does not exist until the first registerTool, and this wraps it.
138
+ // Advertises nothing new — tools/list is untouched at 279.
139
+ applyDroppedToolRefusal(server, privilegedGroups);
140
+ // 257 — the result-cost meter, off unless BREAKPOINT_RESULT_COST names a file.
141
+ // AFTER the refusal wrapper so a dropped tool's refusal is measured as the result
142
+ // it actually is: a client pays for a refusal exactly as it pays for an answer.
143
+ applyResultCost(server);
131
144
  if (config.privilegedGroups) {
132
145
  const on = [...privilegedGroups].sort().join(", ") || "(none)";
133
146
  log(`privileged groups enabled: ${on}; dropped ${droppedTools(privilegedGroups).length} tool(s) from the surface`);
@@ -160,61 +173,89 @@ async function main() {
160
173
  process.on("SIGTERM", shutdown);
161
174
  }
162
175
  function printUsage() {
163
- process.stdout.write([
164
- "breakpoint-mcp — MCP server exposing Godot to AI coding assistants.",
165
- "",
166
- "Usage:",
167
- " breakpoint-mcp Start the MCP server on stdio (default; how MCP clients launch it).",
168
- " breakpoint-mcp init Install + enable the editor addon in a project and wire the MCP client.",
169
- " breakpoint-mcp doctor Check the Godot binary, the editor addon, and the four bridges.",
170
- " breakpoint-mcp tools Export the tool surface + risk annotations (for catalogs / reviews).",
171
- " breakpoint-mcp --help Show this help.",
172
- "",
173
- "init options:",
174
- " --project <dir> Target Godot project (default: $GODOT_PROJECT or the current directory).",
175
- " --client <id> Write the MCP config for a client: claude-code | claude-desktop | cursor | windsurf | vscode.",
176
- " --force Overwrite an addon that is already installed.",
177
- " --dry-run Print what would change without writing anything.",
178
- " --from-github [ref] Fetch the editor addon from GitHub at [ref] (default: this package's version tag) instead of the bundled copy.",
179
- " --repo <owner/repo> With --from-github, the source repo (default: jlivingston-Cipher/godot-breakpoint-mcp).",
180
- " --trust <level> secure | full. `full` enables every higher-trust group (default: secure).",
181
- " --privileged-groups <a,b> Enable named higher-trust groups instead of all of them.",
182
- "",
183
- "doctor options:",
184
- " --project <dir> Project to check (default: $GODOT_PROJECT or the current directory).",
185
- " --require-live Also require the editor/runtime/LSP/DAP bridges to be reachable.",
186
- " --include-csharp Also probe OmniSharp / netcoredbg on PATH (the C# planes).",
187
- " --timeout <ms> Per-bridge connect timeout (default 1500).",
188
- " --json Emit the report as JSON.",
189
- "",
190
- "tools options:",
191
- " --surface <which> full | secure-default (default: secure-default, what an untouched install advertises).",
192
- " --json Emit the surface as JSON — stable and timestamp-free, so releases can be diffed.",
193
- "",
194
- "All runtime configuration is via environment variables; see the README.",
195
- "",
196
- ].join("\n"));
176
+ process.stdout.write(fullUsage().join("\n"));
177
+ }
178
+ // Subcommand dispatch. NO arguments at all is how every MCP client launches
179
+ // this — every invocation in the README, the user guide and `init`'s own
180
+ // emitted config passes the binary an empty argv — so that case, and only that
181
+ // case, starts the stdio server. The launch contract is unchanged.
182
+ //
183
+ // 🔴 WHAT CHANGED IS THE FALL-THROUGH, WHICH USED TO SWALLOW EVERYTHING ELSE.
184
+ // `breakpoint-mcp --version` started a server. So did `-v`, `version`, and any
185
+ // mistyped flag: the process printed one ready line and then sat on a stdin
186
+ // nobody was going to speak MCP into, which in a terminal is a hang. The two
187
+ // halves of that are separate defects and both are user-facing:
188
+ //
189
+ // * There was NO WAY TO ASK THIS BINARY ITS VERSION. `--version` is the most
190
+ // universal convention a CLI has, and the first thing anyone filing a bug
191
+ // report is asked for. A project with 0 issues against ~3,700 downloads
192
+ // cannot afford a version query that hangs the terminal instead.
193
+ // * An unrecognized token is a TYPO, and the only useful thing to do with one
194
+ // is name it. Falling through spends the user's next twenty minutes on the
195
+ // wrong question.
196
+ // 🔴 EXIT ONLY AFTER stdout HAS DRAINED, AND IT IS THE PIPE THAT MAKES THIS A
197
+ // DEFECT RATHER THAN A STYLE NOTE. `process.stdout` is SYNCHRONOUS to a file
198
+ // and to a TTY and ASYNCHRONOUS to a pipe. `process.exit()` does not flush what
199
+ // is still queued, so every subcommand below was correct when a human watched
200
+ // it, correct when a shell redirected it to a file, and lost everything past
201
+ // the first 64 KiB the moment anybody piped it.
202
+ //
203
+ // Measured on 1.75.0 and on the published 1.74.1 — same command, same build:
204
+ //
205
+ // breakpoint-mcp tools --json > file 202,960 bytes · valid JSON
206
+ // breakpoint-mcp tools --json | cat 65,536 bytes · unterminated string
207
+ //
208
+ // 65,536 is one pipe buffer, and that is the whole tell: nothing failed and no
209
+ // error was raised, the process simply stopped existing with the rest of the
210
+ // answer still inside it. The flag's own usage text is `so releases can be
211
+ // diffed`, and diffing is a pipe.
212
+ //
213
+ // 🔴 THE POPULATION IS A PREDICATE, NOT A LIST OF COMMANDS (257). `tools --json`
214
+ // is the only subcommand that exceeds a buffer TODAY — `doctor --json` measures
215
+ // 2,138 bytes and `init --dry-run` 1,186 — so a fix inside `runTools` would be
216
+ // correct and would leave the next command to cross 64 KiB to rediscover this
217
+ // from a user's bug report. The drain is attached at the dispatch every
218
+ // subcommand passes through instead (254).
219
+ //
220
+ // The empty write IS the flush: its callback runs once everything already
221
+ // queued on the stream has been handed to the OS.
222
+ async function exitAfterFlush(code) {
223
+ await new Promise((resolve) => {
224
+ process.stdout.write("", () => resolve());
225
+ });
226
+ process.exit(code);
197
227
  }
198
- // Subcommand dispatch. Anything that isn't a recognized subcommand — including
199
- // no arguments at all, which is how every MCP client launches this — falls
200
- // through to the stdio server, so the server's launch contract is unchanged.
201
228
  void (async () => {
202
229
  const sub = process.argv[2];
203
230
  if (sub === "doctor") {
204
231
  const { runDoctor } = await import("./cli/doctor.js");
205
- process.exit(await runDoctor(process.argv.slice(3)));
232
+ await exitAfterFlush(await runDoctor(process.argv.slice(3)));
206
233
  }
207
234
  if (sub === "tools") {
208
235
  const { runTools } = await import("./cli/tools.js");
209
- process.exit(await runTools(process.argv.slice(3)));
236
+ await exitAfterFlush(await runTools(process.argv.slice(3)));
210
237
  }
211
238
  if (sub === "init") {
212
239
  const { runInit } = await import("./cli/init.js");
213
- process.exit(await runInit(process.argv.slice(3)));
240
+ await exitAfterFlush(await runInit(process.argv.slice(3)));
214
241
  }
215
242
  if (sub === "help" || sub === "--help" || sub === "-h") {
216
243
  printUsage();
217
- process.exit(0);
244
+ await exitAfterFlush(0);
245
+ }
246
+ if (sub === "version" || sub === "--version" || sub === "-v" || sub === "-V") {
247
+ // Bare, so `breakpoint-mcp --version` is usable in a shell substitution.
248
+ // Read from package.json rather than written here: see version.ts on the
249
+ // twenty releases that announced 0.2.0 to every LSP server they met.
250
+ const { packageVersion } = await import("./version.js");
251
+ process.stdout.write(`${packageVersion()}\n`);
252
+ await exitAfterFlush(0);
253
+ }
254
+ if (sub !== undefined) {
255
+ process.stderr.write(`breakpoint-mcp: unknown argument '${sub}'.\n` +
256
+ " Run `breakpoint-mcp --help` for the subcommands and their options.\n" +
257
+ " To start the MCP server, run it with no arguments — that is how MCP clients launch it.\n");
258
+ await exitAfterFlush(2);
218
259
  }
219
260
  await main();
220
261
  })().catch((err) => {
package/dist/peers.js CHANGED
@@ -3,6 +3,7 @@ import { ProcessRegistry } from "./tools/processes.js";
3
3
  import { ensureProjectSecret, readProjectSecret } from "./secret.js";
4
4
  import { log } from "./logger.js";
5
5
  import { portFree } from "./ports.js";
6
+ import { waitForBridge } from "./readiness.js";
6
7
  /**
7
8
  * Hard ceiling on simultaneously live peers.
8
9
  *
@@ -73,9 +74,11 @@ export class PeerRegistry {
73
74
  * sequential and the window is sub-millisecond, and the failure mode is
74
75
  * benign — the child logs "could not listen" and the readiness wait below
75
76
  * reports it with that line attached, rather than the peer silently
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.
77
+ * half-existing. The DEFAULT runtime port refuses a held port outright rather
78
+ * than reporting it after the fact — and as of 257 it waits too:
79
+ * `godot_run_project` and `godot_run_managed` call `waitForRuntimeBridge` and
80
+ * report `bridge_ready`, so the sentence that used to stand here — *the
81
+ * default runtime port has no such readiness wait* — is no longer true.
79
82
  */
80
83
  async allocatePorts(count) {
81
84
  const taken = new Set([this.cfg.runtimePort, ...this.live().map((p) => p.port)]);
@@ -95,22 +98,18 @@ export class PeerRegistry {
95
98
  }
96
99
  return ports;
97
100
  }
98
- /** Poll a peer's bridge until it answers `ping`, or the deadline passes. */
101
+ /**
102
+ * Poll a peer's bridge until it answers `ping`, or the deadline passes.
103
+ *
104
+ * 🔴 THE BODY MOVED TO `readiness.ts` AT 257, AND THE POINT IS NOT REUSE. The
105
+ * comment in `allocatePorts` above named the default runtime port's missing
106
+ * readiness wait as a property of the design — it was the defect
107
+ * `run-project-returns-before-bridge`, open since 249. Two copies would have
108
+ * let the default port's wait drift from the one this module has proven
109
+ * against real peers since 1.21.0; one function cannot.
110
+ */
99
111
  async waitReady(client, deadline) {
100
- for (;;) {
101
- const remaining = deadline - Date.now();
102
- if (remaining <= 0)
103
- return false;
104
- try {
105
- await client.request("ping", {}, Math.min(1000, remaining));
106
- return true;
107
- }
108
- catch {
109
- if (Date.now() >= deadline)
110
- return false;
111
- await sleep(100);
112
- }
113
- }
112
+ return waitForBridge(client, deadline);
114
113
  }
115
114
  /**
116
115
  * Spawn `count` headless peers and wait until each answers `ping`.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * One readiness wait, for every launcher that hands a caller a bridge.
3
+ *
4
+ * 🔴 THE FIELD WAS TRUE AND THE MOMENT WAS FALSE. `godot_run_project` and its
5
+ * twin `godot_run_managed` returned `running: true` the instant `spawn()` handed
6
+ * back a pid — measured 566–3213 ms before the game's autoload binds 9081. Every
7
+ * `runtime_*` call inside that window answers *Is the project running?*, and it
8
+ * is: the tool said so, and returned the pid. The answer is true, the question is
9
+ * the wrong one, and no gate in this repository could read the difference.
10
+ *
11
+ * The tree already argued against itself here. `peers.ts` waits, and says why:
12
+ * *"Godot needs ~200 ms to bind, so a tool that returned immediately would hand
13
+ * the caller peers its very next call cannot reach — a flake in the one feature
14
+ * that exists to remove flakes."* `allocatePorts`'s comment then names the gap
15
+ * out loud — *"The DEFAULT runtime port has no such readiness wait"* — as a
16
+ * property of the design rather than a defect. It was the defect. This module is
17
+ * that wait, lifted out of `PeerRegistry` so the default port and the peer ports
18
+ * are ready by the same code and provably so, and `waitReady` there is now a
19
+ * one-line delegation rather than a second copy.
20
+ *
21
+ * 🔴 `ping` IS THE VERB, NOT A TCP CONNECT. `cli/doctor.ts`'s `probeTcp` proves a
22
+ * socket accepted, which any process holding 9081 does; the launchers already
23
+ * refuse a held port for exactly that reason. `ping` is answered by the addon's
24
+ * runtime bridge and by nothing else, and `runtime_bridge.gd` exempts it from the
25
+ * pause latch, so it is also the one verb that answers on a paused game.
26
+ */
27
+ import { BridgeClient } from "./bridge.js";
28
+ import { resolveBridgeSecret } from "./secret.js";
29
+ /** Delay between two `ping` attempts while waiting for a bridge to bind. */
30
+ export const READY_POLL_INTERVAL_MS = 100;
31
+ /** Per-attempt deadline, so one slow `ping` cannot eat the whole wait. */
32
+ export const READY_PING_TIMEOUT_MS = 1000;
33
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
34
+ /**
35
+ * Poll a bridge until it answers `ping`, or `deadline` passes.
36
+ *
37
+ * Returns whether it answered. It never throws: a caller that launched a process
38
+ * successfully has not failed just because the bridge was slow, and the honest
39
+ * report of that is a false in a field, not an error in place of the pid.
40
+ */
41
+ export async function waitForBridge(client, deadline) {
42
+ for (;;) {
43
+ const remaining = deadline - Date.now();
44
+ if (remaining <= 0)
45
+ return false;
46
+ try {
47
+ await client.request("ping", {}, Math.min(READY_PING_TIMEOUT_MS, remaining));
48
+ return true;
49
+ }
50
+ catch {
51
+ if (Date.now() >= deadline)
52
+ return false;
53
+ await sleep(READY_POLL_INTERVAL_MS);
54
+ }
55
+ }
56
+ }
57
+ /**
58
+ * The default runtime port's own client, built the way `index.ts` builds the
59
+ * long-lived one — same host, same port, same secret resolution order — but
60
+ * short-lived, because a launcher wants one answer and not a subscription.
61
+ *
62
+ * Built here rather than threaded in from `index.ts` because `registerCliTools`
63
+ * and `registerProcessTools` both take only a `Config`: two toolsets, two
64
+ * signatures and a stub type would have to change to hand them a client they use
65
+ * for one `ping`.
66
+ */
67
+ export function runtimeReadinessClient(cfg) {
68
+ return new BridgeClient(cfg.runtimeHost, cfg.runtimePort, READY_PING_TIMEOUT_MS, "runtime bridge", "Is the project running?", () => resolveBridgeSecret(cfg.projectPath, ["BREAKPOINT_RUNTIME_SECRET", "BREAKPOINT_BRIDGE_SECRET"]), "BREAKPOINT_RUNTIME_TIMEOUT_MS", "the running game");
69
+ }
70
+ /**
71
+ * Wait for the DEFAULT runtime bridge after a launch, and report what happened.
72
+ *
73
+ * `timeoutMs` of 0 (or less) is the documented opt-out: no client is built, no
74
+ * socket is opened, and the answer is an honest `{ready: false, waited_ms: 0}` —
75
+ * *not waited* rather than *waited and failed*, which is what a caller that asked
76
+ * not to wait needs to be able to tell apart from a launch that lost the race.
77
+ */
78
+ export async function waitForRuntimeBridge(cfg, timeoutMs) {
79
+ if (timeoutMs <= 0)
80
+ return { ready: false, waited_ms: 0 };
81
+ const start = Date.now();
82
+ const client = runtimeReadinessClient(cfg);
83
+ try {
84
+ const ready = await waitForBridge(client, start + timeoutMs);
85
+ return { ready, waited_ms: Date.now() - start };
86
+ }
87
+ finally {
88
+ client.close();
89
+ }
90
+ }
91
+ /**
92
+ * What a launcher tells a caller whose bridge never came up. 254's rule — a
93
+ * message that says what broke has done half the job — applied to a field rather
94
+ * than to a message: `bridge_ready: false` says what happened, and this says what
95
+ * to do about it.
96
+ */
97
+ export function notReadyRemedy(cfg, waitedMs) {
98
+ if (waitedMs <= 0) {
99
+ return (`The process launched and no wait was requested (wait_timeout_ms 0), so nothing here knows whether the ` +
100
+ `runtime bridge at ${cfg.runtimeHost}:${cfg.runtimePort} is up yet. Call runtime_get_tree to find out, or ` +
101
+ `re-run without wait_timeout_ms and this tool will wait and answer.`);
102
+ }
103
+ return (`The process launched but the runtime bridge at ${cfg.runtimeHost}:${cfg.runtimePort} did not answer ping ` +
104
+ `within ${waitedMs} ms, so a runtime_* call will fail until it does. Is the "Breakpoint MCP" plugin enabled in ` +
105
+ `this project? The runtime autoload it registers is what binds the port. If the project is simply slow to boot, ` +
106
+ `raise the wait with wait_timeout_ms.`);
107
+ }
108
+ //# sourceMappingURL=readiness.js.map
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The next action for a code the ADDON could not name — the host's own fallback.
3
+ *
4
+ * 🔴 WHY A FALLBACK AT ALL, AND WHY IT IS EXACTLY ONE ROW (258 §2).
5
+ * `error_remedies.gd` is the real table: 254 measured 525 `_err(..)` sites across
6
+ * the two engine planes emitting 216 message templates with 451 of them naming no
7
+ * next action, and answered it where the codes are raised. This file is not a
8
+ * second copy of that table and must not become one — check 28f ceilings it at one
9
+ * row, so a second is a decision somebody makes on purpose against a red gate
10
+ * rather than a drift nobody notices.
11
+ *
12
+ * It exists for one population the addon-side table structurally cannot reach.
13
+ * `error_remedies.gd` WAS ADDED IN ADDON 1.10.0. Its `unknown_method` row says
14
+ * *the addon installed in the project is older than the host* — and every addon
15
+ * old enough to raise `unknown_method` is old enough to predate the file that
16
+ * would explain it. The remedy shipped only where it was not needed; the set of
17
+ * users who can receive it and the set who need it are disjoint by construction.
18
+ *
19
+ * So the sentence has to come from the side that is current by definition: the
20
+ * host the user just upgraded. `bridge.ts` reads `remedy` off the wire error and
21
+ * falls back to this table when the wire carried none, which means a current addon
22
+ * keeps answering for itself and nothing here overrides it.
23
+ *
24
+ * 🔴 AND THE ONE THING THIS CANNOT SEE. `unknown_method` also fires if the HOST
25
+ * calls a method no addon has ever implemented — a host bug, not a stale addon —
26
+ * and this remedy would then send a user to reinstall an addon that was never the
27
+ * problem. That case is gated: `contract_check` joins every method the host calls
28
+ * to the addon's own dispatch on both planes, so it cannot reach a release. The
29
+ * remedy is correct for every `unknown_method` that survives CI.
30
+ */
31
+ /**
32
+ * code -> next action, used ONLY when the wire error carried no remedy of its own.
33
+ *
34
+ * Keyed by the same code vocabulary the addon raises and written to the same
35
+ * grammar check 28 enforces on `error_remedies.gd` — one imperative at the head, a
36
+ * full stop at the end, under the length ceiling, every backticked name joined to
37
+ * something real. It is the same sentence a reader gets from the addon when the
38
+ * addon is new enough to have one, so it must read the same way.
39
+ *
40
+ * 🔴 IT IS NOT `ADDON_SKEW_HINT` REUSED, DELIBERATELY. That constant is what a
41
+ * TERMINAL prints — single quotes, because backticks in a shell transcript are
42
+ * command substitution and a user pasting one gets something surprising. This is
43
+ * what crosses the WIRE to an agent, where backticks are how every other remedy
44
+ * marks a name. Same instruction, two audiences that read punctuation differently.
45
+ */
46
+ export const HOST_FALLBACK_REMEDIES = {
47
+ unknown_method: "Re-run `breakpoint-mcp init --force`, then reopen the project — the addon here is older than the host and plain `init` skips an addon that is already present.",
48
+ };
49
+ /**
50
+ * The remedy to attach to a wire error: the addon's if it sent one, else the
51
+ * host's fallback, else nothing.
52
+ *
53
+ * The addon ALWAYS wins when it spoke. A newer addon may know something specific
54
+ * about the failure that a host-side table keyed only on the code cannot — and
55
+ * silently preferring the generic sentence would make every future addon-side
56
+ * remedy improvement invisible to the reader who acts on it.
57
+ */
58
+ export function remedyForWireError(code, fromWire) {
59
+ if (typeof fromWire === "string" && fromWire !== "")
60
+ return fromWire;
61
+ return HOST_FALLBACK_REMEDIES[code];
62
+ }
63
+ //# sourceMappingURL=remedies.js.map
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The half of client cost the cost instrument could not see.
3
+ *
4
+ * 🔴 `token-cost.mjs` GOVERNS THE MENU AND THE MEAL WAS NEVER MEASURED (249, closed
5
+ * 257). Its unit is `JSON.stringify(tools)` from a live `tools/list` — the catalogue a
6
+ * client puts in the model's context once — against a byte ceiling that file owns, and
7
+ * its own header says the rest out loud: *"it reads the tool list over stdio and never
8
+ * calls a tool."* One `gd_completion` was measured returning, in a single result, very
9
+ * nearly the byte cost of the ENTIRE advertised surface. The instrument read that tree
10
+ * and printed `ok`, correctly, about a question nobody was asking.
11
+ *
12
+ * This is the missing axis, and it is deliberately a METER rather than a gate: results
13
+ * depend on a live engine, a live language server and a real project, so nothing here can
14
+ * refuse at build time without lying about what it measured. What it can do is make the
15
+ * number obtainable and repeatable — `BREAKPOINT_RESULT_COST=<path>` records one line per
16
+ * call, and `token-cost.mjs --results <path>` reads them back against a ceiling. A number
17
+ * that lives in a handoff is a measurement; a number a command can re-take is an
18
+ * instrument, and 249's 342,116 B has been the first kind for eight sessions.
19
+ *
20
+ * 🔴 ONE WRAPPER, AT THE ONE POINT THAT KNOWS BOTH HALVES. `ok()` builds the envelope but
21
+ * is not told which tool it is answering for — and it is not even one function: six
22
+ * byte-identical copies exist across the tool files. The `CallTool` dispatcher sees the
23
+ * name and the result together, and `applyDroppedToolRefusal` already proves the SDK's
24
+ * handler map can be wrapped in place. This adds one more branch in front of it.
25
+ *
26
+ * OFF BY DEFAULT, and off means absent: with the variable unset no wrapper is installed
27
+ * at all, so the measured path is the shipped path plus nothing.
28
+ */
29
+ import fs from "node:fs";
30
+ import { log } from "./logger.js";
31
+ const CALL_TOOL_METHOD = "tools/call";
32
+ /** The env var that turns the meter on, and names the file it writes. */
33
+ export const RESULT_COST_ENV = "BREAKPOINT_RESULT_COST";
34
+ /**
35
+ * The wire cost of one tool result, in the same unit the catalogue is measured in.
36
+ *
37
+ * 🔴 THE WHOLE RESULT, NOT THE PAYLOAD. `ok()` returns `content[0].text` — the payload
38
+ * pretty-printed at two-space indent — AND `structuredContent`, the same object again.
39
+ * Measuring the payload once would understate what crosses the wire by roughly half, and
40
+ * the point of this module is to stop understating it.
41
+ */
42
+ export function resultBytes(result) {
43
+ try {
44
+ return Buffer.byteLength(JSON.stringify(result) ?? "", "utf8");
45
+ }
46
+ catch {
47
+ // A result that cannot be stringified cannot have crossed the wire either, so
48
+ // there is no honest number to record. Zero is the absence of a measurement and
49
+ // the reader treats it as one.
50
+ return 0;
51
+ }
52
+ }
53
+ /** Render one record as the reader's line format: `tool<TAB>bytes`. */
54
+ export function formatRecord(rec) {
55
+ return `${rec.tool}\t${rec.bytes}\n`;
56
+ }
57
+ /**
58
+ * Install the meter, if `BREAKPOINT_RESULT_COST` names a file to write.
59
+ *
60
+ * Returns whether it installed, so a caller — and a test — can tell "off" from
61
+ * "on but the dispatcher was missing", which are the same silence otherwise.
62
+ */
63
+ export function applyResultCost(server, env = process.env) {
64
+ const path = env[RESULT_COST_ENV];
65
+ if (!path)
66
+ return false;
67
+ const proto = server.server;
68
+ const handlers = proto?._requestHandlers;
69
+ const inner = handlers?.get(CALL_TOOL_METHOD);
70
+ // No dispatcher means no tool was ever registered; there is nothing to meter.
71
+ if (!handlers || !inner)
72
+ return false;
73
+ handlers.set(CALL_TOOL_METHOD, async (request, extra) => {
74
+ const result = await inner(request, extra);
75
+ const name = request?.params?.name;
76
+ if (typeof name === "string") {
77
+ try {
78
+ fs.appendFileSync(path, formatRecord({ tool: name, bytes: resultBytes(result) }));
79
+ }
80
+ catch (err) {
81
+ // A meter that takes the server down is worse than a meter that misses a line.
82
+ log(`[result-cost] could not append to ${path}: ${err.message}`);
83
+ }
84
+ }
85
+ return result;
86
+ });
87
+ log(`[result-cost] metering tool results to ${path}`);
88
+ return true;
89
+ }
90
+ //# sourceMappingURL=result-cost.js.map