breakpoint-mcp 1.78.1 → 1.78.3
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 +27 -4
- package/dist/connect-cause.js +124 -0
- package/dist/exit-cause.js +110 -0
- package/dist/finiteness.js +36 -0
- package/dist/index.js +4 -2
- package/dist/peers.js +53 -14
- package/dist/tools/processes.js +5 -3
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -2,9 +2,10 @@ import net from "node:net";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { log } from "./logger.js";
|
|
4
4
|
import { OverdueLedger } from "./late-reply.js";
|
|
5
|
-
import { findNonFinite, describeNonFinite, tolerate, TOLERANT_METHODS } from "./finiteness.js";
|
|
5
|
+
import { findNonFinite, describeNonFinite, tolerate, TOLERANT_METHODS, nonFiniteRemedy } 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
|
-
|
|
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());
|
|
@@ -319,7 +342,7 @@ export class BridgeClient {
|
|
|
319
342
|
else {
|
|
320
343
|
const hits = findNonFinite(result);
|
|
321
344
|
if (hits.length)
|
|
322
|
-
p.reject(new BridgeError("non_finite", describeNonFinite(hits)));
|
|
345
|
+
p.reject(new BridgeError("non_finite", describeNonFinite(hits), nonFiniteRemedy(hits)));
|
|
323
346
|
else
|
|
324
347
|
p.resolve(result);
|
|
325
348
|
}
|
|
@@ -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
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a MANAGED CHILD'S termination discriminates, and the next action for each.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 WHY THIS EXISTS — 265 answered the connect family and left the two peer sites in
|
|
5
|
+
* the census's silent bucket. Driven at 266 against real spawned children, they are the
|
|
6
|
+
* same shape one file over, and one of them was making a claim that is FALSE rather than
|
|
7
|
+
* merely absent:
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* what the caller read
|
|
11
|
+
* child exited (code 1) 1 of 1 peer(s) did not become ready. peer-1 on 21101: exited (code 1)
|
|
12
|
+
* — SCRIPT ERROR: could not load main scene. Is the Breakpoint MCP
|
|
13
|
+
* addon enabled in this project (it registers the runtime autoload)?
|
|
14
|
+
*
|
|
15
|
+
* child alive, no ping 1 of 1 peer(s) did not become ready. peer-1 on 21121: never answered
|
|
16
|
+
* ping. Is the Breakpoint MCP addon enabled in this project (it
|
|
17
|
+
* registers the runtime autoload)?
|
|
18
|
+
*
|
|
19
|
+
* two measured causes -> one question -> ZERO next actions
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* 🔴 READ THE FIRST ROW. The child EXITED, on its own, with a code and a stderr line
|
|
23
|
+
* naming what stopped it. Whether the addon is enabled had no bearing on that: an absent
|
|
24
|
+
* runtime autoload does not stop a process, it stops a process from ANSWERING. The
|
|
25
|
+
* operator whose scene fails to load is sent to go and look at the plugin list. That is
|
|
26
|
+
* 265 §1's ENOTFOUND finding at the process boundary — and `m?.exited`, the thing that
|
|
27
|
+
* says which sentence is true, is computed on the line directly above the one that pastes
|
|
28
|
+
* the question over both.
|
|
29
|
+
*
|
|
30
|
+
* 🔴 THE QUESTION IS SUPPRESSED, NOT REWORDED. `readinessHint` returns the shipped
|
|
31
|
+
* sentence unchanged when NO not-ready peer exited — the family it was always true of —
|
|
32
|
+
* and `""` the moment one did. Every caller that reads the silent case reads the same
|
|
33
|
+
* bytes, and there is a test asserting that direction specifically, because a build that
|
|
34
|
+
* dropped the question unconditionally would be a worse defect than the one being fixed
|
|
35
|
+
* and would pass a one-sided test.
|
|
36
|
+
*
|
|
37
|
+
* 🔴 AND THE SECOND SITE NAMED A CODE THAT IS NOT A CODE. `peer_exited` rendered
|
|
38
|
+
* `Peer "peer-1" exited (code null)` for a SIGKILLed child, because
|
|
39
|
+
* `ProcessRegistry.run` registered `child.on("exit", (code) => …)` and dropped node's
|
|
40
|
+
* SECOND argument. The signal was handed to that callback on every kill since the
|
|
41
|
+
* registry was written and assigned to nothing — 265's "a default that compiles asks no
|
|
42
|
+
* question", spelled here as a parameter nobody declared. `exitSignal` is captured now
|
|
43
|
+
* and `describeExit` says `killed by SIGKILL` where the sentence used to print a null.
|
|
44
|
+
*
|
|
45
|
+
* 🔵 WHAT THIS DELIBERATELY DOES NOT DO. `godot_process_output` still answers
|
|
46
|
+
* `exit_code: null` for a signal-killed child and carries no `signal` key: that is a wire
|
|
47
|
+
* addition to a shipped tool, it would make this cut a MINOR, and it is enqueued rather
|
|
48
|
+
* than folded in. The capture is internal; only the two peer sentences read it today.
|
|
49
|
+
*/
|
|
50
|
+
/** How a managed child ended, in the words a person would use. */
|
|
51
|
+
export function describeExit(code, signal) {
|
|
52
|
+
// The signal is checked FIRST because both can be non-null in no case node produces,
|
|
53
|
+
// and because `code` is the one that is null exactly when the signal is the answer.
|
|
54
|
+
if (signal)
|
|
55
|
+
return `killed by ${signal}`;
|
|
56
|
+
if (code === null)
|
|
57
|
+
return "exited without reporting a code";
|
|
58
|
+
return `exited (code ${code})`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The hint that belongs in the MESSAGE, given what actually happened to the children.
|
|
62
|
+
*
|
|
63
|
+
* Returns the shipped sentence unchanged while every not-ready peer is merely SILENT —
|
|
64
|
+
* the family it describes correctly — and suppresses it as soon as one has exited, where
|
|
65
|
+
* it is a statement about a plugin that could not have stopped a process.
|
|
66
|
+
*/
|
|
67
|
+
export function readinessHint(causes, hint) {
|
|
68
|
+
return causes.some((c) => c === "exited") ? "" : hint;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The next action for a peer that did not become ready, branched on the causes in hand.
|
|
72
|
+
*
|
|
73
|
+
* Three populations, because the registry really does produce three: every child gone,
|
|
74
|
+
* every child alive and mute, and a mixture. A mixture gets both actions named rather
|
|
75
|
+
* than the first one that matched, because telling an operator with one dead peer and one
|
|
76
|
+
* mute peer to do only half the work is the same error as the sentence this replaces.
|
|
77
|
+
*
|
|
78
|
+
* Written to the grammar check 28 enforces on `error_remedies.gd`: one imperative at the
|
|
79
|
+
* head, a full stop at the end, and nothing named that does not resolve.
|
|
80
|
+
*/
|
|
81
|
+
export function readinessRemedy(causes) {
|
|
82
|
+
if (causes.length === 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
const exited = causes.some((c) => c === "exited");
|
|
85
|
+
const silent = causes.some((c) => c === "silent");
|
|
86
|
+
if (exited && silent) {
|
|
87
|
+
return (`Read the output quoted above for the peer(s) that exited and fix what stopped them, ` +
|
|
88
|
+
`then check the Breakpoint MCP plugin is enabled for the one(s) that stayed up and never ` +
|
|
89
|
+
`answered — two different things failed here and only the second is about the addon.`);
|
|
90
|
+
}
|
|
91
|
+
if (exited) {
|
|
92
|
+
return (`Read the output quoted above and fix what stopped the child — it exited on its own, ` +
|
|
93
|
+
`so the state of the Breakpoint MCP plugin is not what failed here.`);
|
|
94
|
+
}
|
|
95
|
+
return (`Enable the "Breakpoint MCP" plugin in this project and retry — the peer is still running ` +
|
|
96
|
+
`and answered nothing on its port, which is what a missing runtime autoload looks like.`);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The next action for a request addressed to a peer whose process is already gone.
|
|
100
|
+
*
|
|
101
|
+
* Branched on whether the host CAPTURED any output from it: a child that said something
|
|
102
|
+
* before it died has a cause the caller can read, and one that said nothing has only the
|
|
103
|
+
* fact of its death, which makes re-spawning the next action rather than reading.
|
|
104
|
+
*/
|
|
105
|
+
export function peerExitRemedy(hasTail) {
|
|
106
|
+
return hasTail
|
|
107
|
+
? `Read the last output quoted above to see what stopped this peer, then spawn a replacement with runtime_spawn_peers.`
|
|
108
|
+
: `Spawn a replacement with runtime_spawn_peers — this peer captured no output before it went, so there is nothing here to read.`;
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=exit-cause.js.map
|
package/dist/finiteness.js
CHANGED
|
@@ -117,6 +117,42 @@ export function describeNonFinite(hits) {
|
|
|
117
117
|
`JSON cannot carry them — Godot stringifies INF as 1e99999 and NAN as null — so the ` +
|
|
118
118
|
`call is refused rather than reported as a success with the value silently missing.`);
|
|
119
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* The next action for a refused non-finite reply.
|
|
122
|
+
*
|
|
123
|
+
* 🔴 ONE FAMILY IS MEASURED AND ONE IS SPOKEN FOR (266). Driven against a raw-bytes fake
|
|
124
|
+
* addon writing what Godot writes, every value that reaches this refusal is `Infinity` or
|
|
125
|
+
* `-Infinity` from a literal `1e99999`, flat or nested, one hit or many. **NAN is NOT this
|
|
126
|
+
* refusal's population**, which the message's own aside makes easy to assume it is:
|
|
127
|
+
* measured, Godot's `null` for NAN parses to `null`, `findNonFinite({current: null})`
|
|
128
|
+
* returns `[]`, and the reply RESOLVES and reaches the client's schema as a null. So the
|
|
129
|
+
* remedy speaks about an infinity and about nothing else, per 265's refusal to invent.
|
|
130
|
+
*
|
|
131
|
+
* 🔴 AND IT DOES NOT SAY "RETRY". This is the one host-raised failure in the census whose
|
|
132
|
+
* cause is neither the transport nor the host's own state but the PROJECT'S DATA — the
|
|
133
|
+
* engine really does hold that value, and it will hold it again on the next call. A
|
|
134
|
+
* sentence offering a retry would be the shape 262 §2 found on `dbg_set_variable`: fluent,
|
|
135
|
+
* addressed to the wrong party, and wrong every time it is read.
|
|
136
|
+
*
|
|
137
|
+
* Branched on the one discriminator in hand: how many paths carry it. Naming a single
|
|
138
|
+
* path is a place to go and look; naming six is almost always one upstream value, and
|
|
139
|
+
* sending someone to inspect six properties one at a time is the wrong next action.
|
|
140
|
+
*
|
|
141
|
+
* Written to the grammar check 28 enforces on `error_remedies.gd`: one imperative at the
|
|
142
|
+
* head, a full stop at the end, and nothing named that does not resolve.
|
|
143
|
+
*/
|
|
144
|
+
export function nonFiniteRemedy(hits) {
|
|
145
|
+
if (hits.length === 0)
|
|
146
|
+
return undefined;
|
|
147
|
+
if (hits.length === 1) {
|
|
148
|
+
return (`Inspect ${hits[0].path} in the project — the engine's own value there is infinite ` +
|
|
149
|
+
`(a division by zero and an unset scale are the usual sources), so this is data to fix ` +
|
|
150
|
+
`rather than a call to repeat.`);
|
|
151
|
+
}
|
|
152
|
+
return (`Look for the one value feeding all ${hits.length} of the paths named above — an infinity ` +
|
|
153
|
+
`this wide is almost always a single division by zero propagated by the engine, so this is ` +
|
|
154
|
+
`data to fix rather than a call to repeat.`);
|
|
155
|
+
}
|
|
120
156
|
/**
|
|
121
157
|
* Drop every non-finite value from a flat monitor record, returning what survived and the
|
|
122
158
|
* keys that did not. The declared type of the record does not move: a reading that is not
|
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.
|
|
55
|
+
const server = new McpServer({ name: "breakpoint-mcp", version: "1.78.3" }, { 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
|
@@ -4,6 +4,7 @@ import { ensureProjectSecret, readProjectSecret } from "./secret.js";
|
|
|
4
4
|
import { log } from "./logger.js";
|
|
5
5
|
import { portFree } from "./ports.js";
|
|
6
6
|
import { waitForBridge } from "./readiness.js";
|
|
7
|
+
import { describeExit, peerExitRemedy, readinessHint, readinessRemedy } from "./exit-cause.js";
|
|
7
8
|
/**
|
|
8
9
|
* Hard ceiling on simultaneously live peers.
|
|
9
10
|
*
|
|
@@ -44,6 +45,35 @@ export class PeerRegistry {
|
|
|
44
45
|
all() {
|
|
45
46
|
return [...this.peers.values()].map((e) => e.info);
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* The one `unknown_peer` refusal, raised from both the read path and the write path.
|
|
50
|
+
*
|
|
51
|
+
* 🔴 WHY THIS IS A FUNCTION (265). `clientFor` and `stop` both refused an unknown id
|
|
52
|
+
* with the code `unknown_peer`, off the SAME registry, in two sentences that had drifted:
|
|
53
|
+
*
|
|
54
|
+
* ```
|
|
55
|
+
* clientFor("peer-9") No peer with id "peer-9". Live peers: (none). Spawn peers with runtime_spawn_peers, or omit `peer`…
|
|
56
|
+
* stop("peer-9") No peer with id "peer-9". Live peers: (none).
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* One named a next action and the other stopped dead after the ids, and nothing in this
|
|
60
|
+
* repository could see the difference — 264 §1.2 measured why: a next action written
|
|
61
|
+
* into a message BODY reaches no reader, so `remedyClause()` returned `""` for both and
|
|
62
|
+
* check 28's grammar arm judged neither. The answer moves into the `remedy` FIELD, which
|
|
63
|
+
* is the channel that IS read, and the two paths stop being two sentences.
|
|
64
|
+
*
|
|
65
|
+
* 🔴 AND THE REMEDY IS BRANCHED ON THE REGISTRY, which is the point of 264's "silent,
|
|
66
|
+
* but the host holds something that discriminates" column. Telling a caller with three
|
|
67
|
+
* live peers to spawn some is the wrong next action; telling a caller with none to use
|
|
68
|
+
* one of the ids above names nothing. The registry answers which sentence is true, and
|
|
69
|
+
* it was in hand at both sites the whole time.
|
|
70
|
+
*/
|
|
71
|
+
unknownPeer(id) {
|
|
72
|
+
const ids = this.live().map((p) => p.id);
|
|
73
|
+
return new BridgeError("unknown_peer", `No peer with id "${id}". Live peers: ${ids.length ? ids.join(", ") : "(none)"}.`, ids.length
|
|
74
|
+
? `Address one of the live peer ids above, or omit \`peer\` to address the default running game.`
|
|
75
|
+
: `Spawn peers with runtime_spawn_peers, or omit \`peer\` to address the default running game.`);
|
|
76
|
+
}
|
|
47
77
|
/**
|
|
48
78
|
* The bridge client addressing one peer. Throws a BridgeError the runtime
|
|
49
79
|
* tools' `fail()` renders — an unknown or dead peer must read as a clear
|
|
@@ -51,17 +81,17 @@ export class PeerRegistry {
|
|
|
51
81
|
*/
|
|
52
82
|
clientFor(id) {
|
|
53
83
|
const e = this.peers.get(id);
|
|
54
|
-
if (!e)
|
|
55
|
-
|
|
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
|
-
}
|
|
84
|
+
if (!e)
|
|
85
|
+
throw this.unknownPeer(id);
|
|
59
86
|
if (e.stopped)
|
|
60
87
|
throw new BridgeError("peer_stopped", `Peer "${id}" was stopped by runtime_peer_stop.`);
|
|
61
88
|
const m = this.procs.get(e.managedId);
|
|
62
89
|
if (m?.exited) {
|
|
63
90
|
const tail = this.procs.tail(e.managedId, 6).join(" | ");
|
|
64
|
-
|
|
91
|
+
// 🔴 `describeExit` RATHER THAN `exitCode` ALONE (266). A SIGKILLed child has a null
|
|
92
|
+
// code and a signal, and this sentence used to render the null: `exited (code null)`.
|
|
93
|
+
// See exit-cause.ts for the argument node was handing us and nobody declared.
|
|
94
|
+
throw new BridgeError("peer_exited", `Peer "${id}" ${describeExit(m.exitCode, m.exitSignal)}${tail ? `; last output: ${tail}` : ""}.`, peerExitRemedy(tail.length > 0));
|
|
65
95
|
}
|
|
66
96
|
return e.client;
|
|
67
97
|
}
|
|
@@ -149,7 +179,11 @@ export class PeerRegistry {
|
|
|
149
179
|
// The FILE, not resolveBridgeSecret: the addon's _setup_auth() does not
|
|
150
180
|
// read BREAKPOINT_*_SECRET, so a host-launched child authenticates with
|
|
151
181
|
// whatever is on disk regardless of the host's env overrides.
|
|
152
|
-
() => readProjectSecret(this.cfg.projectPath)
|
|
182
|
+
() => readProjectSecret(this.cfg.projectPath),
|
|
183
|
+
// A peer's deadline and peer-noun are its own, and so is its ADDRESS: peers are
|
|
184
|
+
// dialled on `cfg.runtimeHost`, so an unresolved-host remedy here must name
|
|
185
|
+
// BREAKPOINT_RUNTIME_HOST and not the editor bridge's variable (265).
|
|
186
|
+
"BREAKPOINT_RUNTIME_TIMEOUT_MS", `peer ${id}`, "BREAKPOINT_RUNTIME_HOST");
|
|
153
187
|
const entry = {
|
|
154
188
|
info: { id, port, pid: managed.child.pid ?? null, role: opts.role ?? null, ready: false },
|
|
155
189
|
client,
|
|
@@ -164,10 +198,16 @@ export class PeerRegistry {
|
|
|
164
198
|
}));
|
|
165
199
|
const notReady = spawned.filter((e) => !e.info.ready);
|
|
166
200
|
if (notReady.length) {
|
|
201
|
+
// 🔴 THE CAUSE IS COLLECTED, NOT JUST RENDERED (266). `m?.exited` decided one word
|
|
202
|
+
// of the sentence and was then thrown away, so the hint below was pasted over both
|
|
203
|
+
// measured causes and was FALSE on one of them — an absent runtime autoload does
|
|
204
|
+
// not stop a process, it stops a process from ANSWERING. See exit-cause.ts.
|
|
205
|
+
const causes = [];
|
|
167
206
|
const detail = notReady
|
|
168
207
|
.map((e) => {
|
|
169
208
|
const m = this.procs.get(e.managedId);
|
|
170
|
-
|
|
209
|
+
causes.push(m?.exited ? "exited" : "silent");
|
|
210
|
+
const why = m?.exited ? describeExit(m.exitCode, m.exitSignal) : "never answered ping";
|
|
171
211
|
const tail = this.procs.tail(e.managedId, 6).join(" | ");
|
|
172
212
|
return `${e.info.id} on ${e.info.port}: ${why}${tail ? ` — ${tail}` : ""}`;
|
|
173
213
|
})
|
|
@@ -176,18 +216,17 @@ export class PeerRegistry {
|
|
|
176
216
|
// can still runtime_peer_stop{all:true}, and a half-started peer that
|
|
177
217
|
// vanished from the registry is harder to reason about than one that
|
|
178
218
|
// reports why it is not ready.
|
|
179
|
-
|
|
180
|
-
|
|
219
|
+
const hint = readinessHint(causes, `Is the Breakpoint MCP addon enabled in this project (it registers the runtime autoload)?`);
|
|
220
|
+
throw new BridgeError("peer_not_ready", `${notReady.length} of ${spawned.length} peer(s) did not become ready. ${detail}.` +
|
|
221
|
+
(hint ? ` ${hint}` : ""), readinessRemedy(causes));
|
|
181
222
|
}
|
|
182
223
|
return spawned.map((e) => e.info);
|
|
183
224
|
}
|
|
184
225
|
/** Stop one peer (or all). Repeating is a no-op, so the tool is idempotent. */
|
|
185
226
|
stop(id, all = false) {
|
|
186
227
|
const targets = all ? [...this.peers.keys()] : id ? [id] : [];
|
|
187
|
-
if (!all && id && !this.peers.has(id))
|
|
188
|
-
|
|
189
|
-
throw new BridgeError("unknown_peer", `No peer with id "${id}". Live peers: ${ids.length ? ids.join(", ") : "(none)"}.`);
|
|
190
|
-
}
|
|
228
|
+
if (!all && id && !this.peers.has(id))
|
|
229
|
+
throw this.unknownPeer(id);
|
|
191
230
|
const stopped = [];
|
|
192
231
|
for (const t of targets) {
|
|
193
232
|
const e = this.peers.get(t);
|
package/dist/tools/processes.js
CHANGED
|
@@ -4,6 +4,7 @@ import { log } from "../logger.js";
|
|
|
4
4
|
import { ok, failPath } from "./lsp-common.js";
|
|
5
5
|
import { resolveInsideProject } from "../paths.js";
|
|
6
6
|
import { portFree, portConflictMessage } from "../ports.js";
|
|
7
|
+
import { describeExit } from "../exit-cause.js";
|
|
7
8
|
import { waitForRuntimeBridge, notReadyRemedy } from "../readiness.js";
|
|
8
9
|
const LINE_CAP = 5000;
|
|
9
10
|
/**
|
|
@@ -33,7 +34,7 @@ export class ProcessRegistry {
|
|
|
33
34
|
stdio: ["ignore", "pipe", "pipe"],
|
|
34
35
|
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
35
36
|
});
|
|
36
|
-
const m = { id, child, lines: [], seq: 0, exited: false, exitCode: null };
|
|
37
|
+
const m = { id, child, lines: [], seq: 0, exited: false, exitCode: null, exitSignal: null };
|
|
37
38
|
const ingest = (stream) => (buf) => {
|
|
38
39
|
const text = typeof buf === "string" ? buf : buf.toString("utf8");
|
|
39
40
|
for (const line of text.split(/\r?\n/)) {
|
|
@@ -47,10 +48,11 @@ export class ProcessRegistry {
|
|
|
47
48
|
};
|
|
48
49
|
child.stdout?.on("data", ingest("stdout"));
|
|
49
50
|
child.stderr?.on("data", ingest("stderr"));
|
|
50
|
-
child.on("exit", (code) => {
|
|
51
|
+
child.on("exit", (code, signal) => {
|
|
51
52
|
m.exited = true;
|
|
52
53
|
m.exitCode = code;
|
|
53
|
-
|
|
54
|
+
m.exitSignal = signal;
|
|
55
|
+
log(`managed process ${id} ${describeExit(code, signal)}`);
|
|
54
56
|
});
|
|
55
57
|
this.procs.set(id, m);
|
|
56
58
|
return m;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakpoint-mcp",
|
|
3
|
-
"version": "1.78.
|
|
3
|
+
"version": "1.78.3",
|
|
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",
|