breakpoint-mcp 1.74.1 → 1.76.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/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
package/dist/schemas.js CHANGED
@@ -84,12 +84,31 @@ export const outputSchemas = {
84
84
  },
85
85
  godot_version: { version: z.string(), raw: capturedRaw },
86
86
  godot_launch_editor: { launched: z.boolean(), pid: z.number().nullable(), project: z.string() },
87
- godot_run_project: { running: z.boolean(), pid: z.number().nullable(), scene: z.string().nullable() },
87
+ // 🔴 `running` ALONE WAS THE DEFECT (249, closed 257). It reported that spawn()
88
+ // returned a pid, and callers read it as "runtime_* is reachable now" — which it
89
+ // was not, for 566–3213 ms. `running` still means what it always meant; the
90
+ // three readiness fields are the question that was actually being asked.
91
+ godot_run_project: {
92
+ running: z.boolean(),
93
+ pid: z.number().nullable(),
94
+ scene: z.string().nullable(),
95
+ bridge_ready: z.boolean(),
96
+ bridge_wait_ms: z.number(),
97
+ bridge_note: z.string().nullable(),
98
+ },
88
99
  godot_export: { preset: z.string(), output_path: z.string(), exit_code: z.number().nullable(), timed_out: z.boolean(), stdout: z.string(), stderr: z.string() },
89
100
  godot_import: { exit_code: z.number().nullable(), timed_out: z.boolean(), stdout: z.string(), stderr: z.string() },
90
101
  godot_run_headless_script: { script_path: z.string(), exit_code: z.number().nullable(), timed_out: z.boolean(), stdout: z.string(), stderr: z.string() },
91
102
  // ---- Managed processes (tools/processes.ts) ----
92
- godot_run_managed: { id: z.string(), pid: z.number().nullable(), running: z.boolean(), scene: z.string().nullable() },
103
+ godot_run_managed: {
104
+ id: z.string(),
105
+ pid: z.number().nullable(),
106
+ running: z.boolean(),
107
+ scene: z.string().nullable(),
108
+ bridge_ready: z.boolean(),
109
+ bridge_wait_ms: z.number(),
110
+ bridge_note: z.string().nullable(),
111
+ },
93
112
  godot_output: {
94
113
  id: z.string(),
95
114
  exited: z.boolean(),
@@ -341,7 +360,7 @@ export const outputSchemas = {
341
360
  test_detect: { framework: z.string(), path: z.string(), version: z.string() },
342
361
  test_list: { dir: z.string(), count: z.number(), tests: z.array(z.string()) },
343
362
  // ---- Plane D: semantic / LSP (tools/lsp.ts) ----
344
- gd_completion: { items: z.array(z.object({ label: z.string(), kind: z.string(), detail: z.string(), insertText: z.string() })) },
363
+ gd_completion: { items: z.array(z.object({ label: z.string(), kind: z.string(), detail: z.string(), insertText: z.string() })), truncated: z.boolean() },
345
364
  gd_hover: { contents: z.string() },
346
365
  gd_definition: { locations: z.array(location) },
347
366
  gd_references: { locations: z.array(location) },
@@ -398,7 +417,7 @@ export const outputSchemas = {
398
417
  })),
399
418
  },
400
419
  // ---- Plane D: C# semantic / OmniSharp LSP (tools/cslsp.ts) ----
401
- cs_completion: { items: z.array(z.object({ label: z.string(), kind: z.string(), detail: z.string(), insertText: z.string() })) },
420
+ cs_completion: { items: z.array(z.object({ label: z.string(), kind: z.string(), detail: z.string(), insertText: z.string() })), truncated: z.boolean() },
402
421
  cs_hover: { contents: z.string() },
403
422
  cs_definition: { locations: z.array(location) },
404
423
  cs_references: { locations: z.array(location) },
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { remedyClause } from "../bridge.js";
2
3
  import { gate } from "../confirm.js";
3
4
  import { resolveInsideProject } from "../paths.js";
