@npgamedev/godot-mcp-server 1.0.1 → 1.0.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/README.md CHANGED
@@ -201,8 +201,8 @@ Everything works with zero configuration; the registry handles discovery. The va
201
201
  | `GODOT_MCP_TOKEN_PATH` | auto | Auth-token file override (default: the path the plugin publishes in the registry) |
202
202
  | `GODOT_MCP_READ_ONLY` | `0` | Set to `1` to hide every mutating tool (server-enforced) |
203
203
  | `GODOT_MCP_RATE_LIMIT` | `0` | Max tool calls per second (`0` = unlimited) |
204
- | `GODOT_MCP_SCRIPT_READ_LIMIT` | built-in cap | Size cap (bytes) on script-read responses |
205
- | `GODOT_MCP_WS_BUFFER_LIMIT` | built-in cap | Size cap (bytes) on the WebSocket receive buffer |
204
+ | `GODOT_MCP_SCRIPT_READ_LIMIT` | the project's setting | Overrides the editor's cap on script-read responses, in bytes (minimum 65536) |
205
+ | `GODOT_MCP_WS_BUFFER_LIMIT` | the project's setting | Overrides the editor's per-connection WebSocket buffer size, in bytes (minimum 262144). Applies to connections the editor accepts after the server pushes it, so it takes effect from the next connection |
206
206
  | `GODOT_MCP_CONFIG_VERSION` | written by the plugin | Config-schema version stamp in `.mcp.json`; the server warns on stderr if it is missing or does not match |
207
207
 
208
208
  ### CLI flags
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
- import { createBridge } from "./transport/bridge.js";
19
+ import { capKb, createBridge } from "./transport/bridge.js";
20
20
  import { isReadOnly } from "./security/profiles.js";
21
21
  import { toolRefCount } from "./registration/toolRefs.js";
22
22
  import { createHookPipeline } from "./startup/hooks.js";
