breakpoint-mcp 1.78.1 → 1.78.2

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/bridge.js CHANGED
@@ -5,6 +5,7 @@ import { OverdueLedger } from "./late-reply.js";
5
5
  import { findNonFinite, describeNonFinite, tolerate, TOLERANT_METHODS } from "./finiteness.js";
6
6
  import { remedyForWireError } from "./remedies.js";
7
7
  import { closeDetail, closeRemedy } from "./close-cause.js";
8
+ import { connectHint, connectRemedy } from "./connect-cause.js";
8
9
  export class BridgeError extends Error {
9
10
  code;
10
11
  /**
@@ -121,7 +122,17 @@ export class BridgeClient {
121
122
  * which is worse than saying nothing. Defaults keep the editor bridge's
122
123
  * wording byte-identical to what shipped.
123
124
  */
124
- deadlineKnob = "BREAKPOINT_BRIDGE_TIMEOUT_MS", peerNoun = "the editor") {
125
+ deadlineKnob = "BREAKPOINT_BRIDGE_TIMEOUT_MS", peerNoun = "the editor",
126
+ /**
127
+ * The env var that names THIS instance's HOST, for the same per-INSTANCE reason
128
+ * `deadlineKnob` exists (265). The editor bridge takes its address from
129
+ * BREAKPOINT_BRIDGE_HOST and the runtime bridge and its peers from
130
+ * BREAKPOINT_RUNTIME_HOST; an unresolved-host remedy naming the wrong one sends the
131
+ * operator to a knob that cannot move the address they just failed to reach — the
132
+ * identical defect the late-reply line was given its own knob to avoid. Defaults keep
133
+ * the editor bridge's wording byte-identical to what shipped.
134
+ */
135
+ hostKnob = "BREAKPOINT_BRIDGE_HOST") {
125
136
  this.host = host;
126
137
  this.port = port;
127
138
  this.defaultTimeoutMs = defaultTimeoutMs;
@@ -133,9 +144,12 @@ export class BridgeClient {
133
144
  // peer a late reply does, and the two sentences drifting apart would be a bug nobody
134
145
  // could see from either side.
135
146
  this.peerNoun = peerNoun;
147
+ this.hostKnob = hostKnob;
136
148
  }
137
149
  /** Who answers this client — read by the close path and the late-reply ledger alike. */
138
150
  peerNoun;
151
+ /** The env var that moves this client's host — read by the connect path (265). */
152
+ hostKnob;
139
153
  /**
140
154
  * A cause this client cannot see, supplied by whoever CAN, as the remedy on a timeout.
141
155
  *
@@ -215,7 +229,16 @@ export class BridgeClient {
215
229
  // Recorded for onClose(): if the connection was already up, this reject()
216
230
  // is a no-op on a settled promise and the errno would otherwise be lost.
217
231
  this.lastSocketError = err;
218
- reject(new BridgeError(BRIDGE_UNAVAILABLE, `Cannot reach the Godot ${this.label} at ${this.host}:${this.port}. ${this.hint} (${err.message})`));
232
+ // 🔴 THE ERRNO WAS ALREADY HERE AND ONLY ITS `message` WAS READ (265), which is
233
+ // 264's close-family finding one step earlier in the same file. `err.code`
234
+ // separates a port nothing is listening on from a host name that never resolved,
235
+ // and the hint below was appended to BOTH — telling a caller whose
236
+ // BREAKPOINT_*_HOST is a typo to go and look at Godot, for a connect where no
237
+ // packet ever left this machine. `connectHint` suppresses the hint in exactly
238
+ // that case and returns it untouched otherwise, so every other message a caller
239
+ // reads is byte-identical to what shipped. See connect-cause.ts.
240
+ const hint = connectHint(err, this.hint);
241
+ reject(new BridgeError(BRIDGE_UNAVAILABLE, `Cannot reach the Godot ${this.label} at ${this.host}:${this.port}.${hint ? ` ${hint}` : ""} (${err.message})`, connectRemedy(err, this.peerNoun, this.hostKnob)));
219
242
  });
220
243
  socket.on("data", (chunk) => this.onData(chunk));
221
244
  socket.on("close", () => this.onClose());
@@ -0,0 +1,124 @@
1
+ /**
2
+ * What a FAILED CONNECT's errno discriminates, and the next action for each.
3
+ *
4
+ * 🔴 WHY THIS EXISTS — 264 answered the CLOSE family and left the CONNECT site in the
5
+ * census's silent bucket. They are the same shape one step apart: `close-cause.ts` was
6
+ * written because five close handlers read `cause.message` and threw `cause.code` away,
7
+ * and `bridge.ts`'s connect handler does exactly that too. Measured at 265 against real
8
+ * sockets, two families:
9
+ *
10
+ * ```
11
+ * nothing bound the port code=ECONNREFUSED syscall=connect "connect ECONNREFUSED 127.0.0.1:43305"
12
+ * host name does not resolve code=ENOTFOUND syscall=getaddrinfo "getaddrinfo ENOTFOUND not-a-real-host.invalid"
13
+ * ```
14
+ *
15
+ * and what a caller received for BOTH was one code and one hint:
16
+ *
17
+ * ```
18
+ * [bridge_unavailable] Cannot reach the Godot editor bridge at 127.0.0.1:43305.
19
+ * Is the editor open with the "Breakpoint MCP" plugin enabled? (connect ECONNREFUSED …)
20
+ * [bridge_unavailable] Cannot reach the Godot editor bridge at not-a-real-host.invalid:6010.
21
+ * Is the editor open with the "Breakpoint MCP" plugin enabled? (getaddrinfo ENOTFOUND …)
22
+ * ```
23
+ *
24
+ * 🔴 AND THE SECOND ONE IS NOT MERELY UNHELPFUL, IT IS WRONG ABOUT WHAT HAPPENED. On
25
+ * ENOTFOUND the name never resolved, so nothing was contacted: no packet left this
26
+ * machine, and whether the editor is open and its plugin enabled had no bearing on the
27
+ * failure. The caller with a typo in `BREAKPOINT_BRIDGE_HOST` is sent to look at Godot.
28
+ * That is 263 §3.2's rule — a capability read is a question for somebody, and there may be
29
+ * nobody — arriving at the transport: the hint answers "why can this peer not serve me",
30
+ * and in the unresolved case there is no peer on the other end of the question.
31
+ *
32
+ * 🔴 THE HINT IS SUPPRESSED RATHER THAN REPLACED. `connectHint` returns `""` for the
33
+ * unresolved family and the instance's own hint for everything else, so the message a
34
+ * caller reads is BYTE-IDENTICAL to what shipped in every case except the one that was
35
+ * making a false claim. The next action goes in the `remedy` FIELD, which is the channel
36
+ * `remedyClause` reads and 264 §1.2 found seven other sites answering around.
37
+ *
38
+ * 🔴 AND WHAT IT DELIBERATELY DOES NOT DO — 264 §3.3's rule, kept. An errno outside the
39
+ * two measured families gets NO remedy and NO hint change: `classifyConnect` returns
40
+ * `"unclassified"` and `connectRemedy` returns `undefined`, with a test asserting it.
41
+ * EHOSTUNREACH, ETIMEDOUT, EACCES and EAI_AGAIN are all plausible here and NONE of them
42
+ * was driven — this container's egress answers an unroutable address with ECONNREFUSED,
43
+ * so the case could not be produced honestly and is therefore not spoken for. A third
44
+ * family inheriting one of these sentences by default would be a claim nobody made
45
+ * wearing the authority of one that was measured.
46
+ */
47
+ /**
48
+ * Errnos that mean the address was reached and the connection was REFUSED.
49
+ *
50
+ * ECONNREFUSED: the host's stack answered with RST — the machine is there, and nothing is
51
+ * listening on that port. It is the ordinary "the editor is not running" case, and it is
52
+ * the one case where the instance hint is exactly right.
53
+ */
54
+ export const REFUSED_CONNECT_CODES = new Set(["ECONNREFUSED"]);
55
+ /**
56
+ * Errnos that mean the host NAME never became an address, so nothing was contacted.
57
+ *
58
+ * ENOTFOUND: `getaddrinfo` found no record. Note the syscall — this failure happens
59
+ * before any packet is sent, which is why the peer's own state cannot be the cause and
60
+ * the hint that names it has to go.
61
+ *
62
+ * EAI_AGAIN is deliberately ABSENT: it is a TEMPORARY resolver failure, its next action
63
+ * is "retry" rather than "fix the name", and 265 did not drive it.
64
+ */
65
+ export const UNRESOLVED_HOST_CODES = new Set(["ENOTFOUND"]);
66
+ /**
67
+ * Which of the three a failed connect was.
68
+ *
69
+ * Unlike `classifyClose`, a missing `cause` is NOT a meaningful state here: a connect
70
+ * that fails always fails with an error, and this handler is only ever reached from
71
+ * `socket.once("error")`. An absent cause is therefore `"unclassified"`, not a fourth
72
+ * family invented to fill the slot.
73
+ */
74
+ export function classifyConnect(cause) {
75
+ if (!cause)
76
+ return "unclassified";
77
+ const code = cause.code;
78
+ if (typeof code !== "string")
79
+ return "unclassified";
80
+ if (REFUSED_CONNECT_CODES.has(code))
81
+ return "refused";
82
+ if (UNRESOLVED_HOST_CODES.has(code))
83
+ return "unresolved";
84
+ return "unclassified";
85
+ }
86
+ /**
87
+ * The hint that belongs in the MESSAGE, given what actually failed.
88
+ *
89
+ * Returns the instance's own hint unchanged for every family except `"unresolved"`,
90
+ * where the hint is a statement about a peer that was never contacted. Suppressing is
91
+ * the whole change: nothing is reworded, so every existing caller and test reading the
92
+ * refused case reads the same bytes.
93
+ */
94
+ export function connectHint(cause, hint) {
95
+ return classifyConnect(cause) === "unresolved" ? "" : hint;
96
+ }
97
+ /**
98
+ * The next action, given who the peer was and which env var names this client's host —
99
+ * or `undefined` when the errno does not determine one.
100
+ *
101
+ * `hostKnob` is per-INSTANCE for the same reason `deadlineKnob` is (see `BridgeClient`'s
102
+ * constructor): the editor bridge is configured by `BREAKPOINT_BRIDGE_HOST` and the
103
+ * runtime bridge and its peers by `BREAKPOINT_RUNTIME_HOST`, and a sentence naming the
104
+ * wrong one sends the operator to a knob that cannot move the address they just failed
105
+ * to reach.
106
+ *
107
+ * Written to the grammar check 28 enforces on `error_remedies.gd`: one imperative at the
108
+ * head, a full stop at the end, and nothing named that does not resolve.
109
+ */
110
+ export function connectRemedy(cause, peer, hostKnob) {
111
+ switch (classifyConnect(cause)) {
112
+ case "refused":
113
+ // The errno is NOT repeated: `bridge.ts` already puts `(connect ECONNREFUSED …)` in
114
+ // the sentence immediately to the left, and a second copy teaches nobody anything.
115
+ return (`Start ${peer} and retry — the machine answered and refused the connection, ` +
116
+ `so the address is right and nothing is listening on that port.`);
117
+ case "unresolved":
118
+ return (`Check ${hostKnob} — the host name did not resolve, so nothing was contacted ` +
119
+ `and the state of ${peer} is not what failed here.`);
120
+ default:
121
+ return undefined;
122
+ }
123
+ }
124
+ //# sourceMappingURL=connect-cause.js.map
package/dist/index.js CHANGED
@@ -30,7 +30,9 @@ async function main() {
30
30
  // This instance's deadline comes from BREAKPOINT_RUNTIME_TIMEOUT_MS, not the
31
31
  // editor bridge's variable — a late reply here must name the knob that can
32
32
  // actually move it, and the peer that answered is the running game.
33
- "BREAKPOINT_RUNTIME_TIMEOUT_MS", "the running game");
33
+ "BREAKPOINT_RUNTIME_TIMEOUT_MS", "the running game",
34
+ // And its ADDRESS comes from BREAKPOINT_RUNTIME_HOST, for the same reason (265).
35
+ "BREAKPOINT_RUNTIME_HOST");
34
36
  const lsp = new LspClient(config.lspHost, config.lspPort, config.projectUri, config.lspTimeoutMs);