4
5
  /**
@@ -46,7 +47,7 @@ function fail(err) {
46
47
  const message = be?.message ?? String(err);
47
48
  return {
48
49
  isError: true,
49
- content: [{ type: "text", text: `Backend scaffold error [${code}]: ${message}` }],
50
+ content: [{ type: "text", text: `Backend scaffold error [${code}]: ${message}${remedyClause(err)}` }],
50
51
  };
51
52
  }
52
53
  // ---- the known SDKs + their capability matrix ----------------------------
package/dist/tools/cli.js CHANGED
@@ -7,6 +7,7 @@ import { ok, failPath } from "./lsp-common.js";
7
7
  import { resolveInsideProject } from "../paths.js";
8
8
  import { portFree, portConflictMessage } from "../ports.js";
9
9
  import { runDoctorChecks } from "../cli/doctor.js";
10
+ import { waitForRuntimeBridge, notReadyRemedy } from "../readiness.js";
10
11
  const execFileAsync = promisify(execFile);
11
12
  /** Run the Godot binary to completion, capturing stdout/stderr. */
12
13
  async function runCaptured(cfg, args, timeoutMs, signal) {
@@ -66,6 +67,8 @@ export function registerCliTools(server, cfg) {
66
67
  server.registerTool("godot_run_project", {
67
68
  title: "Run project",
68
69
  description: "Run the project (detached). Optionally start from a specific scene path (res://...). Returns the process id. " +
70
+ "WAITS until the game's runtime bridge answers ping and reports bridge_ready — the game takes roughly half a " +
71
+ "second to three seconds to bind it, and no runtime_* tool is reachable before it does. " +
69
72
  "Refuses if the runtime bridge port is already bound — the new game could not host the bridge, and every " +
70
73
  "runtime_* call would address the process already holding the port. Use runtime_spawn_peers to drive more " +
71
74
  "than one game at once.",
@@ -77,13 +80,26 @@ export function registerCliTools(server, cfg) {
77
80
  .describe("Start even though the runtime bridge port is already bound (default false). The new game's runtime " +
78
81
  "bridge will NOT be reachable — use only when you want the process for its side effects and will not " +
79
82
  "call any runtime_* tool against it."),
83
+ wait_timeout_ms: z
84
+ .number()
85
+ .int()
86
+ .min(0)
87
+ .optional()
88
+ .describe("How long to wait for the runtime bridge to answer ping, in ms (default 15000, the runtime bridge's own " +
89
+ "deadline). 0 returns as soon as the process is spawned, reporting bridge_ready false and " +
90
+ "bridge_wait_ms 0 — waited-not-at-all, which a caller can tell apart from waited-and-lost."),
80
91
  },
81
92
  },
82
93
  // 🔴 MEASURED by the LAUNCHED PROCESS'S OWN ARGV: `res://../example_evil/x.tscn`
83
94
  // produced `godot --path …/example res://../example_evil/x.tscn` and the game
84
95
  // ran it. The port check comes second — a scene that can never legally run
85
96
  // should not first claim the runtime port.
86
- async ({ scene, allow_port_conflict }) => {
97
+ //
98
+ // 🔴 AND THE WAIT COMES LAST, WHICH IS THE WHOLE POINT (257). `portFree` above
99
+ // asserts 9081 is FREE; this handler used to prove the bridge was not up and
100
+ // then answer `running: true`, 566–3213 ms before it came up. The field was
101
+ // true and the moment was false — see `readiness.ts`.
102
+ async ({ scene, allow_port_conflict, wait_timeout_ms }) => {
87
103
  try {
88
104
  if (scene !== undefined)
89
105
  resolveInsideProject(scene, cfg.projectPath, "scene");
@@ -98,7 +114,15 @@ export function registerCliTools(server, cfg) {
98
114
  if (scene)
99
115
  args.push(scene);
100
116
  const pid = launchDetached(cfg, args);
101
- return ok({ running: true, pid, scene: scene ?? null });
117
+ const readiness = await waitForRuntimeBridge(cfg, wait_timeout_ms ?? cfg.runtimeTimeoutMs);
118
+ return ok({
119
+ running: true,
120
+ pid,
121
+ scene: scene ?? null,
122
+ bridge_ready: readiness.ready,
123
+ bridge_wait_ms: readiness.waited_ms,
124
+ bridge_note: readiness.ready ? null : notReadyRemedy(cfg, readiness.waited_ms),
125
+ });
102
126
  });
103
127
  registerTaskTool(server, "godot_export", {
104
128
  title: "Export project",
@@ -198,22 +222,35 @@ export function registerCliTools(server, cfg) {
198
222
  require_live: z
199
223
  .boolean()
200
224
  .optional()
201
- .describe("Treat an unreachable bridge as a failure rather than information (default false)"),
225
+ .describe("Treat an unreachable bridge as a failure rather than information (default false). True means " +
226
+ "the EDITOR's three bridges — editor, GDScript LSP, GDScript DAP. The runtime bridge lives " +
227
+ "inside the running game and is deliberately not implied; use live_level for that."),
228
+ live_level: z
229
+ .enum(["none", "editor", "runtime", "all"])
230
+ .optional()
231
+ .describe("Which bridges this report insists on: none (default), editor (the three the editor brings " +
232
+ "up), runtime (the one the running game brings up), all. Takes precedence over require_live."),
202
233
  include_csharp: z
203
234
  .boolean()
204
235
  .optional()
205
236
  .describe("Also probe OmniSharp / netcoredbg on PATH (the C# planes)"),
206
237
  timeout_ms: z.number().int().positive().optional().describe("Per-bridge connect timeout (default 1500)"),
207
238
  },
208
- }, async ({ require_live, include_csharp, timeout_ms }) => {
239
+ }, async ({ require_live, live_level, include_csharp, timeout_ms }) => {
209
240
  const report = await runDoctorChecks(cfg, {
210
241
  timeoutMs: timeout_ms ?? 1500,
211
- requireLive: require_live ?? false,
242
+ liveLevel: live_level ?? (require_live ? "editor" : "none"),
212
243
  includeCsharp: include_csharp ?? false,
213
244
  });
245
+ // `failed` counted BOTH severities beside `ok: true` — the same sentence
246
+ // the CLI summary used to print, in a field an agent reads instead of a
247
+ // human. Split, so the number that decides the verdict is its own number.
248
+ const failed = report.checks.filter((c) => c.status === "fail");
214
249
  return ok({
215
250
  ok: report.ok,
216
- failed: report.checks.filter((c) => c.status === "fail").length,
251
+ failed: failed.length,
252
+ required_failed: failed.filter((c) => c.severity === "required").length,
253
+ informational_failed: failed.filter((c) => c.severity === "info").length,
217
254
  checks: report.checks,
218
255
  });
219
256
  });
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import { z } from "zod";
3
3
  import { toFileUri, toFsPath, readFileText, resolveSourceFile } from "../paths.js";
4
4
  import { gate } from "../confirm.js";
5
- import { COMPLETION_KIND, SYMBOL_KIND, ok, fail, markupToString, isMethodNotFound, normalizeLocations, applyTextEdits, normalizeWorkspaceEdit, } from "./lsp-common.js";
5
+ import { COMPLETION_KIND, SYMBOL_KIND, ok, fail, markupToString, isMethodNotFound, normalizeLocations, applyTextEdits, normalizeWorkspaceEdit, capList, COMPLETION_LIMIT } from "./lsp-common.js";
6
6
  /**
7
7
  * D4 C2 — the C#/.NET semantic plane. Read-only `cs_*` tools mirroring the proven
8
8
  * read-only `gd_*` LSP surface, but driven by OmniSharp (spawned over stdio by the
@@ -127,7 +127,23 @@ export function registerCsLspTools(server, cslsp, cfg) {
127
127
  line: z.number().int().min(0).describe("0-based line"),
128
128
  character: z.number().int().min(0).describe("0-based character"),
129
129
  };
130
- server.registerTool("cs_completion", { title: "C# completion", description: "Type-aware C# code completion at a position via OmniSharp.", inputSchema: posSchema }, async ({ path, line, character }) => {
130
+ server.registerTool("cs_completion", {
131
+ title: "C# completion",
132
+ description: "Type-aware C# code completion at a position via OmniSharp. Returns at most max_results items " +
133
+ "(default 200) and sets truncated when the language server offered more — `gd_completion`'s twin, " +
134
+ "capped for the reason measured on that one.",
135
+ inputSchema: {
136
+ ...posSchema,
137
+ max_results: z
138
+ .number()
139
+ .int()
140
+ .positive()
141
+ .optional()
142
+ .describe("Maximum completion items to return (default 200). Completion is the one verb whose result size " +
143
+ "is a function of project scope rather than of the cursor — one call was measured at 342,116 bytes, " +
144
+ "more than the entire tool catalogue — so the list is capped and `truncated` says when it was."),
145
+ },
146
+ }, async ({ path, line, character, max_results }) => {
131
147
  try {
132
148
  const uri = await openAndPos(path);
133
149
  const result = await cslsp.request("textDocument/completion", { textDocument: { uri }, position: { line, character } });
@@ -136,7 +152,7 @@ export function registerCsLspTools(server, cslsp, cfg) {
136
152
  const it = i;
137
153
  return { label: it.label ?? "", kind: it.kind ? COMPLETION_KIND[it.kind] ?? String(it.kind) : "", detail: it.detail ?? "", insertText: it.insertText ?? it.label ?? "" };
138
154
  });
139
- return ok({ items });
155
+ return ok(capList(items, max_results ?? COMPLETION_LIMIT));
140
156
  }
141
157
  catch (err) {
142
158
  return fail(err);
@@ -1,5 +1,6 @@
1
1
  import { ok, failPath } from "../lsp-common.js";
2
2
  import { resolveInsideProject } from "../../paths.js";
3
+ import { remedyClause } from "../../bridge.js";
3
4
  /**
4
5
  * MCP error envelope for a failed editor-bridge call (never throws to the
5
6
  * caller). Distinct from lsp-common's `fail` (which labels errors "LSP error");
@@ -11,7 +12,7 @@ export function fail(err) {
11
12
  const message = be?.message ?? String(err);
12
13
  return {
13
14
  isError: true,
14
- content: [{ type: "text", text: `Bridge error [${code}]: ${message}` }],
15
+ content: [{ type: "text", text: `Bridge error [${code}]: ${message}${remedyClause(err)}` }],
15
16
  };
16
17
  }
17
18
  /**
@@ -18,6 +18,29 @@ export const SYMBOL_KIND = {
18
18
  14: "constant", 15: "string", 16: "number", 17: "boolean", 18: "array", 19: "object",
19
19
  20: "key", 21: "null", 22: "enumMember", 23: "struct", 24: "event", 25: "operator", 26: "typeParameter",
20
20
  };
21
+ /**
22
+ * The default ceiling on a completion list, and the only reason there is one.
23
+ *
24
+ * 🔴 MEASURED: ONE `gd_completion` RETURNED 342,116 B (249) — 99.6% of the whole
25
+ * 279-tool `tools/list` surface, in a single result, and pretty-printed and shipped
26
+ * twice by `ok()` below. Completion is the one language-server verb whose result size
27
+ * is a function of PROJECT scope rather than of the cursor: at most positions it is
28
+ * every global class, every autoload and every in-scope built-in. `gd_hover`,
29
+ * `gd_definition` and their C# twins are bounded by the thing under the cursor and are
30
+ * not in this population.
31
+ *
32
+ * 200 is the knowledge family's shipped default — `knowledge.ts` caps grep, symbols and
33
+ * usages there and returns a `truncated` flag beside them. The same number, because a
34
+ * second convention would be a second thing to keep true.
35
+ */
36
+ export const COMPLETION_LIMIT = 200;
37
+ /**
38
+ * Cap a list and say so. The flag is the whole point: a silently short list reads as a
39
+ * complete one, which is the failure `knowledge.ts` avoided the same way.
40
+ */
41
+ export function capList(items, max) {
42
+ return items.length > max ? { items: items.slice(0, max), truncated: true } : { items, truncated: false };
43
+ }
21
44
  /** MCP success envelope: human-readable JSON text plus the structured content. */
22
45
  export function ok(obj) {
23
46
  return {
package/dist/tools/lsp.js CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import { z } from "zod";
3
3
  import { toFileUri, toFsPath, readFileText, resolveSourceFile } from "../paths.js";
4
4
  import { gate } from "../confirm.js";
5
- import { COMPLETION_KIND, SYMBOL_KIND, ok, fail, markupToString, isMethodNotFound, normalizeLocations, applyTextEdits, } from "./lsp-common.js";
5
+ import { COMPLETION_KIND, SYMBOL_KIND, ok, fail, markupToString, isMethodNotFound, normalizeLocations, applyTextEdits, capList, COMPLETION_LIMIT } from "./lsp-common.js";
6
6
  /**
7
7
  * The wording this plane's refusals have shipped with, kept verbatim while the guard
8
8
  * itself moved to `paths.ts` (161 §8 item 5). `lsp-plane.integration.mjs` pins every
@@ -238,7 +238,22 @@ export function registerLspTools(server, lsp, cfg) {
238
238
  line: z.number().int().min(0).describe("0-based line"),
239
239
  character: z.number().int().min(0).describe("0-based character"),
240
240
  };
241
- server.registerTool("gd_completion", { title: "GDScript completion", description: "Type-aware code completion at a position via the Godot language server.", inputSchema: posSchema }, async ({ path, line, character }) => {
241
+ server.registerTool("gd_completion", {
242
+ title: "GDScript completion",
243
+ description: "Type-aware code completion at a position via the Godot language server. Returns at most max_results " +
244
+ "items (default 200) and sets truncated when the language server offered more.",
245
+ inputSchema: {
246
+ ...posSchema,
247
+ max_results: z
248
+ .number()
249
+ .int()
250
+ .positive()
251
+ .optional()
252
+ .describe("Maximum completion items to return (default 200). Completion is the one verb whose result size " +
253
+ "is a function of project scope rather than of the cursor — one call was measured at 342,116 bytes, " +
254
+ "more than the entire tool catalogue — so the list is capped and `truncated` says when it was."),
255
+ },
256
+ }, async ({ path, line, character, max_results }) => {
242
257
  try {
243
258
  const uri = await openAndPos(path);
244
259
  const result = await lsp.request("textDocument/completion", { textDocument: { uri }, position: { line, character } });
@@ -247,7 +262,7 @@ export function registerLspTools(server, lsp, cfg) {
247
262
  const it = i;
248
263
  return { label: it.label ?? "", kind: it.kind ? COMPLETION_KIND[it.kind] ?? String(it.kind) : "", detail: it.detail ?? "", insertText: it.insertText ?? it.label ?? "" };
249
264
  });
250
- return ok({ items });
265
+ return ok(capList(items, max_results ?? COMPLETION_LIMIT));
251
266
  }
252
267
  catch (err) {
253
268
  return fail(err);
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { remedyClause } from "../bridge.js";
2
3
  import { gate } from "../confirm.js";
3
4
  import { readFileText, resolveExistingFile, resolveInsideProject } from "../paths.js";
4
5
  /**
@@ -41,7 +42,7 @@ function fail(err) {
41
42
  const message = be?.message ?? String(err);
42
43
  return {
43
44
  isError: true,
44
- content: [{ type: "text", text: `Netcode scaffold error [${code}]: ${message}` }],
45
+ content: [{ type: "text", text: `Netcode scaffold error [${code}]: ${message}${remedyClause(err)}` }],
45
46
  };
46
47
  }
47
48
  // ------------------------------------------------------------- codegen ----