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.
- package/addon/breakpoint_mcp/bridge_server.gd +4 -1
- package/addon/breakpoint_mcp/error_remedies.gd +113 -0
- package/addon/breakpoint_mcp/operations.gd +10 -2
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/runtime_bridge.gd +20 -2
- package/dist/addon-version.js +113 -0
- package/dist/annotations.js +10 -1
- package/dist/bridge.js +63 -4
- package/dist/capabilities.js +91 -2
- package/dist/cli/args.js +32 -2
- package/dist/cli/doctor.js +240 -19
- package/dist/cli/init.js +87 -16
- package/dist/cli/tools.js +12 -15
- package/dist/cli/usage.js +106 -0
- package/dist/index.js +84 -43
- package/dist/peers.js +17 -18
- package/dist/readiness.js +108 -0
- package/dist/remedies.js +63 -0
- package/dist/result-cost.js +90 -0
- package/dist/schemas.js +23 -4
- package/dist/tools/backend.js +2 -1
- package/dist/tools/cli.js +43 -6
- package/dist/tools/cslsp.js +19 -3
- package/dist/tools/editor/common.js +2 -1
- package/dist/tools/lsp-common.js +23 -0
- package/dist/tools/lsp.js +18 -3
- package/dist/tools/netcode.js +2 -1
- package/dist/tools/processes.js +26 -2
- package/dist/tools/runtime.js +34 -10
- package/dist/tools/tabletop.js +2 -1
- package/dist/wire-defaults.js +80 -8
- package/package.json +2 -2
|
@@ -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.
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
+
await exitAfterFlush(await runInit(process.argv.slice(3)));
|
|
214
241
|
}
|
|
215
242
|
if (sub === "help" || sub === "--help" || sub === "-h") {
|
|
216
243
|
printUsage();
|
|
217
|
-
|
|
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
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
package/dist/remedies.js
ADDED
|
@@ -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
|