@npgamedev/godot-mcp-server 0.0.1 → 1.0.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.
Files changed (129) hide show
  1. package/ATTRIBUTIONS.md +141 -0
  2. package/LICENSE +28 -0
  3. package/README.md +386 -4
  4. package/dist/extensions/extensionChanges.js +131 -0
  5. package/dist/extensions/extensionCommand.js +25 -0
  6. package/dist/extensions/extensionDiscovery.js +141 -0
  7. package/dist/extensions/extensionRegistrar.js +82 -0
  8. package/dist/extensions/extensions.js +33 -0
  9. package/dist/groups/builtinGroups.js +60 -0
  10. package/dist/groups/defs/3dTools.js +17 -0
  11. package/dist/groups/defs/animationAuthoring.js +16 -0
  12. package/dist/groups/defs/assetOps.js +6 -0
  13. package/dist/groups/defs/audio.js +6 -0
  14. package/dist/groups/defs/classdb.js +6 -0
  15. package/dist/groups/defs/cleanup.js +6 -0
  16. package/dist/groups/defs/debugger.js +6 -0
  17. package/dist/groups/defs/editorAdvanced.js +16 -0
  18. package/dist/groups/defs/inputMap.js +6 -0
  19. package/dist/groups/defs/layerNaming.js +6 -0
  20. package/dist/groups/defs/lspCodeAnalysis.js +23 -0
  21. package/dist/groups/defs/lspCodeNavigation.js +15 -0
  22. package/dist/groups/defs/navigation.js +17 -0
  23. package/dist/groups/defs/particles.js +21 -0
  24. package/dist/groups/defs/pathEditing.js +22 -0
  25. package/dist/groups/defs/placeholders.js +28 -0
  26. package/dist/groups/defs/procedural.js +6 -0
  27. package/dist/groups/defs/resourceIo.js +6 -0
  28. package/dist/groups/defs/runtimeAdvanced.js +17 -0
  29. package/dist/groups/defs/sceneAdvanced.js +6 -0
  30. package/dist/groups/defs/sceneInheritance.js +6 -0
  31. package/dist/groups/defs/signals.js +6 -0
  32. package/dist/groups/defs/spriteframes.js +16 -0
  33. package/dist/groups/defs/theme.js +6 -0
  34. package/dist/groups/defs/tilemap.js +6 -0
  35. package/dist/groups/defs/tileset.js +22 -0
  36. package/dist/groups/defs/tilesetEdit.js +21 -0
  37. package/dist/groups/defs/userData.js +6 -0
  38. package/dist/groups/extensionGroups.js +182 -0
  39. package/dist/groups/groupActivation.js +210 -0
  40. package/dist/groups/groupCatalogue.js +52 -0
  41. package/dist/groups/groupMatch.js +182 -0
  42. package/dist/groups/groupResult.js +16 -0
  43. package/dist/groups/groupState.js +15 -0
  44. package/dist/groups/groupToolHandlers.js +103 -0
  45. package/dist/groups/groupTypes.js +10 -0
  46. package/dist/groups/groups.js +205 -0
  47. package/dist/index.js +144 -0
  48. package/dist/lsp/lspClient.js +509 -0
  49. package/dist/lsp/lspLabels.js +105 -0
  50. package/dist/lsp/lspProjectScan.js +156 -0
  51. package/dist/lsp/lspSession.js +139 -0
  52. package/dist/lsp/lspStatusReporter.js +76 -0
  53. package/dist/lsp/lspUri.js +68 -0
  54. package/dist/mcp/prompts.js +59 -0
  55. package/dist/mcp/resources.js +113 -0
  56. package/dist/mcp/roots.js +30 -0
  57. package/dist/registration/catalogue.js +123 -0
  58. package/dist/registration/extensionCollision.js +38 -0
  59. package/dist/registration/operations.js +67 -0
  60. package/dist/registration/screenshotResponse.js +71 -0
  61. package/dist/registration/toolDispatch.js +76 -0
  62. package/dist/registration/toolMeta.js +104 -0
  63. package/dist/registration/toolRefs.js +48 -0
  64. package/dist/registration/toolRegistry.js +211 -0
  65. package/dist/registry.js +291 -0
  66. package/dist/registryLiveness.js +113 -0
  67. package/dist/security/pathGuard.js +97 -0
  68. package/dist/security/profiles.js +104 -0
  69. package/dist/security/untrusted.js +23 -0
  70. package/dist/shared/errorContract.js +154 -0
  71. package/dist/shared/errors.js +21 -0
  72. package/dist/shared/pagination.js +135 -0
  73. package/dist/shared/schemaCoercion.js +173 -0
  74. package/dist/shared/stableJson.js +27 -0
  75. package/dist/shared/types.js +1 -0
  76. package/dist/shared/version.js +82 -0
  77. package/dist/startup/cliArgs.js +103 -0
  78. package/dist/startup/configReload.js +57 -0
  79. package/dist/startup/hooks.js +89 -0
  80. package/dist/startup/lifecycle.js +22 -0
  81. package/dist/startup/portConfig.js +127 -0
  82. package/dist/startup/reconcile.js +81 -0
  83. package/dist/startup/registrars.js +59 -0
  84. package/dist/startup/serverMode.js +19 -0
  85. package/dist/startup/startupEnv.js +142 -0
  86. package/dist/tools/animation.js +88 -0
  87. package/dist/tools/asset.js +58 -0
  88. package/dist/tools/assetWrite.js +16 -0
  89. package/dist/tools/audio.js +40 -0
  90. package/dist/tools/classdb.js +43 -0
  91. package/dist/tools/collision.js +23 -0
  92. package/dist/tools/debug.js +46 -0
  93. package/dist/tools/diff.js +18 -0
  94. package/dist/tools/editor.js +222 -0
  95. package/dist/tools/file.js +38 -0
  96. package/dist/tools/folder.js +27 -0
  97. package/dist/tools/inputMap.js +54 -0
  98. package/dist/tools/layerNames.js +29 -0
  99. package/dist/tools/lsp.js +524 -0
  100. package/dist/tools/navigation.js +24 -0
  101. package/dist/tools/node.js +145 -0
  102. package/dist/tools/nodeManagement.js +82 -0
  103. package/dist/tools/particles.js +83 -0
  104. package/dist/tools/path.js +30 -0
  105. package/dist/tools/playtest.js +99 -0
  106. package/dist/tools/procedural.js +101 -0
  107. package/dist/tools/resource.js +47 -0
  108. package/dist/tools/runtime.js +338 -0
  109. package/dist/tools/save.js +64 -0
  110. package/dist/tools/scene.js +102 -0
  111. package/dist/tools/sceneInheritance.js +19 -0
  112. package/dist/tools/sceneQuery.js +38 -0
  113. package/dist/tools/script.js +75 -0
  114. package/dist/tools/signals.js +53 -0
  115. package/dist/tools/sound.js +31 -0
  116. package/dist/tools/spatial.js +41 -0
  117. package/dist/tools/spriteframes.js +94 -0
  118. package/dist/tools/texture.js +35 -0
  119. package/dist/tools/theme.js +35 -0
  120. package/dist/tools/threeD.js +116 -0
  121. package/dist/tools/tilemap.js +51 -0
  122. package/dist/tools/tileset.js +226 -0
  123. package/dist/transport/authHandshake.js +43 -0
  124. package/dist/transport/bridge.js +225 -0
  125. package/dist/transport/channel.js +352 -0
  126. package/dist/transport/heartbeat.js +54 -0
  127. package/dist/transport/runtimeConnection.js +239 -0
  128. package/dist/transport/tokenPath.js +98 -0
  129. package/package.json +97 -4
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Schema shaping & coercion — make agent / extension inputs validate
3
+ * against Zod. A pure leaf: depends only on zod, with no edge to the
4
+ * registration core, dispatch, or the error contract.
5
+ *
6
+ * Three jobs: (1) per-value coercion presets (coercedBoolean / jsonCoerce)
7
+ * that tool modules attach to individual params; (2) the blanket LLM
8
+ * string-coercion pass (addStringCoercion) applied to every Zod shape at
9
+ * registration; (3) raw-JSON-Schema → Zod conversion for extension tools.
10
+ */
11
+ import { z } from "zod";
12
+ // ── MCP string-coercion helpers ────────────────────────────────────
13
+ /**
14
+ * Boolean schema that coerces string inputs ("true"→true, "false"→false).
15
+ * MCP clients may send all values as strings for dynamically-registered
16
+ * tools (added via tools/list_changed). Standard z.boolean() rejects
17
+ * strings; z.coerce.boolean() converts "false" to true (truthy string).
18
+ * This preprocess handles the "false" case correctly.
19
+ */
20
+ export const coercedBoolean = () => z.preprocess((v) => (typeof v === "string" ? v.toLowerCase() === "true" || v === "1" : v), z.boolean());
21
+ /**
22
+ * Preprocess for JSON-string coercion: parses stringified arrays/objects.
23
+ * Same root cause as coercedBoolean — MCP clients may serialize complex
24
+ * values as JSON strings rather than native JSON types.
25
+ */
26
+ export const jsonCoerce = (v) => {
27
+ if (typeof v === "string") {
28
+ try {
29
+ return JSON.parse(v);
30
+ }
31
+ catch {
32
+ return v;
33
+ }
34
+ }
35
+ return v;
36
+ };
37
+ // ── LLM string coercion ────────────────────────────────────────────
38
+ // LLM agents sometimes serialize complex params as JSON strings instead
39
+ // of passing structured values (e.g. "[{...}]" instead of [{...}]).
40
+ // This pre-validation pass tries JSON.parse on string values when the
41
+ // schema expects array/object/number/boolean.
42
+ export function coerceStringValue(val) {
43
+ if (typeof val !== "string")
44
+ return val;
45
+ // Fast-reject: strings that clearly aren't JSON-encoded values
46
+ const trimmed = val.trim();
47
+ if (trimmed.length === 0)
48
+ return val;
49
+ const first = trimmed[0];
50
+ if (first !== "[" &&
51
+ first !== "{" &&
52
+ first !== '"' &&
53
+ first !== "t" &&
54
+ first !== "f" &&
55
+ first !== "n" &&
56
+ !/^-?\d/.test(trimmed)) {
57
+ return val;
58
+ }
59
+ try {
60
+ return JSON.parse(trimmed);
61
+ }
62
+ catch {
63
+ return val;
64
+ }
65
+ }
66
+ /** Resolve the innermost Zod type name, unwrapping optional/nullable/default. */
67
+ export function innerZodType(schema) {
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Zod v4 internal
69
+ let s = schema;
70
+ // Walk through wrappers: optional → ZodOptional._zod.def.innerType,
71
+ // nullable → ZodNullable._zod.def.innerType, default → ZodDefault._zod.def.innerType.
72
+ while (s?._zod?.def?.innerType) {
73
+ s = s._zod.def.innerType;
74
+ }
75
+ return s?._zod?.def?.type;
76
+ }
77
+ /**
78
+ * Wrap each top-level key in a Zod shape with z.preprocess() so that
79
+ * JSON-encoded strings are parsed before Zod validation. Skips params
80
+ * whose schema expects a string — a JSON-encoded string value passed
81
+ * to a string param should not be unwrapped.
82
+ */
83
+ export function addStringCoercion(shape) {
84
+ const coerced = {};
85
+ for (const [key, schema] of Object.entries(shape)) {
86
+ const inner = innerZodType(schema);
87
+ // Skip schemas that are string-typed (would unwrap intended JSON strings),
88
+ // enum-typed (discrete values, not JSON), or already preprocessed (pipe —
89
+ // e.g. coercedBoolean() already handles its own string coercion).
90
+ if (inner === "string" || inner === "enum" || inner === "pipe") {
91
+ coerced[key] = schema;
92
+ }
93
+ else {
94
+ // Wrap with preprocess so JSON-encoded strings are parsed before Zod.
95
+ // z.preprocess() is a ZodPipe, not a ZodOptional — and the MCP SDK emits
96
+ // the JSON Schema with io:"input" (pipeStrategy), where the pipe's input
97
+ // side does NOT inherit the inner .optional(). That flips optional params
98
+ // to `required` in tools/list (e.g. an optional radius/max_nodes would be
99
+ // advertised as required). Re-apply .optional() so the wrapper stays
100
+ // optional on the input side. (undefined short-circuits the outer optional,
101
+ // so coercion and any inner default are unaffected for provided values.)
102
+ const wrapped = z.preprocess(coerceStringValue, schema);
103
+ coerced[key] = schema instanceof z.ZodOptional ? wrapped.optional() : wrapped;
104
+ }
105
+ }
106
+ return coerced;
107
+ }
108
+ // ── JSON Schema → Zod conversion ────────────────────────────────────
109
+ /**
110
+ * Detect whether an inputSchema is raw JSON Schema (from extension
111
+ * commands) rather than a Zod shape. Heuristic: top-level "type" key
112
+ * with plain string value, OR "properties" key that is a plain object
113
+ * (not a Zod schema). Zod schemas have a `_zod` property; plain JSON
114
+ * Schema `properties` objects do not.
115
+ */
116
+ export function isRawJsonSchema(schema) {
117
+ if (!schema || typeof schema !== "object")
118
+ return false;
119
+ const obj = schema;
120
+ if (typeof obj.type === "string")
121
+ return true;
122
+ if (typeof obj.properties === "object" && obj.properties !== null) {
123
+ // A Zod schema (used as a field named "properties") has _zod; a
124
+ // JSON Schema properties object is a plain dict of field descriptors.
125
+ return !obj.properties._zod;
126
+ }
127
+ return false;
128
+ }
129
+ /**
130
+ * Convert a raw JSON Schema object to a Zod shape compatible with the
131
+ * MCP SDK's registerTool. Handles the common types extension authors use.
132
+ */
133
+ export function jsonSchemaToZodShape(schema) {
134
+ const properties = schema.properties;
135
+ if (!properties)
136
+ return {};
137
+ const required = new Set(schema.required ?? []);
138
+ const shape = {};
139
+ for (const [key, prop] of Object.entries(properties)) {
140
+ let zodType;
141
+ switch (prop.type) {
142
+ case "string":
143
+ if (Array.isArray(prop.enum) && prop.enum.length > 0) {
144
+ zodType = z.enum(prop.enum);
145
+ }
146
+ else {
147
+ zodType = z.string();
148
+ }
149
+ break;
150
+ case "number":
151
+ case "integer":
152
+ zodType = z.coerce.number();
153
+ break;
154
+ case "boolean":
155
+ zodType = coercedBoolean();
156
+ break;
157
+ case "array":
158
+ zodType = z.preprocess(jsonCoerce, z.array(z.any()));
159
+ break;
160
+ default:
161
+ zodType = z.any();
162
+ break;
163
+ }
164
+ if (typeof prop.description === "string") {
165
+ zodType = zodType.describe(prop.description);
166
+ }
167
+ if (!required.has(key)) {
168
+ zodType = zodType.optional();
169
+ }
170
+ shape[key] = zodType;
171
+ }
172
+ return shape;
173
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Stable serialization utility for deterministic JSON output.
3
+ */
4
+ /**
5
+ * Stable JSON.stringify with sorted keys: identical inputs produce
6
+ * byte-identical strings.
7
+ *
8
+ * Where this earns prompt-cache hits is the re-sent schema/tools block —
9
+ * stable key order keeps that whole-request payload byte-identical across
10
+ * turns, so Anthropic's cache matches it. A per-call tool response is
11
+ * serialized exactly once into history, so sorting its keys buys no extra
12
+ * cache hit; it is retained there for output stability (a tool's response
13
+ * keys don't reorder between otherwise-identical calls).
14
+ */
15
+ export function stableStringify(value) {
16
+ return JSON.stringify(value, sortedReplacer);
17
+ }
18
+ function sortedReplacer(_key, value) {
19
+ if (value && typeof value === "object" && !Array.isArray(value)) {
20
+ const sorted = {};
21
+ for (const k of Object.keys(value).sort()) {
22
+ sorted[k] = value[k];
23
+ }
24
+ return sorted;
25
+ }
26
+ return value;
27
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Version utilities — the server's own version (read from package.json) and the
3
+ * Godot version-gating helpers. {@link GodotVer} is the parsed `[major, minor]`
4
+ * tuple the tool-version gate compares against; the rest parse, compare, and
5
+ * bound-check engine versions for the registration-time and per-call gates, plus
6
+ * a semver severity compare for the auth-handshake version check.
7
+ *
8
+ * @module
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { join, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ // ── Server version (single source of truth: package.json) ───────────
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ let cachedVersion = undefined;
16
+ /** Read the server version from package.json. Cached after first call. */
17
+ export function getServerVersion() {
18
+ if (cachedVersion)
19
+ return cachedVersion;
20
+ try {
21
+ // package.json lives at the package root — two levels up from this module's
22
+ // home (dist/shared/version.js when built; src/shared/version.ts under tsx).
23
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"));
24
+ cachedVersion = pkg.version ?? "0.0.0";
25
+ }
26
+ catch {
27
+ cachedVersion = "0.0.0";
28
+ }
29
+ return cachedVersion;
30
+ }
31
+ /** Parse a "major.minor" or "major.minor.patch" string to a [major, minor] tuple. */
32
+ export function parseGodotVer(v) {
33
+ const parts = v.split(".").map(Number);
34
+ return [parts[0] ?? 0, parts[1] ?? 0];
35
+ }
36
+ /** Compare two GodotVer tuples. Returns negative/0/positive like strcmp. */
37
+ export function compareGodotVer(a, b) {
38
+ if (a[0] !== b[0])
39
+ return a[0] - b[0];
40
+ return a[1] - b[1];
41
+ }
42
+ /** Check whether a connected Godot version is >= the given minimum. */
43
+ export function isVersionAtLeast(connected, min) {
44
+ return compareGodotVer(connected, parseGodotVer(min)) >= 0;
45
+ }
46
+ /** Check whether a connected Godot version is <= the given maximum. */
47
+ export function isVersionAtMost(connected, max) {
48
+ return compareGodotVer(connected, parseGodotVer(max)) <= 0;
49
+ }
50
+ /** Check whether a connected Godot version falls within [min, max] bounds (each bound optional, inclusive). */
51
+ export function isVersionCompatible(connected, min, max) {
52
+ if (min != null && !isVersionAtLeast(connected, min))
53
+ return false;
54
+ if (max != null && !isVersionAtMost(connected, max))
55
+ return false;
56
+ return true;
57
+ }
58
+ /**
59
+ * Compare two semver version strings.
60
+ *
61
+ * Returns:
62
+ * "ok" — versions match (all components equal)
63
+ * "minor" — same major, different minor or patch
64
+ * "major" — different major version
65
+ * "unknown" — remote is undefined/empty (pre-handshake peer)
66
+ */
67
+ export function compareVersions(local, remote) {
68
+ if (remote == null || remote === "")
69
+ return "unknown";
70
+ const localParts = local.split(".").map(Number);
71
+ const remoteParts = remote.split(".").map(Number);
72
+ if (localParts.length !== 3 || remoteParts.length !== 3)
73
+ return "unknown";
74
+ if (localParts.some((n) => !Number.isFinite(n)) || remoteParts.some((n) => !Number.isFinite(n))) {
75
+ return "unknown";
76
+ }
77
+ if (localParts[0] !== remoteParts[0])
78
+ return "major";
79
+ if (localParts[1] !== remoteParts[1] || localParts[2] !== remoteParts[2])
80
+ return "minor";
81
+ return "ok";
82
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Hand-rolled CLI argument parser for the server bin (no dependency).
3
+ *
4
+ * Collects the recognised `--flag`s into a flat {@link CliArgs} struct and hands
5
+ * it to the port resolver — it does NO port validation or precedence itself (that
6
+ * is the resolver's job). The surface is deliberately tiny: explicit
7
+ * `--flag value` / `--flag=value` value flags, two boolean gates, no positionals,
8
+ * no bundling, no convenience forms. An unrecognised flag, a stray positional, or
9
+ * a value flag missing its value sets `error`, which the composition root turns
10
+ * into a fail-loud exit.
11
+ *
12
+ * @module
13
+ */
14
+ // Value flags — maps the long name to the CliArgs field it populates.
15
+ const VALUE_FLAGS = {
16
+ "--editor-port": "editorPort",
17
+ "--runtime-port": "runtimePort",
18
+ "--lsp-port": "lspPort",
19
+ "--lsp-host": "lspHost",
20
+ };
21
+ /**
22
+ * Parse the server's CLI arguments — pass `process.argv.slice(2)`.
23
+ *
24
+ * @param argv - the raw argument list, without the node binary and script path
25
+ * @returns the collected {@link CliArgs}; `error` is set on the first parse
26
+ * problem, but scanning continues so `--help` is still detected when it follows
27
+ * a bad flag
28
+ * @remarks Both `--flag value` and `--flag=value` forms are accepted for value
29
+ * flags. A next token that begins with `--` is treated as the *next flag*, not
30
+ * a value — so a value flag at the end of argv (or before another flag) is a
31
+ * missing-value error rather than silently swallowing the following flag.
32
+ */
33
+ export function parseCliArgs(argv) {
34
+ const args = { help: false, toolsCount: false, listEager: false };
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const token = argv[i];
37
+ if (!token.startsWith("--")) {
38
+ recordError(args, `unexpected argument "${token}" (no positional arguments are accepted)`);
39
+ continue;
40
+ }
41
+ const eq = token.indexOf("=");
42
+ const name = eq === -1 ? token : token.slice(0, eq);
43
+ const inlineValue = eq === -1 ? undefined : token.slice(eq + 1);
44
+ if (name === "--help") {
45
+ args.help = true;
46
+ continue;
47
+ }
48
+ if (name === "--tools-count") {
49
+ args.toolsCount = true;
50
+ continue;
51
+ }
52
+ if (name === "--list-eager") {
53
+ args.listEager = true;
54
+ continue;
55
+ }
56
+ const field = VALUE_FLAGS[name];
57
+ if (field === undefined) {
58
+ recordError(args, `unknown flag "${name}"`);
59
+ continue;
60
+ }
61
+ let value = inlineValue;
62
+ if (value === undefined && i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
63
+ value = argv[i + 1];
64
+ i++;
65
+ }
66
+ if (value === undefined || value === "") {
67
+ recordError(args, `missing value for "${name}"`);
68
+ continue;
69
+ }
70
+ args[field] = value;
71
+ }
72
+ return args;
73
+ }
74
+ // Keep only the first error — later tokens after a bad flag are symptoms, not the cause.
75
+ function recordError(args, message) {
76
+ if (args.error === undefined)
77
+ args.error = message;
78
+ }
79
+ /** The `--help` text (printed to stdout). Kept in sync with {@link parseCliArgs}'s flag set. */
80
+ export function formatHelp() {
81
+ return [
82
+ "godot-mcp-server — MCP bridge to the Godot 4.x editor",
83
+ "",
84
+ "Usage: godot-mcp-server [options]",
85
+ "",
86
+ "Port options (a flag overrides its env var, which overrides registry discovery):",
87
+ " --editor-port <n> Editor WebSocket port to dial (env: GODOT_MCP_EDITOR_PORT)",
88
+ " --runtime-port <n> Playtest-runtime WebSocket port to dial (env: GODOT_MCP_RUNTIME_PORT)",
89
+ " --lsp-port <n> GDScript LSP port to dial (env: GODOT_MCP_LSP_PORT)",
90
+ " --lsp-host <h> GDScript LSP host to dial (env: GODOT_MCP_LSP_HOST)",
91
+ "",
92
+ "Other options:",
93
+ " --tools-count Print the static tool-count summary and exit",
94
+ " --list-eager Print the eager+meta tool manifest (JSON) and exit",
95
+ " --help Show this help and exit",
96
+ "",
97
+ "These flags move the DIAL target only. The ports the editor/runtime BIND",
98
+ "(listen side) are configured toolkit-side — see the toolkit's",
99
+ "advanced-configuration docs. With no options the server discovers ports via",
100
+ "the shared project registry.",
101
+ "",
102
+ ].join("\n");
103
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Live config reload — re-reads .mcp.json env vars and rebuilds the
3
+ * tool list without restarting the server process.
4
+ *
5
+ * Triggered by a WebSocket notification from the Godot plugin when the
6
+ * user changes settings in the editor UI.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ /**
11
+ * Read .mcp.json from the project root and return the env vars
12
+ * for the godot-mcp-toolkit server entry. Returns undefined on any failure
13
+ * (missing file, parse error, no matching server key).
14
+ */
15
+ export function readMcpJsonEnv(projectPath) {
16
+ try {
17
+ const mcpJsonPath = join(projectPath, ".mcp.json");
18
+ const raw = readFileSync(mcpJsonPath, "utf-8");
19
+ const parsed = JSON.parse(raw);
20
+ const servers = parsed?.mcpServers;
21
+ if (!servers)
22
+ return undefined;
23
+ // Find our server entry (mirrors mcp_json_sync.gd._find_server_key).
24
+ let serverEntry = servers["godot-mcp-toolkit"];
25
+ if (!serverEntry) {
26
+ for (const key of Object.keys(servers)) {
27
+ if (key.toLowerCase().includes("godot-mcp")) {
28
+ serverEntry = servers[key];
29
+ break;
30
+ }
31
+ }
32
+ }
33
+ if (!serverEntry?.env)
34
+ return undefined;
35
+ return serverEntry.env;
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ /**
42
+ * Apply new env vars to process.env. Only touches GODOT_MCP_* keys
43
+ * to avoid overwriting unrelated environment.
44
+ */
45
+ export function applyEnvUpdate(newEnv) {
46
+ const prefix = "GODOT_MCP_";
47
+ // Remove MCP vars that are no longer in .mcp.json.
48
+ for (const key of Object.keys(process.env)) {
49
+ if (key.startsWith(prefix) && !(key in newEnv)) {
50
+ delete process.env[key];
51
+ }
52
+ }
53
+ // Set / update vars from .mcp.json.
54
+ for (const [key, value] of Object.entries(newEnv)) {
55
+ process.env[key] = String(value);
56
+ }
57
+ }
@@ -0,0 +1,89 @@
1
+ export class HookPipeline {
2
+ hooks = [];
3
+ /** Register a hook. Hooks fire in registration order (FIFO). */
4
+ use(hook) {
5
+ this.hooks.push(hook);
6
+ }
7
+ /**
8
+ * Execute the hook chain around a tool handler.
9
+ * If any hook throws, the error is caught and logged — dispatch
10
+ * continues with the remaining chain so one misbehaving hook
11
+ * cannot break all tool calls.
12
+ */
13
+ async execute(req, handler) {
14
+ let chain = handler;
15
+ // Build chain from innermost (last hook) to outermost (first hook).
16
+ for (let i = this.hooks.length - 1; i >= 0; i--) {
17
+ const hook = this.hooks[i];
18
+ const next = chain;
19
+ chain = async () => {
20
+ try {
21
+ return await hook(req, next);
22
+ }
23
+ catch (err) {
24
+ process.stderr.write(`[godot-mcp] hook error: ${err.message}\n`);
25
+ return next();
26
+ }
27
+ };
28
+ }
29
+ return chain();
30
+ }
31
+ get length() {
32
+ return this.hooks.length;
33
+ }
34
+ }
35
+ // ── Seed hooks ─────────────────────────────────────────────────────────
36
+ /** Logging hook — writes tool name + duration to stderr. */
37
+ export function loggingHook() {
38
+ return async (req, next) => {
39
+ const start = Date.now();
40
+ const result = await next();
41
+ const ms = Date.now() - start;
42
+ process.stderr.write(`[godot-mcp] tool=${req.name} duration=${ms}ms isError=${!!result.isError}\n`);
43
+ return result;
44
+ };
45
+ }
46
+ /**
47
+ * Rate-limit hook — rejects calls that exceed `maxPerWindow` within
48
+ * `windowMs`. Disabled when `maxPerWindow` is 0 (default).
49
+ */
50
+ export function rateLimitHook(maxPerWindow = 0, windowMs = 60_000) {
51
+ if (maxPerWindow <= 0)
52
+ return undefined;
53
+ const timestamps = [];
54
+ return async (_req, next) => {
55
+ const now = Date.now();
56
+ while (timestamps.length > 0 && timestamps[0] <= now - windowMs) {
57
+ timestamps.shift();
58
+ }
59
+ if (timestamps.length >= maxPerWindow) {
60
+ return {
61
+ content: [
62
+ {
63
+ type: "text",
64
+ text: JSON.stringify({
65
+ success: false,
66
+ error: `Rate limit exceeded (${maxPerWindow} calls per ${windowMs / 1000}s)`,
67
+ code: "RATE_LIMITED",
68
+ }),
69
+ },
70
+ ],
71
+ isError: true,
72
+ };
73
+ }
74
+ timestamps.push(now);
75
+ return next();
76
+ };
77
+ }
78
+ /** Create a pre-configured pipeline with seed hooks. */
79
+ export function createHookPipeline() {
80
+ const pipeline = new HookPipeline();
81
+ // Logging is always on (writes to stderr, low cost).
82
+ pipeline.use(loggingHook());
83
+ // Rate limiting off by default; users can set GODOT_MCP_RATE_LIMIT.
84
+ const limit = Number(process.env.GODOT_MCP_RATE_LIMIT ?? "0");
85
+ const rlHook = rateLimitHook(limit);
86
+ if (rlHook)
87
+ pipeline.use(rlHook);
88
+ return pipeline;
89
+ }
@@ -0,0 +1,22 @@
1
+ /** Install SIGINT/SIGTERM graceful shutdown (await bridge.close → exit 0) and the
2
+ * unhandledRejection / uncaughtException stderr loggers that keep the bridge alive. */
3
+ export function installProcessHandlers(bridge) {
4
+ async function shutdown() {
5
+ try {
6
+ await bridge.close();
7
+ }
8
+ finally {
9
+ process.exit(0);
10
+ }
11
+ }
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.
16
+ process.on("unhandledRejection", (reason) => {
17
+ process.stderr.write(`[godot-mcp] unhandledRejection: ${reason}\n`);
18
+ });
19
+ process.on("uncaughtException", (err) => {
20
+ process.stderr.write(`[godot-mcp] uncaughtException: ${err?.stack ?? err}\n`);
21
+ });
22
+ }
@@ -0,0 +1,127 @@
1
+ import { lookupProject } from "../registry.js";
2
+ // Matches the toolkit's default editor bind port and the historical fallback.
3
+ const DEFAULT_EDITOR_PORT = "6550";
4
+ /** A pin failed validation — the composition root turns this into stderr + exit 1. */
5
+ export class PortConfigError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "PortConfigError";
9
+ }
10
+ }
11
+ /**
12
+ * Whether a raw pin string is a usable TCP port: all digits (no sign, decimal,
13
+ * or unit suffix) and in 1–65535. Port 0 ("any free port") is rejected — a pin
14
+ * must name the exact port both sides agree on.
15
+ */
16
+ export function isValidPort(value) {
17
+ if (!/^\d+$/.test(value))
18
+ return false;
19
+ const port = parseInt(value, 10);
20
+ return port >= 1 && port <= 65535;
21
+ }
22
+ // Fail loud on a bad pin. `label` names the exact source the user typed
23
+ // (`--editor-port` or GODOT_MCP_EDITOR_PORT) so the error is directly fixable.
24
+ function requireValidPin(value, label) {
25
+ if (!isValidPort(value)) {
26
+ throw new PortConfigError(`${label} value "${value}" is not a valid port — expected an integer 1–65535`);
27
+ }
28
+ }
29
+ /**
30
+ * Resolve editor / runtime / LSP dial config from CLI flags, env, and the
31
+ * registry. The editor port is fully resolved (CLI > env > registry > `6550`);
32
+ * the runtime and LSP channels resolve a pin only (CLI > env, else `undefined` =
33
+ * discover lazily).
34
+ *
35
+ * @param cli - parsed CLI flags (the highest-precedence tier)
36
+ * @param projectPath - absolute project path used for registry discovery when
37
+ * the editor channel has no pin
38
+ * @throws PortConfigError when an effective pin (the value precedence selected)
39
+ * is not an integer 1–65535 — shadowed values are not validated
40
+ */
41
+ export function resolvePortConfig(cli, projectPath) {
42
+ // Editor — the one channel dialed eagerly, so resolve to a concrete port.
43
+ let editorPort;
44
+ let editorPinned;
45
+ let editorSource;
46
+ const envEditorPort = process.env.GODOT_MCP_EDITOR_PORT;
47
+ if (cli.editorPort) {
48
+ requireValidPin(cli.editorPort, "--editor-port");
49
+ editorPort = cli.editorPort;
50
+ editorPinned = true;
51
+ editorSource = "cli";
52
+ }
53
+ else if (envEditorPort) {
54
+ requireValidPin(envEditorPort, "GODOT_MCP_EDITOR_PORT");
55
+ editorPort = envEditorPort;
56
+ editorPinned = true;
57
+ editorSource = "env";
58
+ }
59
+ else {
60
+ const entry = lookupProject(projectPath);
61
+ if (entry) {
62
+ editorPort = String(entry.port);
63
+ editorSource = "discovery";
64
+ }
65
+ else {
66
+ editorPort = DEFAULT_EDITOR_PORT;
67
+ editorSource = "default";
68
+ }
69
+ editorPinned = false;
70
+ }
71
+ // Runtime — pin only (CLI > env); undefined → runtimeConnection discovers lazily.
72
+ const envRuntimePort = process.env.GODOT_MCP_RUNTIME_PORT;
73
+ if (cli.runtimePort)
74
+ requireValidPin(cli.runtimePort, "--runtime-port");
75
+ else if (envRuntimePort)
76
+ requireValidPin(envRuntimePort, "GODOT_MCP_RUNTIME_PORT");
77
+ const runtimePort = cli.runtimePort ?? envRuntimePort;
78
+ const runtimeSource = cli.runtimePort ? "cli" : envRuntimePort ? "env" : "discovery";
79
+ // LSP — override only (CLI > env); undefined → lspClient discovers lazily.
80
+ const envLspPort = process.env.GODOT_MCP_LSP_PORT;
81
+ if (cli.lspPort)
82
+ requireValidPin(cli.lspPort, "--lsp-port");
83
+ else if (envLspPort)
84
+ requireValidPin(envLspPort, "GODOT_MCP_LSP_PORT");
85
+ const lspPort = cli.lspPort ?? envLspPort;
86
+ const lspHost = cli.lspHost ?? process.env.GODOT_MCP_LSP_HOST;
87
+ const lspSource = cli.lspPort ? "cli" : envLspPort ? "env" : "discovery";
88
+ return {
89
+ editorPort,
90
+ editorPinned,
91
+ editorSource,
92
+ runtimePort,
93
+ runtimeSource,
94
+ lspPort,
95
+ lspHost,
96
+ lspSource,
97
+ };
98
+ }
99
+ /**
100
+ * {@link resolvePortConfig} as a preflight gate: a {@link PortConfigError}
101
+ * becomes a precise stderr line + `process.exit(1)` (fail loud at startup, never
102
+ * an ERR_INVALID_URL crash at first dial). Anything else rethrows.
103
+ */
104
+ export function resolvePortConfigOrExit(cli, projectPath) {
105
+ try {
106
+ return resolvePortConfig(cli, projectPath);
107
+ }
108
+ catch (err) {
109
+ if (err instanceof PortConfigError) {
110
+ process.stderr.write(`[godot-mcp] ${err.message}\n`);
111
+ process.exit(1);
112
+ }
113
+ throw err;
114
+ }
115
+ }
116
+ /**
117
+ * Log each channel's resolved port + source to **stderr** — never stdout, which
118
+ * is the MCP transport (a stray write there reaches the LLM). Informational; the
119
+ * actionable desync error surfaces separately from the bridge. "auto" marks a
120
+ * channel with no pin (discovered per connect).
121
+ */
122
+ export function logResolvedPortConfig(config) {
123
+ const runtime = config.runtimePort ?? "auto";
124
+ const lsp = config.lspPort ?? "auto";
125
+ process.stderr.write(`[godot-mcp] port config: editor=${config.editorPort} [${config.editorSource}], ` +
126
+ `runtime=${runtime} [${config.runtimeSource}], lsp=${lsp} [${config.lspSource}]\n`);
127
+ }