@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.
- package/ATTRIBUTIONS.md +141 -0
- package/LICENSE +28 -0
- package/README.md +386 -4
- package/dist/extensions/extensionChanges.js +131 -0
- package/dist/extensions/extensionCommand.js +25 -0
- package/dist/extensions/extensionDiscovery.js +141 -0
- package/dist/extensions/extensionRegistrar.js +82 -0
- package/dist/extensions/extensions.js +33 -0
- package/dist/groups/builtinGroups.js +60 -0
- package/dist/groups/defs/3dTools.js +17 -0
- package/dist/groups/defs/animationAuthoring.js +16 -0
- package/dist/groups/defs/assetOps.js +6 -0
- package/dist/groups/defs/audio.js +6 -0
- package/dist/groups/defs/classdb.js +6 -0
- package/dist/groups/defs/cleanup.js +6 -0
- package/dist/groups/defs/debugger.js +6 -0
- package/dist/groups/defs/editorAdvanced.js +16 -0
- package/dist/groups/defs/inputMap.js +6 -0
- package/dist/groups/defs/layerNaming.js +6 -0
- package/dist/groups/defs/lspCodeAnalysis.js +23 -0
- package/dist/groups/defs/lspCodeNavigation.js +15 -0
- package/dist/groups/defs/navigation.js +17 -0
- package/dist/groups/defs/particles.js +21 -0
- package/dist/groups/defs/pathEditing.js +22 -0
- package/dist/groups/defs/placeholders.js +28 -0
- package/dist/groups/defs/procedural.js +6 -0
- package/dist/groups/defs/resourceIo.js +6 -0
- package/dist/groups/defs/runtimeAdvanced.js +17 -0
- package/dist/groups/defs/sceneAdvanced.js +6 -0
- package/dist/groups/defs/sceneInheritance.js +6 -0
- package/dist/groups/defs/signals.js +6 -0
- package/dist/groups/defs/spriteframes.js +16 -0
- package/dist/groups/defs/theme.js +6 -0
- package/dist/groups/defs/tilemap.js +6 -0
- package/dist/groups/defs/tileset.js +22 -0
- package/dist/groups/defs/tilesetEdit.js +21 -0
- package/dist/groups/defs/userData.js +6 -0
- package/dist/groups/extensionGroups.js +182 -0
- package/dist/groups/groupActivation.js +210 -0
- package/dist/groups/groupCatalogue.js +52 -0
- package/dist/groups/groupMatch.js +182 -0
- package/dist/groups/groupResult.js +16 -0
- package/dist/groups/groupState.js +15 -0
- package/dist/groups/groupToolHandlers.js +103 -0
- package/dist/groups/groupTypes.js +10 -0
- package/dist/groups/groups.js +205 -0
- package/dist/index.js +144 -0
- package/dist/lsp/lspClient.js +509 -0
- package/dist/lsp/lspLabels.js +105 -0
- package/dist/lsp/lspProjectScan.js +156 -0
- package/dist/lsp/lspSession.js +139 -0
- package/dist/lsp/lspStatusReporter.js +76 -0
- package/dist/lsp/lspUri.js +68 -0
- package/dist/mcp/prompts.js +59 -0
- package/dist/mcp/resources.js +113 -0
- package/dist/mcp/roots.js +30 -0
- package/dist/registration/catalogue.js +123 -0
- package/dist/registration/extensionCollision.js +38 -0
- package/dist/registration/operations.js +67 -0
- package/dist/registration/screenshotResponse.js +71 -0
- package/dist/registration/toolDispatch.js +76 -0
- package/dist/registration/toolMeta.js +104 -0
- package/dist/registration/toolRefs.js +48 -0
- package/dist/registration/toolRegistry.js +211 -0
- package/dist/registry.js +291 -0
- package/dist/registryLiveness.js +113 -0
- package/dist/security/pathGuard.js +97 -0
- package/dist/security/profiles.js +104 -0
- package/dist/security/untrusted.js +23 -0
- package/dist/shared/errorContract.js +154 -0
- package/dist/shared/errors.js +21 -0
- package/dist/shared/pagination.js +135 -0
- package/dist/shared/schemaCoercion.js +173 -0
- package/dist/shared/stableJson.js +27 -0
- package/dist/shared/types.js +1 -0
- package/dist/shared/version.js +82 -0
- package/dist/startup/cliArgs.js +103 -0
- package/dist/startup/configReload.js +57 -0
- package/dist/startup/hooks.js +89 -0
- package/dist/startup/lifecycle.js +22 -0
- package/dist/startup/portConfig.js +127 -0
- package/dist/startup/reconcile.js +81 -0
- package/dist/startup/registrars.js +59 -0
- package/dist/startup/serverMode.js +19 -0
- package/dist/startup/startupEnv.js +142 -0
- package/dist/tools/animation.js +88 -0
- package/dist/tools/asset.js +58 -0
- package/dist/tools/assetWrite.js +16 -0
- package/dist/tools/audio.js +40 -0
- package/dist/tools/classdb.js +43 -0
- package/dist/tools/collision.js +23 -0
- package/dist/tools/debug.js +46 -0
- package/dist/tools/diff.js +18 -0
- package/dist/tools/editor.js +222 -0
- package/dist/tools/file.js +38 -0
- package/dist/tools/folder.js +27 -0
- package/dist/tools/inputMap.js +54 -0
- package/dist/tools/layerNames.js +29 -0
- package/dist/tools/lsp.js +524 -0
- package/dist/tools/navigation.js +24 -0
- package/dist/tools/node.js +145 -0
- package/dist/tools/nodeManagement.js +82 -0
- package/dist/tools/particles.js +83 -0
- package/dist/tools/path.js +30 -0
- package/dist/tools/playtest.js +99 -0
- package/dist/tools/procedural.js +101 -0
- package/dist/tools/resource.js +47 -0
- package/dist/tools/runtime.js +338 -0
- package/dist/tools/save.js +64 -0
- package/dist/tools/scene.js +102 -0
- package/dist/tools/sceneInheritance.js +19 -0
- package/dist/tools/sceneQuery.js +38 -0
- package/dist/tools/script.js +75 -0
- package/dist/tools/signals.js +53 -0
- package/dist/tools/sound.js +31 -0
- package/dist/tools/spatial.js +41 -0
- package/dist/tools/spriteframes.js +94 -0
- package/dist/tools/texture.js +35 -0
- package/dist/tools/theme.js +35 -0
- package/dist/tools/threeD.js +116 -0
- package/dist/tools/tilemap.js +51 -0
- package/dist/tools/tileset.js +226 -0
- package/dist/transport/authHandshake.js +43 -0
- package/dist/transport/bridge.js +225 -0
- package/dist/transport/channel.js +352 -0
- package/dist/transport/heartbeat.js +54 -0
- package/dist/transport/runtimeConnection.js +239 -0
- package/dist/transport/tokenPath.js +98 -0
- 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
|
+
}
|