@@ -72,9 +72,14 @@ const extensions = createExtensionManager({ server, bridge, getReadOnly: isReadO
72
72
  // discover is injected (extensions.discoverExtensions, a closure method — no
73
73
  // `this`) so reconcile.ts imports no other composition module (acyclic graph).
74
74
  const reconciler = createReconciler({ server, bridge, projectPath, discover: extensions.discoverExtensions });
75
+ // A cap the env sets is shown as the KB value the bridge pushes; an unset one as
76
+ // "project", because the editor's own setting governs it.
77
+ function capLabel(bytes) {
78
+ return bytes === undefined ? "project" : `${capKb(bytes)}KB`;
79
+ }
75
80
  function logStartup(extTimedOut = false) {
76
81
  const suffix = extTimedOut ? " (ext discovery timed out — extensions_refresh available)" : "";
77
- process.stderr.write(`[godot-mcp] readOnly=${isReadOnly()} tools=${toolRefCount()} hooks=${hookPipeline.length} caps=${caps.scriptReadLimitBytes / 1024}KB/${caps.wsBufferLimitBytes / 1024}KB${suffix}\n`);
82
+ process.stderr.write(`[godot-mcp] readOnly=${isReadOnly()} tools=${toolRefCount()} hooks=${hookPipeline.length} caps=${capLabel(caps.scriptReadLimitBytes)}/${capLabel(caps.wsBufferLimitBytes)}${suffix}\n`);
78
83
  }
79
84
  // ── Initial registration ────────────────────────────────────────────
80
85
  // Snapshot whether the Godot version is unknown at eager registration. When it
@@ -42,9 +42,13 @@ export function installProcessHandlers(bridge) {
42
42
  process.stdout.on("error", (err) => {
43
43
  departureShutdown(`stdout unwritable (${err?.code ?? err?.message ?? "unknown"})`);
44
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.
45
+ // Diagnostics gone: swallow, since nothing can be logged about a dead log sink. Once
46
+ // installed, this listener keeps every direct process.stderr.write in the server safe
47
+ // after the pipe dies, not only logSafely's, so it must not be dropped in favour of
48
+ // logSafely. A try/catch cannot replace it: the failure arrives as an asynchronous
49
+ // 'error' event after write() has already returned. With no listener that event is
50
+ // thrown into uncaughtException, whose handler writes to the same dead stderr and
51
+ // re-enters itself, pinning a core at 100% CPU.
48
52
  process.stderr.on("error", () => {
49
53
  /* muted */
50
54
  });
@@ -90,19 +90,21 @@ export function applyCliMetaGates(cli) {
90
90
  process.exit(0);
91
91
  }
92
92
  }
93
- // Defaults match the plugin-side ProjectSettings defaults.
94
- const SCRIPT_READ_LIMIT_DEFAULT = 262144; // 256 KB
95
- const WS_BUFFER_LIMIT_DEFAULT = 1048576; // 1 MB
93
+ // Floors match the minimums the plugin clamps meta.set_limits values to.
96
94
  const SCRIPT_READ_LIMIT_FLOOR = 65536; // 64 KB
97
95
  const WS_BUFFER_LIMIT_FLOOR = 262144; // 256 KB
98
- function parseCapEnv(envName, defaultVal, floor) {
96
+ /** Read one cap from the environment. Unset or empty yields undefined. An invalid
97
+ * value is ignored with a warning instead of becoming a default, since a pushed
98
+ * default would override the project's own setting. A value below the floor is
99
+ * still a configured cap, so it is clamped up to the floor. */
100
+ function parseCapEnv(envName, floor) {
99
101
  const raw = process.env[envName];
100
102
  if (!raw)
101
- return defaultVal;
103
+ return undefined;
102
104
  const parsed = Number(raw);
103
105
  if (!Number.isFinite(parsed) || parsed <= 0) {
104
- process.stderr.write(`[godot-mcp] WARNING: ${envName}=${raw} is not a valid positive number; using default ${defaultVal}\n`);
105
- return defaultVal;
106
+ process.stderr.write(`[godot-mcp] WARNING: ${envName}=${raw} is not a valid positive number; ignoring it, so the project's own setting applies\n`);
107
+ return undefined;
106
108
  }
107
109
  if (parsed < floor) {
108
110
  process.stderr.write(`[godot-mcp] WARNING: ${envName}=${parsed} is below minimum ${floor}; clamping to ${floor}\n`);
@@ -110,11 +112,16 @@ function parseCapEnv(envName, defaultVal, floor) {
110
112
  }
111
113
  return parsed;
112
114
  }
113
- /** Parse + clamp GODOT_MCP_SCRIPT_READ_LIMIT / _WS_BUFFER_LIMIT against their floors/defaults. */
115
+ /**
116
+ * Resolve the response caps from `GODOT_MCP_SCRIPT_READ_LIMIT` and
117
+ * `GODOT_MCP_WS_BUFFER_LIMIT`, both in bytes. A variable that is unset, empty or
118
+ * invalid leaves its cap undefined; a value below the floor is clamped to it.
119
+ */
114
120
  export function resolveResponseCaps() {
115
- const scriptReadLimitBytes = parseCapEnv("GODOT_MCP_SCRIPT_READ_LIMIT", SCRIPT_READ_LIMIT_DEFAULT, SCRIPT_READ_LIMIT_FLOOR);
116
- const wsBufferLimitBytes = parseCapEnv("GODOT_MCP_WS_BUFFER_LIMIT", WS_BUFFER_LIMIT_DEFAULT, WS_BUFFER_LIMIT_FLOOR);
117
- return { scriptReadLimitBytes, wsBufferLimitBytes };
121
+ return {
122
+ scriptReadLimitBytes: parseCapEnv("GODOT_MCP_SCRIPT_READ_LIMIT", SCRIPT_READ_LIMIT_FLOOR),
123
+ wsBufferLimitBytes: parseCapEnv("GODOT_MCP_WS_BUFFER_LIMIT", WS_BUFFER_LIMIT_FLOOR),
124
+ };
118
125
  }
119
126
  // ── Config version check ─────────────────────────────────────────────
120
127
  const EXPECTED_CONFIG_VERSION = 1;
@@ -3,6 +3,15 @@ import { createChannel } from "./channel.js";
3
3
  import { createRuntimeConnection } from "./runtimeConnection.js";
4
4
  import { parseGodotVer } from "../shared/version.js";
5
5
  import { lookupProject } from "../registry.js";
6
+ /**
7
+ * Convert a cap in bytes to the whole kilobytes `meta.set_limits` takes: the value
8
+ * the bridge pushes for it.
9
+ *
10
+ * @internal
11
+ */
12
+ export function capKb(bytes) {
13
+ return Math.round(bytes / 1024);
14
+ }
6
15
  /**
7
16
  * Build the bridge for one Godot project. Connection is lazy — the first
8
17
  * {@link Bridge.call} performs the WebSocket connect + auth handshake; the runtime
@@ -12,7 +21,7 @@ import { lookupProject } from "../registry.js";
12
21
  * is re-discovered from the registry on disconnect unless `explicitEditorPort` is
13
22
  * set or the project path is unknown
14
23
  * @param opts - see {@link BridgeOptions}: project path for registry discovery,
15
- * static-port overrides, and the response/buffer caps pushed to the plugin after auth
24
+ * static-port overrides, and the optional response/buffer caps pushed to the plugin
16
25
  * @returns the {@link Bridge}, augmented with `onNotification` (unsolicited plugin
17
26
  * pushes) and `onGodotVersionKnown` (fires once on the unknown → known version
18
27
  * transition, so the composition root can complete a tool surface registered
@@ -49,20 +58,25 @@ export function createBridge(editorUrl, opts) {
49
58
  }
50
59
  }
51
60
  }
52
- // After auth, push server-side response caps to the plugin so it can
53
- // enforce them (server env var > dock UI ProjectSettings > defaults).
61
+ // The project's mcp_toolkit/limits/* settings apply unless an env var sets a
62
+ // cap. Then only that cap is pushed, from the initial editor channel's auth
63
+ // callback below; the channel rediscoverEditor opens does not push. The plugin
64
+ // sets it in memory, where it stays in force until the editor restarts or the
65
+ // setting is changed, and the next settings save from any source persists it
66
+ // to project.godot. With none set nothing is sent, so a cap set in the dock or
67
+ // in Project Settings stays in force and stays in project.godot.
54
68
  function sendLimitsIfConfigured(channel) {
55
- const scriptKb = opts?.scriptReadLimitBytes ? Math.round(opts.scriptReadLimitBytes / 1024) : 0;
56
- const wsKb = opts?.wsBufferLimitBytes ? Math.round(opts.wsBufferLimitBytes / 1024) : 0;
57
- if (scriptKb > 0 || wsKb > 0) {
58
- const params = {};
59
- if (scriptKb > 0)
60
- params.script_read_cap_kb = scriptKb;
61
- if (wsKb > 0)
62
- params.ws_buffer_kb = wsKb;
63
- // Fire-and-forget — failure here is non-fatal.
64
- void channel.call("meta.set_limits", params, 5000).catch(() => { });
65
- }
69
+ const scriptKb = opts?.scriptReadLimitBytes ? capKb(opts.scriptReadLimitBytes) : 0;
70
+ const wsKb = opts?.wsBufferLimitBytes ? capKb(opts.wsBufferLimitBytes) : 0;
71
+ const params = {};
72
+ if (scriptKb > 0)
73
+ params.script_read_cap_kb = scriptKb;
74
+ if (wsKb > 0)
75
+ params.ws_buffer_kb = wsKb;
76
+ if (Object.keys(params).length === 0)
77
+ return;
78
+ // Fire-and-forget: a failed push leaves the project's own setting in force.
79
+ void channel.call("meta.set_limits", params, 5000).catch(() => { });
66
80
  }
67
81
  const getNotificationHandler = () => notificationHandler;
68
82
  let editor = createChannel(editorUrl, projectPath, ({ version, headless: h }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npgamedev/godot-mcp-server",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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",