35
37
  // D4 C2: the C# semantic plane. OmniSharp is spawned over stdio (lazily, on the
36
38
  // first cs_* call) against the C# project root — so a host without OmniSharp
@@ -50,7 +52,7 @@ async function main() {
50
52
  // so long jobs (export/import/headless script) support poll/await/cancel.
51
53
  // D3: also advertise resources.subscribe so clients can subscribe to
52
54
  // godot://… resources and receive notifications/resources/updated.
53
- const server = new McpServer({ name: "breakpoint-mcp", version: "1.78.1" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
55
+ const server = new McpServer({ name: "breakpoint-mcp", version: "1.78.2" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
54
56
  // 208 §7.1 — delete from the wire what the protocol already says: the SDK's
55
57
  // draft-07 `$schema` inside every input/output schema (MCP's default dialect is
56
58
  // 2020-12, and declaring another is an explicit switch a peer may reject), and
package/dist/peers.js CHANGED
@@ -44,6 +44,35 @@ export class PeerRegistry {
44
44
  all() {
45
45
  return [...this.peers.values()].map((e) => e.info);
46
46
  }
47
+ /**
48
+ * The one `unknown_peer` refusal, raised from both the read path and the write path.
49
+ *
50
+ * 🔴 WHY THIS IS A FUNCTION (265). `clientFor` and `stop` both refused an unknown id
51
+ * with the code `unknown_peer`, off the SAME registry, in two sentences that had drifted:
52
+ *
53
+ * ```
54
+ * clientFor("peer-9") No peer with id "peer-9". Live peers: (none). Spawn peers with runtime_spawn_peers, or omit `peer`…
55
+ * stop("peer-9") No peer with id "peer-9". Live peers: (none).
56
+ * ```
57
+ *
58
+ * One named a next action and the other stopped dead after the ids, and nothing in this
59
+ * repository could see the difference — 264 §1.2 measured why: a next action written
60
+ * into a message BODY reaches no reader, so `remedyClause()` returned `""` for both and
61
+ * check 28's grammar arm judged neither. The answer moves into the `remedy` FIELD, which
62
+ * is the channel that IS read, and the two paths stop being two sentences.
63
+ *
64
+ * 🔴 AND THE REMEDY IS BRANCHED ON THE REGISTRY, which is the point of 264's "silent,
65
+ * but the host holds something that discriminates" column. Telling a caller with three
66
+ * live peers to spawn some is the wrong next action; telling a caller with none to use
67
+ * one of the ids above names nothing. The registry answers which sentence is true, and
68
+ * it was in hand at both sites the whole time.
69
+ */
70
+ unknownPeer(id) {
71
+ const ids = this.live().map((p) => p.id);
72
+ return new BridgeError("unknown_peer", `No peer with id "${id}". Live peers: ${ids.length ? ids.join(", ") : "(none)"}.`, ids.length
73
+ ? `Address one of the live peer ids above, or omit \`peer\` to address the default running game.`
74
+ : `Spawn peers with runtime_spawn_peers, or omit \`peer\` to address the default running game.`);
75
+ }
47
76
  /**
48
77
  * The bridge client addressing one peer. Throws a BridgeError the runtime
49
78
  * tools' `fail()` renders — an unknown or dead peer must read as a clear
@@ -51,11 +80,8 @@ export class PeerRegistry {
51
80
  */
52
81
  clientFor(id) {
53
82
  const e = this.peers.get(id);
54
- if (!e) {
55
- const ids = this.live().map((p) => p.id);
56
- throw new BridgeError("unknown_peer", `No peer with id "${id}". Live peers: ${ids.length ? ids.join(", ") : "(none)"}. ` +
57
- `Spawn peers with runtime_spawn_peers, or omit \`peer\` to address the default running game.`);
58
- }
83
+ if (!e)
84
+ throw this.unknownPeer(id);
59
85
  if (e.stopped)
60
86
  throw new BridgeError("peer_stopped", `Peer "${id}" was stopped by runtime_peer_stop.`);
61
87
  const m = this.procs.get(e.managedId);
@@ -149,7 +175,11 @@ export class PeerRegistry {
149
175
  // The FILE, not resolveBridgeSecret: the addon's _setup_auth() does not
150
176
  // read BREAKPOINT_*_SECRET, so a host-launched child authenticates with
151
177
  // whatever is on disk regardless of the host's env overrides.
152
- () => readProjectSecret(this.cfg.projectPath));
178
+ () => readProjectSecret(this.cfg.projectPath),
179
+ // A peer's deadline and peer-noun are its own, and so is its ADDRESS: peers are
180
+ // dialled on `cfg.runtimeHost`, so an unresolved-host remedy here must name
181
+ // BREAKPOINT_RUNTIME_HOST and not the editor bridge's variable (265).
182
+ "BREAKPOINT_RUNTIME_TIMEOUT_MS", `peer ${id}`, "BREAKPOINT_RUNTIME_HOST");
153
183
  const entry = {
154
184
  info: { id, port, pid: managed.child.pid ?? null, role: opts.role ?? null, ready: false },
155
185
  client,
@@ -184,10 +214,8 @@ export class PeerRegistry {
184
214
  /** Stop one peer (or all). Repeating is a no-op, so the tool is idempotent. */
185
215
  stop(id, all = false) {
186
216
  const targets = all ? [...this.peers.keys()] : id ? [id] : [];
187
- if (!all && id && !this.peers.has(id)) {
188
- const ids = this.live().map((p) => p.id);
189
- throw new BridgeError("unknown_peer", `No peer with id "${id}". Live peers: ${ids.length ? ids.join(", ") : "(none)"}.`);
190
- }
217
+ if (!all && id && !this.peers.has(id))
218
+ throw this.unknownPeer(id);
191
219
  const stopped = [];
192
220
  for (const t of targets) {
193
221
  const e = this.peers.get(t);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.78.1",
3
+ "version": "1.78.2",
4
4
  "description": "MCP server exposing Godot to AI coding assistants over the Model Context Protocol — developed and tested with Claude — across four planes (CLI, live editor, LSP+DAP, runtime) with elicitation-gated destructive tools, long jobs on the formal MCP task model, captured console output, and MCP resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",