@npgamedev/godot-mcp-server 1.0.0 → 1.0.1

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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  # Godot MCP Server
5
5
 
6
6
  [![CI](https://github.com/NPGameDev/godot-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/NPGameDev/godot-mcp-server/actions/workflows/ci.yml)
7
- ![version](https://img.shields.io/badge/version-1.0.0-blue)
7
+ [![npm](https://img.shields.io/npm/v/@npgamedev/godot-mcp-server)](https://www.npmjs.com/package/@npgamedev/godot-mcp-server)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
9
9
 
10
10
  The npm bridge that connects AI coding assistants to the Godot 4.2+ editor over the [Model Context Protocol](https://modelcontextprotocol.io). Your assistant can create scenes, edit scripts, inspect nodes, run playtests, and read the results back, directly inside the editor while you watch. It pairs with the [Godot MCP Toolkit](https://github.com/NPGameDev/godot-mcp-toolkit) editor plugin, which hosts the WebSocket servers this bridge talks to.
@@ -118,7 +118,7 @@ Launch your MCP client from the project root. The server discovers the plugin th
118
118
  Then try the first prompt below. This is the kind of result it produces:
119
119
 
120
120
  <!-- captured: pre-1.0, Godot 4.7, 2026-07-24, human-recorded (editor + MCP dock, 1 peer connected, game running); image lives in the toolkit repo (docs/media/), absolute raw URL so it renders on npm. -->
121
- ![The Godot 4.7 editor driving the toolkit: the brick-breaker scene open in the viewport, the game running in a debug window with a full wall of colored bricks, and the MCP dock reporting "Listening on 127.0.0.1:6550" with 1 connected peer](https://raw.githubusercontent.com/NPGameDev/godot-mcp-toolkit/main/docs/media/editor-dock-breakout.png)
121
+ ![The Godot 4.7 editor driving the toolkit: the brick-breaker scene open in the viewport, the game running in a debug window with a full wall of colored bricks, and the MCP dock reporting "Listening on 127.0.0.1:6550" with 1 connected peer](https://raw.githubusercontent.com/NPGameDev/godot-mcp-toolkit/main/docs/media/editor-dock-brick-breaker.png)
122
122
 
123
123
  If a step does not produce its "you should see", head to the [troubleshooting guide](https://github.com/NPGameDev/godot-mcp-toolkit/blob/main/docs/troubleshooting.md). It starts with a 60-second checklist and a connectivity probe.
124
124
 
@@ -132,7 +132,7 @@ If a step does not produce its "you should see", head to the [troubleshooting gu
132
132
  The last three run in seconds. The first one is a real project, the same kind of small game we build end-to-end when validating a release, in a single agent session. Larger games span multiple sessions, with or without MCP.
133
133
 
134
134
  <!-- captured: pre-1.0, Godot 4.5, 2026-07-24, via runtime_screenshot of the running brick-breaker (mid-flight); image lives in the toolkit repo (docs/media/). -->
135
- ![The agent-built brick-breaker running: a full wall of red, orange, yellow, green, and blue bricks on a dark background, the ball mid-flight, a paddle at the bottom, and a Score and Lives readout](https://raw.githubusercontent.com/NPGameDev/godot-mcp-toolkit/main/docs/media/breakout-running.png)
135
+ ![The agent-built brick-breaker running: a full wall of red, orange, yellow, green, and blue bricks on a dark background, the ball mid-flight, a paddle at the bottom, and a Score and Lives readout](https://raw.githubusercontent.com/NPGameDev/godot-mcp-toolkit/main/docs/media/brick-breaker-running.png)
136
136
 
137
137
  *The brick-breaker from prompt 1 above.*
138
138
 
@@ -60,9 +60,14 @@ export function isVersionCompatible(connected, min, max) {
60
60
  *
61
61
  * Returns:
62
62
  * "ok" — versions match (all components equal)
63
- * "minor" — same major, different minor or patch
63
+ * "patch" — same major+minor, different patch
64
+ * "minor" — same major, different minor
64
65
  * "major" — different major version
65
66
  * "unknown" — remote is undefined/empty (pre-handshake peer)
67
+ *
68
+ * A patch difference is its own severity because compatibility floors are declared
69
+ * at major.minor (ADR 0024) — the patch segment carries no compatibility meaning, so
70
+ * a patch-level difference between the two halves is not something to warn about.
66
71
  */
67
72
  export function compareVersions(local, remote) {
68
73
  if (remote == null || remote === "")
@@ -76,7 +81,9 @@ export function compareVersions(local, remote) {
76
81
  }
77
82
  if (localParts[0] !== remoteParts[0])
78
83
  return "major";
79
- if (localParts[1] !== remoteParts[1] || localParts[2] !== remoteParts[2])
84
+ if (localParts[1] !== remoteParts[1])
80
85
  return "minor";
86
+ if (localParts[2] !== remoteParts[2])
87
+ return "patch";
81
88
  return "ok";
82
89
  }
@@ -1,22 +1,59 @@
1
- /** Install SIGINT/SIGTERM graceful shutdown (await bridge.close exit 0) and the
2
- * unhandledRejection / uncaughtException stderr loggers that keep the bridge alive. */
1
+ /** Upper bound on the graceful close. A frozen editor makes the WebSocket close
2
+ * handshake wait for ws's 30 s timeout; a departing server exits well before that. */
3
+ export const SHUTDOWN_DEADLINE_MS = 2_000;
4
+ /** Write a diagnostic line without ever throwing. A file-backed stderr can throw
5
+ * synchronously; a pipe-backed one reports failure through its 'error' event, which
6
+ * the muted listener in installProcessHandlers absorbs. Either way the caller continues. */
7
+ export function logSafely(line) {
8
+ try {
9
+ process.stderr.write(line);
10
+ }
11
+ catch {
12
+ // The diagnostics sink is unusable; dropping the line is the only safe option.
13
+ }
14
+ }
15
+ /** Install the departure shutdown (stdin EOF, dead stdout, SIGINT/SIGTERM → await
16
+ * bridge.close bounded by SHUTDOWN_DEADLINE_MS → exit 0), the muted stderr listener,
17
+ * and the unhandledRejection / uncaughtException loggers that keep the bridge alive. */
3
18
  export function installProcessHandlers(bridge) {
4
- async function shutdown() {
5
- try {
6
- await bridge.close();
7
- }
8
- finally {
9
- process.exit(0);
10
- }
19
+ let shuttingDown = false;
20
+ // The single exit path. First trigger wins: a dying client can deliver stdin EOF, a
21
+ // stream error and a signal within one tick, and bridge.close() must run once. The
22
+ // close races the deadline so a hung peer can never keep a departed server alive.
23
+ // Always exit 0: the server ended because its job ended, not because it failed.
24
+ function departureShutdown(reason) {
25
+ if (shuttingDown)
26
+ return;
27
+ shuttingDown = true;
28
+ logSafely(`[godot-mcp] shutting down: ${reason}\n`);
29
+ const deadline = setTimeout(() => process.exit(0), SHUTDOWN_DEADLINE_MS);
30
+ deadline.unref();
31
+ void bridge.close().then(() => process.exit(0), () => process.exit(0));
11
32
  }
12
- process.on("SIGINT", shutdown);
13
- process.on("SIGTERM", shutdown);
14
- // Prevent unhandled errors from crashing the bridge process.
15
- // Log to stderr for diagnostics; the bridge stays alive.
33
+ process.on("SIGINT", () => departureShutdown("SIGINT"));
34
+ process.on("SIGTERM", () => departureShutdown("SIGTERM"));
35
+ // Client departure: the client closed its end of our stdin. Fires whether or not we
36
+ // ever write again, and it is the only signal that fires while an editor socket keeps
37
+ // the event loop alive.
38
+ process.stdin.on("end", () => departureShutdown("stdin closed by the client"));
39
+ process.stdin.on("close", () => departureShutdown("stdin closed"));
40
+ // Transport dead: any stdout failure means no response can reach the client — not
41
+ // tied to one error code on purpose (an unlisted code would leave a zombie).
42
+ process.stdout.on("error", (err) => {
43
+ departureShutdown(`stdout unwritable (${err?.code ?? err?.message ?? "unknown"})`);
44
+ });
45
+ // Diagnostics gone: swallow. The listener's presence is the fix — an 'error' with
46
+ // no listener is thrown, lands in uncaughtException, gets logged to the same dead
47
+ // stderr, and so on forever. Nothing can be logged about a dead log sink.
48
+ process.stderr.on("error", () => {
49
+ /* muted */
50
+ });
51
+ // Stray errors never end the process (§7.6). Logging goes through logSafely so a
52
+ // dead stderr cannot re-enter these handlers.
16
53
  process.on("unhandledRejection", (reason) => {
17
- process.stderr.write(`[godot-mcp] unhandledRejection: ${reason}\n`);
54
+ logSafely(`[godot-mcp] unhandledRejection: ${String(reason)}\n`);
18
55
  });
19
56
  process.on("uncaughtException", (err) => {
20
- process.stderr.write(`[godot-mcp] uncaughtException: ${err?.stack ?? err}\n`);
57
+ logSafely(`[godot-mcp] uncaughtException: ${err?.stack ?? err}\n`);
21
58
  });
22
59
  }
@@ -132,6 +132,9 @@ export function createChannel(url, projectPath, onAuthResolved, onNotification,
132
132
  if (!skipVersionCheck) {
133
133
  const serverVer = getServerVersion();
134
134
  const severity = compareVersions(serverVer, authResp.toolkitVersion);
135
+ // "patch" deliberately matches no branch below. Compatibility floors are declared at
136
+ // major.minor (ADR 0024), so a patch difference is compatible by construction and stays
137
+ // silent by fall-through — the omission is the behaviour, not an oversight.
135
138
  if (severity === "major") {
136
139
  process.stderr.write(`[bridge] ERROR: major version mismatch — server ${serverVer}, toolkit ${authResp.toolkitVersion}. Update both to the same major version.\n`);
137
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npgamedev/godot-mcp-server",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "MCP server that connects AI coding assistants to the Godot 4.2+ editor (scenes, nodes, scripts, ClassDB, playtests) via the companion godot-mcp-toolkit plugin.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -49,6 +49,7 @@
49
49
  "test:integration:portpin": "tsx test/integration/port-pin-probe.ts",
50
50
  "repro:lsp-conflict": "tsx test/integration/lsp-conflict-repro.ts",
51
51
  "probe:screenshot": "tsx test/screenshot-window-probe.ts",
52
+ "probe:departure": "tsx test/probes/client-departure-probe.ts",
52
53
  "stress:dispatch": "tsx test/integration/dispatch-safety-stress.ts",
53
54
  "lint": "eslint src/ test/",
54
55
  "lint:fix": "eslint src/ test/ --fix",