@npgamedev/godot-mcp-server 0.0.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +89 -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 +59 -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 +355 -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 +98 -4
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group keyword matching — the query → scored → dominant-filtered → capped
|
|
3
|
+
* scoring pipeline behind discover_tools' fuzzy search. Scores a keyword against
|
|
4
|
+
* every built-in group (via the GROUPS catalogue) and every extension group (via
|
|
5
|
+
* the extensionGroups accessor), applies the recall-biased dominant-match
|
|
6
|
+
* filter, and caps the fuzzy result set (3 per keyword, 5 total). Also coerces
|
|
7
|
+
* the raw request param to a string[]. Pure leaf — no SDK, no registration, no
|
|
8
|
+
* module state.
|
|
9
|
+
*/
|
|
10
|
+
import { GROUPS, allDefs } from "./groupCatalogue.js";
|
|
11
|
+
import { extensionGroupEntries } from "./extensionGroups.js";
|
|
12
|
+
import { isAllowedInReadOnly } from "../security/profiles.js";
|
|
13
|
+
// ── Keyword matching ────────────────────────────────────────────────
|
|
14
|
+
// Substring matching requires query.length >= 3 to avoid noisy 1-2 char
|
|
15
|
+
// matches. Short domain terms ("2d", "3d", "ui", "ai") must be added as
|
|
16
|
+
// explicit exact-match keywords in the group definition.
|
|
17
|
+
function matchKeywords(query, keywords) {
|
|
18
|
+
let score = 0;
|
|
19
|
+
for (const kw of keywords) {
|
|
20
|
+
if (query === kw)
|
|
21
|
+
score += 3;
|
|
22
|
+
else if (query.includes(kw))
|
|
23
|
+
score += 2;
|
|
24
|
+
else if (kw.includes(query) && query.length >= 3)
|
|
25
|
+
score += 1;
|
|
26
|
+
}
|
|
27
|
+
return score;
|
|
28
|
+
}
|
|
29
|
+
// Score a query against a list of tool names — the loop shared by both
|
|
30
|
+
// findMatchesSingle halves (built-in groups pass group.tools directly; extension
|
|
31
|
+
// groups map cmd.toolName into an array). Each name is normalized "_"→space, then
|
|
32
|
+
// contributes +1 to `delta` when its normalized form substring-contains the query
|
|
33
|
+
// (same query.length >= 3 floor as matchKeywords) and flips `exact` on a raw- or
|
|
34
|
+
// normalized-name equality. The caller adds `delta` to its running score and ORs
|
|
35
|
+
// `exact` into its running flag.
|
|
36
|
+
function scoreToolNameTokens(q, toolNames) {
|
|
37
|
+
let delta = 0;
|
|
38
|
+
let exact = false;
|
|
39
|
+
for (const toolName of toolNames) {
|
|
40
|
+
const norm = toolName.replace(/_/g, " ");
|
|
41
|
+
if (norm.includes(q) && q.length >= 3)
|
|
42
|
+
delta += 1;
|
|
43
|
+
if (toolName === q || norm === q)
|
|
44
|
+
exact = true;
|
|
45
|
+
}
|
|
46
|
+
return { delta, exact };
|
|
47
|
+
}
|
|
48
|
+
// Recall-biased dominant-match filter. A multi-word query
|
|
49
|
+
// substring-matches several unrelated groups' single keywords (+2 each) while
|
|
50
|
+
// the intended group scores far higher; admitting those incidental matches
|
|
51
|
+
// bloats the tool context. Drop candidates below this fraction of the top score
|
|
52
|
+
// — but NEVER hide a valid group (over-activation is the safe failure direction;
|
|
53
|
+
// it is clearer for the LLM to receive extra groups and reset them than to have
|
|
54
|
+
// a valid group withheld). Safeguards: keep top-1 always, inclusive boundary,
|
|
55
|
+
// and exempt exact keyword/tool-name matches.
|
|
56
|
+
const DOMINANT_MATCH_RATIO = 0.5;
|
|
57
|
+
/**
|
|
58
|
+
* Score a single keyword against all groups, apply the dominant-match filter,
|
|
59
|
+
* and return surviving {name, score} sorted desc. Exported so the prune +
|
|
60
|
+
* recall-preservation guardrail is directly testable.
|
|
61
|
+
*/
|
|
62
|
+
export function findMatchesSingle(keyword, readOnly) {
|
|
63
|
+
const q = keyword.toLowerCase();
|
|
64
|
+
const matches = [];
|
|
65
|
+
for (const group of GROUPS) {
|
|
66
|
+
if (readOnly) {
|
|
67
|
+
const hasReadOnlyTool = group.tools.some((t) => {
|
|
68
|
+
const d = allDefs.get(t);
|
|
69
|
+
return d ? isAllowedInReadOnly(d.annotations) : false;
|
|
70
|
+
});
|
|
71
|
+
if (!hasReadOnlyTool)
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
let score = matchKeywords(q, group.keywords);
|
|
75
|
+
let exact = group.keywords.includes(q);
|
|
76
|
+
const toolNameScore = scoreToolNameTokens(q, group.tools);
|
|
77
|
+
score += toolNameScore.delta;
|
|
78
|
+
exact = exact || toolNameScore.exact;
|
|
79
|
+
if (score > 0)
|
|
80
|
+
matches.push({ name: group.name, score, exact });
|
|
81
|
+
}
|
|
82
|
+
for (const [name, ext] of extensionGroupEntries()) {
|
|
83
|
+
if (readOnly) {
|
|
84
|
+
const hasReadOnly = ext.commands.some((c) => isAllowedInReadOnly(c.annotations));
|
|
85
|
+
if (!hasReadOnly)
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
let score = 0;
|
|
89
|
+
let exact = ext.keywords.includes(q);
|
|
90
|
+
if (ext.keywords.length > 0) {
|
|
91
|
+
score += matchKeywords(q, ext.keywords);
|
|
92
|
+
}
|
|
93
|
+
const descTokens = (ext.description || name).toLowerCase().split(/\s+/);
|
|
94
|
+
for (const tok of descTokens) {
|
|
95
|
+
if (q === tok)
|
|
96
|
+
score += 2;
|
|
97
|
+
else if (tok.includes(q) && q.length >= 3)
|
|
98
|
+
score += 1;
|
|
99
|
+
}
|
|
100
|
+
const toolNameScore = scoreToolNameTokens(q, ext.commands.map((c) => c.toolName));
|
|
101
|
+
score += toolNameScore.delta;
|
|
102
|
+
exact = exact || toolNameScore.exact;
|
|
103
|
+
if (score > 0)
|
|
104
|
+
matches.push({ name, score, exact });
|
|
105
|
+
}
|
|
106
|
+
matches.sort((a, b) => b.score - a.score);
|
|
107
|
+
// Apply the dominant-match filter. matches[0] is the top score (sorted desc).
|
|
108
|
+
// Keep: the top match (i === 0), any exact match (exempt), and anything within
|
|
109
|
+
// DOMINANT_MATCH_RATIO of the top (inclusive >=). Single-keyword queries cluster
|
|
110
|
+
// within 2× so they survive intact; only the multi-word-phrase noise is pruned.
|
|
111
|
+
let kept = matches;
|
|
112
|
+
if (matches.length > 1) {
|
|
113
|
+
const cutoff = matches[0].score * DOMINANT_MATCH_RATIO;
|
|
114
|
+
kept = matches.filter((m, i) => i === 0 || m.exact || m.score >= cutoff);
|
|
115
|
+
}
|
|
116
|
+
return kept.map((m) => ({ name: m.name, score: m.score }));
|
|
117
|
+
}
|
|
118
|
+
const FUZZY_PER_ELEMENT_CAP = 3;
|
|
119
|
+
const FUZZY_TOTAL_CAP = 5;
|
|
120
|
+
/**
|
|
121
|
+
* Cap fuzzy results: 3 per keyword, 5 total.
|
|
122
|
+
* Round-robin top-1 per keyword first (each keyword gets representation),
|
|
123
|
+
* then fill remaining slots by score.
|
|
124
|
+
*/
|
|
125
|
+
export function capFuzzyResults(perKeyword) {
|
|
126
|
+
// Per-element cap: keep top-3 per keyword.
|
|
127
|
+
const cappedPerKeyword = new Map();
|
|
128
|
+
for (const [keyword, matches] of perKeyword) {
|
|
129
|
+
cappedPerKeyword.set(keyword, matches.slice(0, FUZZY_PER_ELEMENT_CAP));
|
|
130
|
+
}
|
|
131
|
+
// Collect all unique candidates (for counting truncation).
|
|
132
|
+
const allUnique = new Set();
|
|
133
|
+
for (const matches of perKeyword.values()) {
|
|
134
|
+
for (const m of matches)
|
|
135
|
+
allUnique.add(m.name);
|
|
136
|
+
}
|
|
137
|
+
// Round 1: top-1 per keyword (round-robin ensures each keyword gets representation).
|
|
138
|
+
const selected = new Set();
|
|
139
|
+
const selectedList = [];
|
|
140
|
+
for (const [, matches] of cappedPerKeyword) {
|
|
141
|
+
if (selectedList.length >= FUZZY_TOTAL_CAP)
|
|
142
|
+
break;
|
|
143
|
+
const best = matches.find((m) => !selected.has(m.name));
|
|
144
|
+
if (best) {
|
|
145
|
+
selected.add(best.name);
|
|
146
|
+
selectedList.push(best.name);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Round 2: fill remaining from all capped matches by aggregate score.
|
|
150
|
+
const remaining = new Map();
|
|
151
|
+
for (const matches of cappedPerKeyword.values()) {
|
|
152
|
+
for (const m of matches) {
|
|
153
|
+
if (selected.has(m.name))
|
|
154
|
+
continue;
|
|
155
|
+
remaining.set(m.name, (remaining.get(m.name) ?? 0) + m.score);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const sorted = [...remaining.entries()].sort((a, b) => b[1] - a[1]);
|
|
159
|
+
for (const [name] of sorted) {
|
|
160
|
+
if (selectedList.length >= FUZZY_TOTAL_CAP)
|
|
161
|
+
break;
|
|
162
|
+
selected.add(name);
|
|
163
|
+
selectedList.push(name);
|
|
164
|
+
}
|
|
165
|
+
return { selected: selectedList, additionalCount: allUnique.size - selected.size };
|
|
166
|
+
}
|
|
167
|
+
/** Coerce request param to string[]. Handles stringified JSON arrays. */
|
|
168
|
+
export function coerceRequest(raw) {
|
|
169
|
+
if (Array.isArray(raw))
|
|
170
|
+
return raw;
|
|
171
|
+
if (typeof raw === "string" && raw.startsWith("[")) {
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(raw);
|
|
174
|
+
if (Array.isArray(parsed))
|
|
175
|
+
return parsed.map(String);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* fall through */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return [raw];
|
|
182
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** A freshly-activated group: tool names map to bare { name } metas. */
|
|
2
|
+
export function activatedResult(name, toolNames, description) {
|
|
3
|
+
return { name, status: "activated", tools: toolNames.map((t) => ({ name: t })), description };
|
|
4
|
+
}
|
|
5
|
+
/** An already-loaded group (idempotent activate / status query). */
|
|
6
|
+
export function alreadyLoadedResult(name, tools, description) {
|
|
7
|
+
return { name, status: "already_loaded", tools, description };
|
|
8
|
+
}
|
|
9
|
+
/** An available (not-yet-loaded) group carrying a description. */
|
|
10
|
+
export function availableResult(name, tools, description) {
|
|
11
|
+
return { name, status: "available", tools, description };
|
|
12
|
+
}
|
|
13
|
+
/** The shared read-only-empty result (identical string in both built-in + ext paths). */
|
|
14
|
+
export function readOnlyEmptyResult(name) {
|
|
15
|
+
return availableResult(name, [], `Group '${name}' has no tools available in read-only mode.`);
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// ── Group-loaded session state ───────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Leaf module (no imports) holding which on-demand tool groups have been
|
|
4
|
+
// activated this session. A separate leaf so tool-def modules (e.g.
|
|
5
|
+
// tools/playtest.ts) can read load state via isGroupLoaded() WITHOUT importing
|
|
6
|
+
// the heavy groups.ts — which would form an import cycle once groups.ts derives
|
|
7
|
+
// its tool lookup from catalogue.ts
|
|
8
|
+
// (catalogue → tools/playtest → groups → catalogue). groups.ts mutates
|
|
9
|
+
// `loadedGroups` directly (shared Set); it is never reassigned.
|
|
10
|
+
/** Names of built-in groups activated this session (via discover_tools). */
|
|
11
|
+
export const loadedGroups = new Set();
|
|
12
|
+
/** Whether a built-in group has been activated this session. */
|
|
13
|
+
export function isGroupLoaded(name) {
|
|
14
|
+
return loadedGroups.has(name);
|
|
15
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { callAndWrap, injectSuccessHint } from "../registration/toolDispatch.js";
|
|
2
|
+
import { toolErrorFromPayload, toolErrorFromException } from "../shared/errorContract.js";
|
|
3
|
+
import { buildScreenshotResult } from "../registration/screenshotResponse.js";
|
|
4
|
+
import { RUNTIME_TOOLS, LSP_TOOLS } from "./groupCatalogue.js";
|
|
5
|
+
import { createLspHandler } from "../tools/lsp.js";
|
|
6
|
+
// ── Special-case handlers ────────────────────────────────────────────
|
|
7
|
+
// Tools with non-standard response processing. Each returns a handler
|
|
8
|
+
// function matching the registerTool callback signature.
|
|
9
|
+
/** signal_emit routes by channel (editor or runtime). */
|
|
10
|
+
function handleSignalEmit(bridge, def) {
|
|
11
|
+
return async (input) => {
|
|
12
|
+
const parsed = input;
|
|
13
|
+
// The schema maps the hidden "game" alias to "runtime" upstream, so only
|
|
14
|
+
// "runtime" reaches here as the runtime trigger; anything else is editor.
|
|
15
|
+
const channel = parsed.channel ?? "editor";
|
|
16
|
+
const params = { node_path: parsed.node_path, signal_name: parsed.signal_name, args: parsed.args ?? [] };
|
|
17
|
+
return callAndWrap(bridge, def.method, params, { runtime: channel === "runtime" });
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* editor_screenshot returns an image block plus metadata for an inline/both
|
|
22
|
+
* capture, or a lean text-only path envelope for a disk-mode capture (PNG
|
|
23
|
+
* persisted toolkit-side, only its file path returned — no image bytes).
|
|
24
|
+
*/
|
|
25
|
+
function handleEditorScreenshot(bridge, def) {
|
|
26
|
+
return async (input) => {
|
|
27
|
+
try {
|
|
28
|
+
const result = await bridge.call(def.method, input ?? {});
|
|
29
|
+
const err = toolErrorFromPayload(result);
|
|
30
|
+
if (err)
|
|
31
|
+
return err;
|
|
32
|
+
const obj = result;
|
|
33
|
+
// Disk-mode capture: a saved path with no image bytes is a success, not the
|
|
34
|
+
// empty-content failure below.
|
|
35
|
+
if (obj?.path && !obj.image_base64) {
|
|
36
|
+
return buildScreenshotResult(undefined, obj.mime_type, {
|
|
37
|
+
width: obj.width,
|
|
38
|
+
height: obj.height,
|
|
39
|
+
bytes: obj.bytes,
|
|
40
|
+
path: obj.path,
|
|
41
|
+
remediation: obj.remediation,
|
|
42
|
+
hint: obj.hint,
|
|
43
|
+
image_detail: obj.image_detail,
|
|
44
|
+
returned: obj.returned,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (!obj?.image_base64) {
|
|
48
|
+
return toolErrorFromPayload({
|
|
49
|
+
success: false,
|
|
50
|
+
code: "EMPTY_CONTENT",
|
|
51
|
+
error: "screenshot returned no image bytes — node may lack visual content. Use editor_screenshot for full viewport.",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return buildScreenshotResult(obj.image_base64, obj.mime_type, {
|
|
55
|
+
width: obj.width,
|
|
56
|
+
height: obj.height,
|
|
57
|
+
bytes: obj.bytes,
|
|
58
|
+
path: obj.path,
|
|
59
|
+
remediation: obj.remediation,
|
|
60
|
+
hint: obj.hint,
|
|
61
|
+
image_detail: obj.image_detail,
|
|
62
|
+
returned: obj.returned,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
return toolErrorFromException(err);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// ── Handler dispatch ─────────────────────────────────────────────────
|
|
71
|
+
/**
|
|
72
|
+
* Create the handler for a given tool, respecting runtime routing
|
|
73
|
+
* and special-case tools.
|
|
74
|
+
*/
|
|
75
|
+
export function createGroupToolHandler(bridge, def) {
|
|
76
|
+
switch (def.name) {
|
|
77
|
+
case "signal_emit":
|
|
78
|
+
return handleSignalEmit(bridge, def);
|
|
79
|
+
case "editor_screenshot":
|
|
80
|
+
return handleEditorScreenshot(bridge, def);
|
|
81
|
+
default: {
|
|
82
|
+
if (LSP_TOOLS.has(def.name)) {
|
|
83
|
+
const projectPath = process.env.GODOT_MCP_PROJECT_PATH ?? process.cwd();
|
|
84
|
+
const handler = createLspHandler(def.name, projectPath);
|
|
85
|
+
// LSP handlers own their TCP transport and never flow through callAndWrap,
|
|
86
|
+
// so their declared successHint must be injected here, matching the
|
|
87
|
+
// custom-handler wrapping registerTools does. injectSuccessHint no-ops on
|
|
88
|
+
// an error result or a payload that already carries a hint.
|
|
89
|
+
if (!def.successHint)
|
|
90
|
+
return handler;
|
|
91
|
+
const hintText = def.successHint;
|
|
92
|
+
return async (input) => {
|
|
93
|
+
const result = await handler(input);
|
|
94
|
+
if (!result.isError)
|
|
95
|
+
injectSuccessHint(result, hintText);
|
|
96
|
+
return result;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const useRuntime = RUNTIME_TOOLS.has(def.name);
|
|
100
|
+
return (input) => callAndWrap(bridge, def.method, input, { runtime: useRuntime });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group type leaf — the {@link GroupName} string-literal union (every built-in
|
|
3
|
+
* group's canonical name) and the {@link GroupDef} shape
|
|
4
|
+
* (name/description/tools/keywords) that each `GROUPS` entry conforms to. Pure
|
|
5
|
+
* types with zero runtime imports, so any module can depend on the group
|
|
6
|
+
* vocabulary without pulling in the catalogue data.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { registerToolWrapped, batchToolRegistration } from "../registration/toolRegistry.js";
|
|
3
|
+
import { coercedBoolean } from "../shared/schemaCoercion.js";
|
|
4
|
+
import { enrichGroupResults } from "../registration/toolMeta.js";
|
|
5
|
+
import { updateToolRef, hasToolRef } from "../registration/toolRefs.js";
|
|
6
|
+
// Static group catalogue (the GROUPS literal + its derived lookup/index sets)
|
|
7
|
+
// and group-loaded state — both leaf modules that do NOT import groups.ts, so
|
|
8
|
+
// tool-def modules never cycle back here via registration/catalogue.ts.
|
|
9
|
+
// groupCatalogue.ts owns allDefs (derived from that catalogue's ALL_TOOL_DEFS).
|
|
10
|
+
import { GROUPS, allDefs, GROUP_NAMES } from "./groupCatalogue.js";
|
|
11
|
+
import { loadedGroups } from "./groupState.js";
|
|
12
|
+
// Dynamic extension-group registry. The extensionGroups / loadedExtensionGroups
|
|
13
|
+
// maps live there (private); this orchestrator touches ext state only through
|
|
14
|
+
// these accessors (clearExtensionGroups on reset, reportExtGroupStatus for the
|
|
15
|
+
// catalog path's ext query). Activation + deactivation live in
|
|
16
|
+
// groupActivation.ts.
|
|
17
|
+
import { clearExtensionGroups, extensionGroupEntries, getExtensionGroup, loadedExtensionGroupCount, reportExtGroupStatus, } from "./extensionGroups.js";
|
|
18
|
+
// Keyword-scoring pipeline. findMatchesSingle scores a query against built-in +
|
|
19
|
+
// extension groups; capFuzzyResults caps the fuzzy set; coerceRequest normalizes
|
|
20
|
+
// the raw request param.
|
|
21
|
+
import { findMatchesSingle, capFuzzyResults, coerceRequest } from "./groupMatch.js";
|
|
22
|
+
// Group-activation lifecycle. registerGroupTools, the command/query split
|
|
23
|
+
// (activateGroupByName / reportGroupStatusByName dispatchers over activateGroup /
|
|
24
|
+
// reportGroupStatus), deactivateGroups, and buildDiscoverToolsDesc all live
|
|
25
|
+
// there; the discover_tools handler below composes them. reportGroupStatus is
|
|
26
|
+
// used directly by the catalog path; reportGroupStatusByName carries the
|
|
27
|
+
// built-in-vs-ext query dispatch at the exact/fuzzy call sites.
|
|
28
|
+
import { activateGroupByName, reportGroupStatus, reportGroupStatusByName, buildDiscoverToolsDesc, deactivateGroups, } from "./groupActivation.js";
|
|
29
|
+
export { GROUPS, GROUP_TOOL_NAMES, RUNTIME_TOOLS, LSP_TOOLS } from "./groupCatalogue.js";
|
|
30
|
+
export { addExtensionGroup, removeExtensionCommand, removeExtensionGroup, removeUngroupedExtensionTool, hasExtensionGroups, } from "./extensionGroups.js";
|
|
31
|
+
// ── Keyword matching (re-exported from groupMatch.ts) ───────────────
|
|
32
|
+
// The keyword-scoring pipeline lives in the pure leaf groupMatch.ts. Re-export
|
|
33
|
+
// findMatchesSingle — it is consumed externally (groups.test.ts,
|
|
34
|
+
// extensions.test.ts, §39 smoke) — so importers of groups.js get one stable
|
|
35
|
+
// entry point. (capFuzzyResults / coerceRequest are export-but-internal:
|
|
36
|
+
// imported above for the discover_tools handler; no barrel entry needed.)
|
|
37
|
+
export { findMatchesSingle } from "./groupMatch.js";
|
|
38
|
+
// loadedGroups (session group-load state) + isGroupLoaded() live in the leaf
|
|
39
|
+
// module groupState.ts (imported above) — lets tool-def modules read load
|
|
40
|
+
// state without importing groups.ts. resetLoadedGroups() below still clears it.
|
|
41
|
+
/** Clear loaded-group tracking (used by config reload). */
|
|
42
|
+
export function resetLoadedGroups() {
|
|
43
|
+
loadedGroups.clear();
|
|
44
|
+
clearExtensionGroups();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Register the discover_tools meta-tool and its handler.
|
|
48
|
+
* Call this during base registration. Idempotent — if the tool
|
|
49
|
+
* already exists, updates its description in-place (one notification);
|
|
50
|
+
* otherwise registers fresh (also one notification).
|
|
51
|
+
*/
|
|
52
|
+
export function registerGroupSystem(server, bridge, readOnly) {
|
|
53
|
+
if (hasToolRef("discover_tools")) {
|
|
54
|
+
updateToolRef("discover_tools", { description: buildDiscoverToolsDesc(bridge, readOnly) });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
registerToolWrapped(server, bridge, "discover_tools", {
|
|
58
|
+
description: buildDiscoverToolsDesc(bridge, readOnly),
|
|
59
|
+
inputSchema: {
|
|
60
|
+
request: z
|
|
61
|
+
.union([z.string(), z.array(z.string())])
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("Names or keywords to find tool groups. Built-in: " + GROUP_NAMES.join(", ") + " (plus extension groups)."),
|
|
64
|
+
activate: z
|
|
65
|
+
.boolean()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe("Auto-activate matching groups. Default true. Set false to browse without loading."),
|
|
68
|
+
include_schemas: coercedBoolean()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Include full parameter schemas and annotations in the response. " +
|
|
71
|
+
"Default false. Only needed when you activated a group but the " +
|
|
72
|
+
"new tools are missing from your tool list."),
|
|
73
|
+
reset: z
|
|
74
|
+
.union([z.boolean(), z.array(z.string())])
|
|
75
|
+
.optional()
|
|
76
|
+
.describe("Deactivate groups. true = reset ALL on-demand groups. " +
|
|
77
|
+
'Array of group names = selectively deactivate only those groups (e.g. reset: ["tilemap", "audio"]). ' +
|
|
78
|
+
"false or omitted = keep current groups active."),
|
|
79
|
+
},
|
|
80
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
81
|
+
}, async (input) => {
|
|
82
|
+
const parsed = input;
|
|
83
|
+
const activate = parsed.activate !== false;
|
|
84
|
+
const includeSchemas = parsed.include_schemas === true;
|
|
85
|
+
const requestIsEmpty = Array.isArray(parsed.request) && parsed.request.length === 0;
|
|
86
|
+
const groupResults = [];
|
|
87
|
+
const deactivated = [];
|
|
88
|
+
let fuzzyHint;
|
|
89
|
+
batchToolRegistration(server, () => {
|
|
90
|
+
// Phase 1: reset/deactivation (false is a no-op, same as omitting).
|
|
91
|
+
if (parsed.reset !== undefined && parsed.reset !== false) {
|
|
92
|
+
const names = parsed.reset === true ? true : parsed.reset;
|
|
93
|
+
deactivated.push(...deactivateGroups(names, readOnly));
|
|
94
|
+
}
|
|
95
|
+
// Phase 2: process request — exact names activate directly,
|
|
96
|
+
// unrecognized elements trigger fuzzy keyword search.
|
|
97
|
+
// Empty array is treated as "no request" (catalog trigger), not "zero elements".
|
|
98
|
+
if (parsed.request !== undefined && !requestIsEmpty) {
|
|
99
|
+
const elements = coerceRequest(parsed.request);
|
|
100
|
+
const allNames = new Set([
|
|
101
|
+
...GROUP_NAMES,
|
|
102
|
+
...[...extensionGroupEntries()].map(([name]) => name),
|
|
103
|
+
]);
|
|
104
|
+
const exactElements = [];
|
|
105
|
+
const fuzzyElements = [];
|
|
106
|
+
for (const el of elements) {
|
|
107
|
+
if (allNames.has(el))
|
|
108
|
+
exactElements.push(el);
|
|
109
|
+
else
|
|
110
|
+
fuzzyElements.push(el);
|
|
111
|
+
}
|
|
112
|
+
// Exact matches (uncapped — agent asked for these by name).
|
|
113
|
+
for (const name of exactElements) {
|
|
114
|
+
const result = activate
|
|
115
|
+
? activateGroupByName(server, bridge, name, readOnly)
|
|
116
|
+
: reportGroupStatusByName(bridge, name, readOnly);
|
|
117
|
+
result.match = "exact_name";
|
|
118
|
+
groupResults.push(result);
|
|
119
|
+
}
|
|
120
|
+
// Fuzzy matches (capped: 3 per element, 5 total).
|
|
121
|
+
if (fuzzyElements.length > 0) {
|
|
122
|
+
const perKeyword = new Map();
|
|
123
|
+
for (const keyword of fuzzyElements) {
|
|
124
|
+
perKeyword.set(keyword, findMatchesSingle(keyword, readOnly));
|
|
125
|
+
}
|
|
126
|
+
const { selected, additionalCount } = capFuzzyResults(perKeyword);
|
|
127
|
+
for (const name of selected) {
|
|
128
|
+
if (groupResults.some((r) => r.name === name))
|
|
129
|
+
continue;
|
|
130
|
+
const result = activate
|
|
131
|
+
? activateGroupByName(server, bridge, name, readOnly)
|
|
132
|
+
: reportGroupStatusByName(bridge, name, readOnly);
|
|
133
|
+
result.match = "loose_keyword";
|
|
134
|
+
groupResults.push(result);
|
|
135
|
+
}
|
|
136
|
+
if (additionalCount > 0) {
|
|
137
|
+
fuzzyHint = `${additionalCount} additional group(s) matched but were not activated — refine your request or pass exact group names.`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Update discover_tools description inside the batch so the
|
|
142
|
+
// tools/list_changed notification fires atomically with all
|
|
143
|
+
// registrations.
|
|
144
|
+
updateToolRef("discover_tools", { description: buildDiscoverToolsDesc(bridge, readOnly) });
|
|
145
|
+
});
|
|
146
|
+
// No params (or empty array without reset) → full catalog (no activation).
|
|
147
|
+
// Empty array + reset → reset only; hint nudges a follow-up call for the catalog.
|
|
148
|
+
const catalogRequested = parsed.request === undefined || requestIsEmpty;
|
|
149
|
+
const resetActive = parsed.reset !== undefined && parsed.reset !== false;
|
|
150
|
+
if (catalogRequested && !resetActive) {
|
|
151
|
+
for (const group of GROUPS) {
|
|
152
|
+
groupResults.push(reportGroupStatus(bridge, group.name, readOnly));
|
|
153
|
+
}
|
|
154
|
+
for (const [name] of extensionGroupEntries()) {
|
|
155
|
+
groupResults.push(reportExtGroupStatus(name));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (requestIsEmpty && resetActive && !fuzzyHint) {
|
|
159
|
+
fuzzyHint = "All groups have been reset. Call discover_tools() with no parameters to browse the full catalog.";
|
|
160
|
+
}
|
|
161
|
+
// Post-collection enrichment: replace bare {name} tool objects with
|
|
162
|
+
// full metadata for activated/already_loaded groups.
|
|
163
|
+
const extCmdLookup = new Map();
|
|
164
|
+
for (const [, ext] of extensionGroupEntries()) {
|
|
165
|
+
for (const cmd of ext.commands)
|
|
166
|
+
extCmdLookup.set(cmd.toolName, cmd);
|
|
167
|
+
}
|
|
168
|
+
enrichGroupResults(groupResults, includeSchemas, allDefs, extCmdLookup);
|
|
169
|
+
// Build response.
|
|
170
|
+
const response = { success: true, groups: groupResults };
|
|
171
|
+
if (fuzzyHint)
|
|
172
|
+
response.hint = fuzzyHint;
|
|
173
|
+
if (deactivated.length > 0) {
|
|
174
|
+
response.deactivated = deactivated;
|
|
175
|
+
if (parsed.reset === true)
|
|
176
|
+
response.reset_all = true;
|
|
177
|
+
const deactivatedTools = [];
|
|
178
|
+
for (const gName of deactivated) {
|
|
179
|
+
const group = GROUPS.find((g) => g.name === gName);
|
|
180
|
+
if (group)
|
|
181
|
+
deactivatedTools.push(...group.tools);
|
|
182
|
+
const ext = getExtensionGroup(gName);
|
|
183
|
+
if (ext)
|
|
184
|
+
deactivatedTools.push(...ext.commands.map((c) => c.toolName));
|
|
185
|
+
}
|
|
186
|
+
if (deactivatedTools.length > 0)
|
|
187
|
+
response.deactivated_tools = deactivatedTools;
|
|
188
|
+
if (!response.hint) {
|
|
189
|
+
response.hint =
|
|
190
|
+
"Deactivated tools are no longer callable. " +
|
|
191
|
+
"Call discover_tools(request=[...]) to re-activate before using them.";
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Cumulative >5 warning — checks total loaded groups across all calls.
|
|
195
|
+
const totalLoaded = loadedGroups.size + loadedExtensionGroupCount();
|
|
196
|
+
if (totalLoaded > 5) {
|
|
197
|
+
response.warning =
|
|
198
|
+
`${totalLoaded} groups currently loaded. ` +
|
|
199
|
+
"This adds many tools to your context and may degrade response quality. " +
|
|
200
|
+
"Prefer activating only the groups needed for your current task. " +
|
|
201
|
+
"Use reset to deactivate groups you no longer need.";
|
|
202
|
+
}
|
|
203
|
+
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
204
|
+
});
|
|
205
|
+
}
|