@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,131 @@
|
|
|
1
|
+
import { isExcludedByReadOnly } from "../security/profiles.js";
|
|
2
|
+
import { registerGroupSystem, addExtensionGroup, removeExtensionCommand, removeUngroupedExtensionTool, } from "../groups/groups.js";
|
|
3
|
+
import { removeToolByName, updateToolRef, hasToolRef } from "../registration/toolRefs.js";
|
|
4
|
+
import { batchToolRegistration } from "../registration/toolRegistry.js";
|
|
5
|
+
import { extensionNameCollides } from "../registration/extensionCollision.js";
|
|
6
|
+
import { extensionAnnotations, toolNameFromMethod, toExtensionCommand } from "./extensionCommand.js";
|
|
7
|
+
/**
|
|
8
|
+
* Construct the extension change-application service. The registrar is injected
|
|
9
|
+
* (the SAME shared instance the facade hands to discovery) so all ledger reads and
|
|
10
|
+
* mutations land on one consistency boundary; getReadOnly is injected (a live read
|
|
11
|
+
* of profiles.isReadOnly) so this module depends on no other composition module —
|
|
12
|
+
* maximising unit-testability with a fake bridge + fake server.
|
|
13
|
+
*/
|
|
14
|
+
export function createExtensionChangeHandler(deps) {
|
|
15
|
+
const { server, bridge, getReadOnly, registrar } = deps;
|
|
16
|
+
/**
|
|
17
|
+
* Handle "extensions.changed" push notification from the toolkit plugin.
|
|
18
|
+
* Reconciles the tool list inside one batchToolRegistration — adds new tools,
|
|
19
|
+
* removes dropped ones, updates known ones in-place — so the delta collapses to
|
|
20
|
+
* a single tools/list_changed; a following discover_tools description refresh
|
|
21
|
+
* (registerGroupSystem, only when something changed) may emit one more.
|
|
22
|
+
*/
|
|
23
|
+
function handleExtensionsChanged(params) {
|
|
24
|
+
const commands = params?.commands;
|
|
25
|
+
const removedMethods = params?.removed ?? [];
|
|
26
|
+
if (!Array.isArray(commands)) {
|
|
27
|
+
process.stderr.write("[godot-mcp] extensions.changed: invalid payload (no commands array)\n");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
let added = 0;
|
|
31
|
+
let removed = 0;
|
|
32
|
+
batchToolRegistration(server, () => {
|
|
33
|
+
// 1. Remove tools for methods listed in 'removed'.
|
|
34
|
+
for (const method of removedMethods) {
|
|
35
|
+
const toolName = toolNameFromMethod(method);
|
|
36
|
+
if (registrar.isRegistered(toolName)) {
|
|
37
|
+
// Try grouped removal first, then ungrouped.
|
|
38
|
+
if (!removeExtensionCommand(method)) {
|
|
39
|
+
removeUngroupedExtensionTool(toolName);
|
|
40
|
+
}
|
|
41
|
+
registrar.deregister(toolName);
|
|
42
|
+
removed++;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// 2. Process current command set — register new tools, reconcile known ones.
|
|
46
|
+
const ungrouped = [];
|
|
47
|
+
for (const cmd of commands) {
|
|
48
|
+
const toolName = toolNameFromMethod(cmd.method);
|
|
49
|
+
const annotations = extensionAnnotations(cmd);
|
|
50
|
+
if (registrar.isRegistered(toolName)) {
|
|
51
|
+
// Known tool — reconcile annotation/description changes in-place.
|
|
52
|
+
if (!cmd.group?.name) {
|
|
53
|
+
// Ungrouped: update or register/remove based on read-only eligibility.
|
|
54
|
+
const isToolRegistered = hasToolRef(toolName);
|
|
55
|
+
const shouldBeRegistered = !isExcludedByReadOnly(getReadOnly(), annotations);
|
|
56
|
+
if (isToolRegistered && !shouldBeRegistered) {
|
|
57
|
+
// Was eligible, now excluded (e.g., readOnlyHint removed in read-only mode).
|
|
58
|
+
removeUngroupedExtensionTool(toolName);
|
|
59
|
+
removed++;
|
|
60
|
+
}
|
|
61
|
+
else if (!isToolRegistered && shouldBeRegistered) {
|
|
62
|
+
// Was excluded, now eligible (e.g., readOnlyHint added in read-only mode).
|
|
63
|
+
// Feed into the ungrouped registration path below.
|
|
64
|
+
registrar.deregister(toolName);
|
|
65
|
+
ungrouped.push(cmd);
|
|
66
|
+
}
|
|
67
|
+
else if (isToolRegistered) {
|
|
68
|
+
// Still registered — update description + annotations in-place.
|
|
69
|
+
updateToolRef(toolName, {
|
|
70
|
+
description: cmd.description || `Extension: ${cmd.method}`,
|
|
71
|
+
annotations,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Grouped: addExtensionGroup replaces commands, so re-add to pick up changes.
|
|
76
|
+
// Already-loaded group tools are updated via updateToolRef if registered.
|
|
77
|
+
if (cmd.group?.name) {
|
|
78
|
+
const extCmd = toExtensionCommand(cmd, annotations);
|
|
79
|
+
addExtensionGroup(cmd.group.name, cmd.group.description ?? "", [extCmd], cmd.group.keywords);
|
|
80
|
+
// If the group is loaded and the tool is registered, update in-place.
|
|
81
|
+
if (hasToolRef(toolName)) {
|
|
82
|
+
const shouldBeRegistered = !isExcludedByReadOnly(getReadOnly(), annotations);
|
|
83
|
+
if (!shouldBeRegistered) {
|
|
84
|
+
removeToolByName(toolName);
|
|
85
|
+
removed++;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
updateToolRef(toolName, {
|
|
89
|
+
description: cmd.description || `Extension: ${cmd.method}`,
|
|
90
|
+
annotations,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// New tool — refuse a name that shadows a built-in or another tool (before
|
|
98
|
+
// it can enter the ledger or a group; the incumbent wins), then partition.
|
|
99
|
+
if (extensionNameCollides(toolName))
|
|
100
|
+
continue;
|
|
101
|
+
if (cmd.group?.name) {
|
|
102
|
+
const extCmd = toExtensionCommand(cmd, annotations);
|
|
103
|
+
addExtensionGroup(cmd.group.name, cmd.group.description ?? "", [extCmd], cmd.group.keywords);
|
|
104
|
+
registrar.register(toolName);
|
|
105
|
+
added++;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
ungrouped.push(cmd);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Register ungrouped tools (new + newly-eligible from annotation changes).
|
|
112
|
+
for (const cmd of ungrouped) {
|
|
113
|
+
const toolName = toolNameFromMethod(cmd.method);
|
|
114
|
+
if (hasToolRef(toolName))
|
|
115
|
+
continue; // Dedup guard.
|
|
116
|
+
if (registrar.registerExtensionTool(cmd)) {
|
|
117
|
+
registrar.register(toolName);
|
|
118
|
+
added++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
// Update discover_tools description if extension groups changed.
|
|
123
|
+
if (added > 0 || removed > 0) {
|
|
124
|
+
registerGroupSystem(server, bridge, getReadOnly());
|
|
125
|
+
}
|
|
126
|
+
if (added > 0 || removed > 0) {
|
|
127
|
+
process.stderr.write(`[godot-mcp] extensions.changed: +${added} -${removed} tools\n`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { handleExtensionsChanged };
|
|
131
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Map an extension method name ("a.b.c") to its MCP tool name ("a_b_c"). */
|
|
2
|
+
export function toolNameFromMethod(method) {
|
|
3
|
+
return method.replace(/\./g, "_");
|
|
4
|
+
}
|
|
5
|
+
/** Build the MCP annotation object for an extension command, defaulting each
|
|
6
|
+
* hint to false when the plugin omits it. */
|
|
7
|
+
export function extensionAnnotations(cmd) {
|
|
8
|
+
return {
|
|
9
|
+
readOnlyHint: cmd.annotations?.readOnlyHint ?? false,
|
|
10
|
+
destructiveHint: cmd.annotations?.destructiveHint ?? false,
|
|
11
|
+
idempotentHint: cmd.annotations?.idempotentHint ?? false,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** Build the grouped ExtensionCmd model from a wire command and its already-computed
|
|
15
|
+
* annotations. Callers compute annotations once (they also need it for the read-only
|
|
16
|
+
* eligibility check), so it is passed in rather than recomputed here. */
|
|
17
|
+
export function toExtensionCommand(cmd, annotations) {
|
|
18
|
+
return {
|
|
19
|
+
method: cmd.method,
|
|
20
|
+
toolName: toolNameFromMethod(cmd.method),
|
|
21
|
+
description: cmd.description || `Extension: ${cmd.method}`,
|
|
22
|
+
inputSchema: cmd.input_schema ?? {},
|
|
23
|
+
annotations,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { registerGroupSystem, addExtensionGroup } from "../groups/groups.js";
|
|
2
|
+
import { batchToolRegistration } from "../registration/toolRegistry.js";
|
|
3
|
+
import { extensionNameCollides } from "../registration/extensionCollision.js";
|
|
4
|
+
import { extensionAnnotations, toolNameFromMethod, toExtensionCommand } from "./extensionCommand.js";
|
|
5
|
+
// Generous for the editor-running case (<1s); protects against the rare
|
|
6
|
+
// hanging-WebSocket-handshake scenario (editor partially started, port
|
|
7
|
+
// open but not yet accepting). When the editor is fully down,
|
|
8
|
+
// ECONNREFUSED fires in ~50ms — the deadline is irrelevant.
|
|
9
|
+
const EXTENSION_DISCOVERY_DEADLINE_MS = 8000;
|
|
10
|
+
/**
|
|
11
|
+
* Construct the extension discovery service. The registrar is injected (the SAME
|
|
12
|
+
* shared instance the facade hands to change-application) so all ledger mutation
|
|
13
|
+
* lands on one consistency boundary; getReadOnly is injected (a live read of
|
|
14
|
+
* profiles.isReadOnly) so this module depends on no other composition module —
|
|
15
|
+
* maximising unit-testability with a fake bridge + fake server.
|
|
16
|
+
*/
|
|
17
|
+
export function createExtensionDiscovery(deps) {
|
|
18
|
+
const { server, bridge, getReadOnly, registrar } = deps;
|
|
19
|
+
// Single-flight latch for discoverExtensions. Holds the currently-running
|
|
20
|
+
// discovery promise so a concurrent caller joins it instead of starting a
|
|
21
|
+
// second pass.
|
|
22
|
+
let discoveryInFlight = undefined;
|
|
23
|
+
// Discover third-party extensions from the toolkit and register them as
|
|
24
|
+
// MCP tools. Called eagerly before transport (deadline-wrapped) at startup,
|
|
25
|
+
// and again from handleConfigReload on config changes.
|
|
26
|
+
//
|
|
27
|
+
// Single-flight: if a discovery is already running, JOIN it rather than start
|
|
28
|
+
// a second concurrent pass. Without this, the eager
|
|
29
|
+
// discovery losing the 8s Promise.race deadline stays in-flight, and the
|
|
30
|
+
// immediate startup reconcile (maybeStartupReconcile → handleConfigReload) would
|
|
31
|
+
// fire a second discoverExtensions() — two concurrent passes issuing duplicate
|
|
32
|
+
// extensions.refresh RPCs and double-registering the same ungrouped tools (a
|
|
33
|
+
// swallowed "already registered" throw + up to 2× tools/list_changed). The latch
|
|
34
|
+
// engages ONLY when a discovery is genuinely concurrent; in the common case and
|
|
35
|
+
// the server-before-editor fast-fail the eager pass settles (and clears the
|
|
36
|
+
// latch) before any reconcile fires, so this is a transparent pass-through.
|
|
37
|
+
//
|
|
38
|
+
// Safe against handleConfigReload's removeAllTools(): a joined pass always
|
|
39
|
+
// registers AFTER the reconcile's synchronous removeAllTools()+module rebuild
|
|
40
|
+
// (the eager pass is still awaiting its RPC when the timeout fires, so it has
|
|
41
|
+
// registered nothing yet), landing the extension tools exactly once on the
|
|
42
|
+
// freshly-rebuilt surface. If a prior pass had already registered, it has
|
|
43
|
+
// settled → the latch is clear → a fresh discovery runs. Either way: registered
|
|
44
|
+
// exactly once. registrar.register() (the ledger add) is idempotent across joins.
|
|
45
|
+
//
|
|
46
|
+
// Accepted residual (deferred fix, post-1.0): a join inherits a FAILING eager
|
|
47
|
+
// pass — if the joined discovery's RPC fails, the reconcile registers zero
|
|
48
|
+
// extensions and consumes its one-shot, so extensions recover only on the next
|
|
49
|
+
// extensions.changed / extensions_refresh / reconnect. Ultra-narrow + self-
|
|
50
|
+
// healing; built-ins unaffected. Deferred fix = retry a fresh pass on an empty join.
|
|
51
|
+
function discoverExtensions() {
|
|
52
|
+
if (discoveryInFlight)
|
|
53
|
+
return discoveryInFlight;
|
|
54
|
+
const run = runDiscovery().finally(() => {
|
|
55
|
+
// Clear only if still ours — a later pass may have replaced the latch.
|
|
56
|
+
if (discoveryInFlight === run)
|
|
57
|
+
discoveryInFlight = undefined;
|
|
58
|
+
});
|
|
59
|
+
discoveryInFlight = run;
|
|
60
|
+
return run;
|
|
61
|
+
}
|
|
62
|
+
async function runDiscovery() {
|
|
63
|
+
let registered = 0;
|
|
64
|
+
let deferredCount = 0;
|
|
65
|
+
try {
|
|
66
|
+
let result;
|
|
67
|
+
try {
|
|
68
|
+
result = (await bridge.call("extensions.refresh", {}, 5000));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
result = (await bridge.call("extensions.list", {}, 5000));
|
|
72
|
+
}
|
|
73
|
+
if (result?.success && Array.isArray(result.commands)) {
|
|
74
|
+
// Partition commands: ungrouped → immediate, grouped → deferred.
|
|
75
|
+
// Collect ungrouped for batched registration (1 notification).
|
|
76
|
+
const ungrouped = [];
|
|
77
|
+
for (const cmd of result.commands) {
|
|
78
|
+
const toolName = toolNameFromMethod(cmd.method);
|
|
79
|
+
// Skip a name that shadows a built-in or another tool before it can enter
|
|
80
|
+
// the known-extension ledger or a deferred group — the incumbent wins.
|
|
81
|
+
if (extensionNameCollides(toolName))
|
|
82
|
+
continue;
|
|
83
|
+
registrar.register(toolName);
|
|
84
|
+
const annotations = extensionAnnotations(cmd);
|
|
85
|
+
if (cmd.group?.name) {
|
|
86
|
+
const extCmd = toExtensionCommand(cmd, annotations);
|
|
87
|
+
addExtensionGroup(cmd.group.name, cmd.group.description ?? "", [extCmd], cmd.group.keywords);
|
|
88
|
+
deferredCount++;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
ungrouped.push(cmd);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Batch ungrouped extension tool registrations into a single notification.
|
|
95
|
+
// Grouped extensions stay deferred — loaded via discover_tools.
|
|
96
|
+
if (ungrouped.length > 0) {
|
|
97
|
+
batchToolRegistration(server, () => {
|
|
98
|
+
for (const cmd of ungrouped) {
|
|
99
|
+
if (registrar.registerExtensionTool(cmd))
|
|
100
|
+
registered++;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Editor unreachable or extensions.list not available — not an error.
|
|
108
|
+
// Fall through to register discover_tools with built-in groups only.
|
|
109
|
+
}
|
|
110
|
+
// Update discover_tools description to include extension groups.
|
|
111
|
+
// Uses in-place update (1 notification). For the common case
|
|
112
|
+
// (no extensions), discover_tools was already registered at startup.
|
|
113
|
+
if (deferredCount > 0) {
|
|
114
|
+
registerGroupSystem(server, bridge, getReadOnly());
|
|
115
|
+
}
|
|
116
|
+
if (registered > 0 || deferredCount > 0) {
|
|
117
|
+
const parts = [];
|
|
118
|
+
if (registered > 0)
|
|
119
|
+
parts.push(`${registered} registered`);
|
|
120
|
+
if (deferredCount > 0)
|
|
121
|
+
parts.push(`${deferredCount} deferred in groups`);
|
|
122
|
+
process.stderr.write(`[godot-mcp] extensions: ${parts.join(" + ")}\n`);
|
|
123
|
+
}
|
|
124
|
+
// Defensive re-registration: on the handleConfigReload path,
|
|
125
|
+
// removeAllTools() has cleared extensions_refresh, so re-add it.
|
|
126
|
+
// Delegates to registrar.registerRefreshTool() (self-guards via hasToolRef),
|
|
127
|
+
// which is the same helper used by the startup path.
|
|
128
|
+
registrar.registerRefreshTool();
|
|
129
|
+
}
|
|
130
|
+
function discoverEagerly(deadlineMs = EXTENSION_DISCOVERY_DEADLINE_MS) {
|
|
131
|
+
// Wrap discovery in the deadline so a slow/hanging editor handshake can't
|
|
132
|
+
// block startup. On deadline or discovery error, the caller (the startup
|
|
133
|
+
// reconcile) re-runs discovery once the version is known (immediately if it
|
|
134
|
+
// already is); the LLM can also call extensions_refresh (always registered).
|
|
135
|
+
return Promise.race([
|
|
136
|
+
discoverExtensions(),
|
|
137
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("extension discovery deadline")), deadlineMs)),
|
|
138
|
+
]).then(() => ({ timedOut: false }), () => ({ timedOut: true }));
|
|
139
|
+
}
|
|
140
|
+
return { discoverExtensions, discoverEagerly };
|
|
141
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { isExcludedByReadOnly } from "../security/profiles.js";
|
|
2
|
+
import { hasToolRef } from "../registration/toolRefs.js";
|
|
3
|
+
import { registerToolWrapped } from "../registration/toolRegistry.js";
|
|
4
|
+
import { extensionNameCollides } from "../registration/extensionCollision.js";
|
|
5
|
+
import { callAndWrap } from "../registration/toolDispatch.js";
|
|
6
|
+
import { extensionAnnotations, toolNameFromMethod } from "./extensionCommand.js";
|
|
7
|
+
const DEFAULT_EXTENSION_TIMEOUT_MS = 30_000;
|
|
8
|
+
/**
|
|
9
|
+
* Construct the extension registrar. getReadOnly is injected (a live read of
|
|
10
|
+
* profiles.isReadOnly) so this module depends on no other composition module —
|
|
11
|
+
* maximising unit-testability with a fake bridge + fake server.
|
|
12
|
+
*/
|
|
13
|
+
export function createExtensionRegistrar(deps) {
|
|
14
|
+
const { server, bridge, getReadOnly } = deps;
|
|
15
|
+
// The known-extension ledger: the names the subsystem currently considers
|
|
16
|
+
// registered. The raw Set is never exposed — all access goes through the
|
|
17
|
+
// register / isRegistered / deregister accessors below.
|
|
18
|
+
const knownExtensionTools = new Set();
|
|
19
|
+
/** Build a context-aware timeout hint for extension tools. */
|
|
20
|
+
function buildExtensionTimeoutHint(method, timeoutMs) {
|
|
21
|
+
const effectiveMs = timeoutMs ?? DEFAULT_EXTENSION_TIMEOUT_MS;
|
|
22
|
+
if (timeoutMs != null) {
|
|
23
|
+
return (`Extension tool '${method}' timed out after ${effectiveMs}ms (custom timeout). ` +
|
|
24
|
+
"If this exceeds 5 minutes, consider restructuring the tool to start work and return a polling handle rather than blocking the bridge.");
|
|
25
|
+
}
|
|
26
|
+
return (`Extension tool '${method}' timed out after ${effectiveMs / 1000}s. ` +
|
|
27
|
+
"If this tool calls external services, the extension author can increase timeout_ms in registry.add() options.");
|
|
28
|
+
}
|
|
29
|
+
function registerRefreshTool() {
|
|
30
|
+
if (!hasToolRef("extensions_refresh")) {
|
|
31
|
+
registerToolWrapped(server, bridge, "extensions_refresh", {
|
|
32
|
+
description: "Force a filesystem rescan and re-discover extension scripts. " +
|
|
33
|
+
"Call after creating, modifying, or deleting extension files from outside the Godot editor. " +
|
|
34
|
+
"Returns the updated list of extension commands.",
|
|
35
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
36
|
+
}, (input, signal) => callAndWrap(bridge, "extensions.refresh", input, { signal }));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Register one ungrouped extension command as an MCP tool. Returns true when the
|
|
41
|
+
* tool was registered, false when skipped by read-only exclusion. Callers own
|
|
42
|
+
* their own pre-checks (dedup guard), counters, and knownExtensionTools
|
|
43
|
+
* bookkeeping — this encapsulates only the shared registration recipe common to
|
|
44
|
+
* the eager-discovery and live-reconciliation ungrouped paths.
|
|
45
|
+
*/
|
|
46
|
+
function registerExtensionTool(cmd) {
|
|
47
|
+
const toolName = toolNameFromMethod(cmd.method);
|
|
48
|
+
// Refuse a name that shadows a built-in or an already-registered tool — the
|
|
49
|
+
// incumbent keeps the name (defence-in-depth at the registration choke point).
|
|
50
|
+
if (extensionNameCollides(toolName))
|
|
51
|
+
return false;
|
|
52
|
+
const annotations = extensionAnnotations(cmd);
|
|
53
|
+
// Read-only mode: skip extension tools that aren't read-only.
|
|
54
|
+
if (isExcludedByReadOnly(getReadOnly(), annotations))
|
|
55
|
+
return false;
|
|
56
|
+
const timeoutMs = cmd.timeout_ms ?? undefined;
|
|
57
|
+
const extensionTimeoutHint = buildExtensionTimeoutHint(cmd.method, timeoutMs);
|
|
58
|
+
registerToolWrapped(server, bridge, toolName, {
|
|
59
|
+
description: cmd.description || `Extension: ${cmd.method}`,
|
|
60
|
+
inputSchema: cmd.input_schema ?? {},
|
|
61
|
+
annotations,
|
|
62
|
+
}, (input, signal) => callAndWrap(bridge, cmd.method, input, {
|
|
63
|
+
timeoutMs,
|
|
64
|
+
extensionTimeoutHint,
|
|
65
|
+
signal,
|
|
66
|
+
}), {
|
|
67
|
+
godotMinVersion: cmd.min_godot_version,
|
|
68
|
+
godotMaxVersion: cmd.max_godot_version,
|
|
69
|
+
});
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
function register(toolName) {
|
|
73
|
+
knownExtensionTools.add(toolName);
|
|
74
|
+
}
|
|
75
|
+
function isRegistered(toolName) {
|
|
76
|
+
return knownExtensionTools.has(toolName);
|
|
77
|
+
}
|
|
78
|
+
function deregister(toolName) {
|
|
79
|
+
knownExtensionTools.delete(toolName);
|
|
80
|
+
}
|
|
81
|
+
return { registerExtensionTool, registerRefreshTool, register, isRegistered, deregister };
|
|
82
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createExtensionRegistrar } from "./extensionRegistrar.js";
|
|
2
|
+
import { createExtensionDiscovery } from "./extensionDiscovery.js";
|
|
3
|
+
import { createExtensionChangeHandler } from "./extensionChanges.js";
|
|
4
|
+
/**
|
|
5
|
+
* Construct the extension manager. getReadOnly is injected (a live read of
|
|
6
|
+
* profiles.isReadOnly) so this module depends on no other composition module —
|
|
7
|
+
* maximising unit-testability with a fake bridge + fake server.
|
|
8
|
+
*
|
|
9
|
+
* @returns the {@link ExtensionManager} facade over one shared registrar (single
|
|
10
|
+
* ledger), the discovery service, and the change-application service
|
|
11
|
+
*/
|
|
12
|
+
export function createExtensionManager(deps) {
|
|
13
|
+
const { server, bridge, getReadOnly } = deps;
|
|
14
|
+
// One shared registrar — owns the known-extension ledger + the register-one-tool
|
|
15
|
+
// recipe + the always-on refresh tool. The discovery and change-application
|
|
16
|
+
// services operate on THIS instance, so the ledger stays a single shared
|
|
17
|
+
// consistency boundary (two registrars would fork it).
|
|
18
|
+
const registrar = createExtensionRegistrar({ server, bridge, getReadOnly });
|
|
19
|
+
// The discovery service — the eager, deadline-wrapped, single-flight pull of
|
|
20
|
+
// extensions and their bulk registration. Injected the SAME registrar so its
|
|
21
|
+
// ledger mutations land on the one shared consistency boundary.
|
|
22
|
+
const discovery = createExtensionDiscovery({ server, bridge, getReadOnly, registrar });
|
|
23
|
+
// The change-application service — applies one extensions.changed delta
|
|
24
|
+
// (removed[] + commands) to the live surface. Injected the SAME registrar so its
|
|
25
|
+
// ledger reads/mutations land on the one shared consistency boundary.
|
|
26
|
+
const changes = createExtensionChangeHandler({ server, bridge, getReadOnly, registrar });
|
|
27
|
+
return {
|
|
28
|
+
registerRefreshTool: registrar.registerRefreshTool,
|
|
29
|
+
discoverExtensions: discovery.discoverExtensions,
|
|
30
|
+
discoverEagerly: discovery.discoverEagerly,
|
|
31
|
+
handleExtensionsChanged: changes.handleExtensionsChanged,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { runtimeAdvancedGroup } from "./defs/runtimeAdvanced.js";
|
|
2
|
+
import { signalsGroup } from "./defs/signals.js";
|
|
3
|
+
import { animationAuthoringGroup } from "./defs/animationAuthoring.js";
|
|
4
|
+
import { inputMapGroup } from "./defs/inputMap.js";
|
|
5
|
+
import { resourceIoGroup } from "./defs/resourceIo.js";
|
|
6
|
+
import { assetOpsGroup } from "./defs/assetOps.js";
|
|
7
|
+
import { placeholdersGroup } from "./defs/placeholders.js";
|
|
8
|
+
import { cleanupGroup } from "./defs/cleanup.js";
|
|
9
|
+
import { userDataGroup } from "./defs/userData.js";
|
|
10
|
+
import { sceneAdvancedGroup } from "./defs/sceneAdvanced.js";
|
|
11
|
+
import { editorAdvancedGroup } from "./defs/editorAdvanced.js";
|
|
12
|
+
import { tilemapGroup } from "./defs/tilemap.js";
|
|
13
|
+
import { tilesetGroup } from "./defs/tileset.js";
|
|
14
|
+
import { tilesetEditGroup } from "./defs/tilesetEdit.js";
|
|
15
|
+
import { themeGroup } from "./defs/theme.js";
|
|
16
|
+
import { layerNamingGroup } from "./defs/layerNaming.js";
|
|
17
|
+
import { pathEditingGroup } from "./defs/pathEditing.js";
|
|
18
|
+
import { threeDToolsGroup } from "./defs/3dTools.js";
|
|
19
|
+
import { proceduralGroup } from "./defs/procedural.js";
|
|
20
|
+
import { sceneInheritanceGroup } from "./defs/sceneInheritance.js";
|
|
21
|
+
import { audioGroup } from "./defs/audio.js";
|
|
22
|
+
import { spriteframesGroup } from "./defs/spriteframes.js";
|
|
23
|
+
import { particlesGroup } from "./defs/particles.js";
|
|
24
|
+
import { navigationGroup } from "./defs/navigation.js";
|
|
25
|
+
import { lspCodeAnalysisGroup } from "./defs/lspCodeAnalysis.js";
|
|
26
|
+
import { lspCodeNavigationGroup } from "./defs/lspCodeNavigation.js";
|
|
27
|
+
import { debuggerGroup } from "./defs/debugger.js";
|
|
28
|
+
import { classdbGroup } from "./defs/classdb.js";
|
|
29
|
+
// Canonical group order — the single source of truth for discover_tools
|
|
30
|
+
// enumeration (placeholders is 7th; classdb is last). Do NOT reorder.
|
|
31
|
+
export const GROUPS = [
|
|
32
|
+
runtimeAdvancedGroup,
|
|
33
|
+
signalsGroup,
|
|
34
|
+
animationAuthoringGroup,
|
|
35
|
+
inputMapGroup,
|
|
36
|
+
resourceIoGroup,
|
|
37
|
+
assetOpsGroup,
|
|
38
|
+
placeholdersGroup,
|
|
39
|
+
cleanupGroup,
|
|
40
|
+
userDataGroup,
|
|
41
|
+
sceneAdvancedGroup,
|
|
42
|
+
editorAdvancedGroup,
|
|
43
|
+
tilemapGroup,
|
|
44
|
+
tilesetGroup,
|
|
45
|
+
tilesetEditGroup,
|
|
46
|
+
themeGroup,
|
|
47
|
+
layerNamingGroup,
|
|
48
|
+
pathEditingGroup,
|
|
49
|
+
threeDToolsGroup,
|
|
50
|
+
proceduralGroup,
|
|
51
|
+
sceneInheritanceGroup,
|
|
52
|
+
audioGroup,
|
|
53
|
+
spriteframesGroup,
|
|
54
|
+
particlesGroup,
|
|
55
|
+
navigationGroup,
|
|
56
|
+
lspCodeAnalysisGroup,
|
|
57
|
+
lspCodeNavigationGroup,
|
|
58
|
+
debuggerGroup,
|
|
59
|
+
classdbGroup,
|
|
60
|
+
];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const threeDToolsGroup = {
|
|
2
|
+
name: "3d_tools",
|
|
3
|
+
description: "Create 3D primitives, lights, cameras, and environment setups",
|
|
4
|
+
tools: ["3d_create_primitive", "3d_setup_environment", "3d_create_light", "3d_create_camera"],
|
|
5
|
+
keywords: [
|
|
6
|
+
"3d",
|
|
7
|
+
"mesh",
|
|
8
|
+
"meshinstance",
|
|
9
|
+
"primitive",
|
|
10
|
+
"camera3d",
|
|
11
|
+
"light",
|
|
12
|
+
"environment",
|
|
13
|
+
"directional light",
|
|
14
|
+
"world environment",
|
|
15
|
+
"sky",
|
|
16
|
+
],
|
|
17
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const animationAuthoringGroup = {
|
|
2
|
+
name: "animation_authoring",
|
|
3
|
+
description: "Inspect and author keyframes, edit tracks, and configure AnimationTree state machines",
|
|
4
|
+
tools: ["animation_keyframe", "animation_get_keys", "animationtree_edit", "animationtree_list"],
|
|
5
|
+
keywords: [
|
|
6
|
+
"animation",
|
|
7
|
+
"keyframe",
|
|
8
|
+
"track",
|
|
9
|
+
"animate",
|
|
10
|
+
"animationtree",
|
|
11
|
+
"state machine",
|
|
12
|
+
"blend tree",
|
|
13
|
+
"transition",
|
|
14
|
+
"blend",
|
|
15
|
+
],
|
|
16
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const assetOpsGroup = {
|
|
2
|
+
name: "asset_ops",
|
|
3
|
+
description: "List assets, query dependencies, and import binary files into the project",
|
|
4
|
+
tools: ["asset_list", "asset_get_dependencies", "asset_import"],
|
|
5
|
+
keywords: ["asset", "import", "dependencies", "texture", "image", "list assets", "files", "browse"],
|
|
6
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const audioGroup = {
|
|
2
|
+
name: "audio",
|
|
3
|
+
description: "List and configure audio buses, effects, and volume settings",
|
|
4
|
+
tools: ["audiobus_edit", "audiobus_list"],
|
|
5
|
+
keywords: ["audio", "audiobus", "sound", "music", "volume", "bus", "effect", "reverb", "sfx"],
|
|
6
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const classdbGroup = {
|
|
2
|
+
name: "classdb",
|
|
3
|
+
description: "Search and inspect Godot class hierarchy — properties, methods, signals, inheritance",
|
|
4
|
+
tools: ["classdb_get_info", "classdb_search"],
|
|
5
|
+
keywords: ["class", "classdb", "api", "inheritance", "introspection"],
|
|
6
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const cleanupGroup = {
|
|
2
|
+
name: "cleanup",
|
|
3
|
+
description: "Delete files, scripts, scenes, resources, and folders; close open scenes",
|
|
4
|
+
tools: ["file_delete", "scene_delete", "script_delete", "resource_delete", "folder_delete", "scene_close"],
|
|
5
|
+
keywords: ["delete", "cleanup", "close", "remove", "delete file", "delete scene", "delete script"],
|
|
6
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const debuggerGroup = {
|
|
2
|
+
name: "debugger",
|
|
3
|
+
description: "Inspect debugger state, manage breakpoints, and control execution flow",
|
|
4
|
+
tools: ["debug_state", "debug_list_breakpoints", "debug_set_breakpoint", "debug_continue"],
|
|
5
|
+
keywords: ["debug", "breakpoint", "pause", "continue", "step", "debugger", "state", "breaked"],
|
|
6
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const editorAdvancedGroup = {
|
|
2
|
+
name: "editor_advanced",
|
|
3
|
+
description: "Capture editor screenshots, refresh the filesystem, and wait for idle",
|
|
4
|
+
tools: ["editor_screenshot", "editor_refresh", "editor_wait_for_idle"],
|
|
5
|
+
keywords: [
|
|
6
|
+
"screenshot",
|
|
7
|
+
"editor screenshot",
|
|
8
|
+
"refresh",
|
|
9
|
+
"reload scripts",
|
|
10
|
+
"rescan",
|
|
11
|
+
"filesystem",
|
|
12
|
+
"reimport",
|
|
13
|
+
"wait idle",
|
|
14
|
+
"editor capture",
|
|
15
|
+
],
|
|
16
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const inputMapGroup = {
|
|
2
|
+
name: "input_map",
|
|
3
|
+
description: "List, create, and edit input actions and their key/controller bindings",
|
|
4
|
+
tools: ["input_map_action", "input_map_event"],
|
|
5
|
+
keywords: ["input", "input map", "action", "key binding", "keybind", "keyboard", "controller", "gamepad", "joystick"],
|
|
6
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const layerNamingGroup = {
|
|
2
|
+
name: "layer_naming",
|
|
3
|
+
description: "Get and set physics, render, and navigation layer names",
|
|
4
|
+
tools: ["layer_names_set", "layer_names_get"],
|
|
5
|
+
keywords: ["layer", "layer name", "physics layer", "render layer", "collision layer", "collision mask", "mask"],
|
|
6
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const lspCodeAnalysisGroup = {
|
|
2
|
+
name: "lsp_code_analysis",
|
|
3
|
+
description: "GDScript diagnostics, symbols, hover info, and a project-wide compile check via the language server",
|
|
4
|
+
tools: ["lsp_diagnostics", "lsp_symbols", "lsp_hover", "lsp_project_diagnostics"],
|
|
5
|
+
keywords: [
|
|
6
|
+
"lsp",
|
|
7
|
+
"diagnostics",
|
|
8
|
+
"symbols",
|
|
9
|
+
"hover",
|
|
10
|
+
"type",
|
|
11
|
+
"gdscript",
|
|
12
|
+
"shader",
|
|
13
|
+
"gdshader",
|
|
14
|
+
"errors",
|
|
15
|
+
"warnings",
|
|
16
|
+
"validate",
|
|
17
|
+
"analyze",
|
|
18
|
+
"project",
|
|
19
|
+
"whole project",
|
|
20
|
+
"all scripts",
|
|
21
|
+
"compile",
|
|
22
|
+
],
|
|
23
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const lspCodeNavigationGroup = {
|
|
2
|
+
name: "lsp_code_navigation",
|
|
3
|
+
description: "Code completion, go-to-definition, and find references via the language server",
|
|
4
|
+
tools: ["lsp_completion", "lsp_definition", "lsp_references"],
|
|
5
|
+
keywords: [
|
|
6
|
+
"completion",
|
|
7
|
+
"definition",
|
|
8
|
+
"references",
|
|
9
|
+
"go to definition",
|
|
10
|
+
"find references",
|
|
11
|
+
"autocomplete",
|
|
12
|
+
"navigate",
|
|
13
|
+
"cross-file",
|
|
14
|
+
],
|
|
15
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const navigationGroup = {
|
|
2
|
+
name: "navigation",
|
|
3
|
+
description: "Set up navigation regions, meshes, and obstacle avoidance",
|
|
4
|
+
tools: ["navigation_edit"],
|
|
5
|
+
keywords: [
|
|
6
|
+
"nav",
|
|
7
|
+
"navigation",
|
|
8
|
+
"navmesh",
|
|
9
|
+
"pathfinding",
|
|
10
|
+
"navigate",
|
|
11
|
+
"obstacle",
|
|
12
|
+
"avoidance",
|
|
13
|
+
"navigation region",
|
|
14
|
+
"nav polygon",
|
|
15
|
+
"ai pathfinding",
|
|
16
|
+
],
|
|
17
|
+
};
|