@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.
Files changed (129) hide show
  1. package/ATTRIBUTIONS.md +141 -0
  2. package/LICENSE +28 -0
  3. package/README.md +386 -4
  4. package/dist/extensions/extensionChanges.js +131 -0
  5. package/dist/extensions/extensionCommand.js +25 -0
  6. package/dist/extensions/extensionDiscovery.js +141 -0
  7. package/dist/extensions/extensionRegistrar.js +82 -0
  8. package/dist/extensions/extensions.js +33 -0
  9. package/dist/groups/builtinGroups.js +60 -0
  10. package/dist/groups/defs/3dTools.js +17 -0
  11. package/dist/groups/defs/animationAuthoring.js +16 -0
  12. package/dist/groups/defs/assetOps.js +6 -0
  13. package/dist/groups/defs/audio.js +6 -0
  14. package/dist/groups/defs/classdb.js +6 -0
  15. package/dist/groups/defs/cleanup.js +6 -0
  16. package/dist/groups/defs/debugger.js +6 -0
  17. package/dist/groups/defs/editorAdvanced.js +16 -0
  18. package/dist/groups/defs/inputMap.js +6 -0
  19. package/dist/groups/defs/layerNaming.js +6 -0
  20. package/dist/groups/defs/lspCodeAnalysis.js +23 -0
  21. package/dist/groups/defs/lspCodeNavigation.js +15 -0
  22. package/dist/groups/defs/navigation.js +17 -0
  23. package/dist/groups/defs/particles.js +21 -0
  24. package/dist/groups/defs/pathEditing.js +22 -0
  25. package/dist/groups/defs/placeholders.js +28 -0
  26. package/dist/groups/defs/procedural.js +6 -0
  27. package/dist/groups/defs/resourceIo.js +6 -0
  28. package/dist/groups/defs/runtimeAdvanced.js +17 -0
  29. package/dist/groups/defs/sceneAdvanced.js +6 -0
  30. package/dist/groups/defs/sceneInheritance.js +6 -0
  31. package/dist/groups/defs/signals.js +6 -0
  32. package/dist/groups/defs/spriteframes.js +16 -0
  33. package/dist/groups/defs/theme.js +6 -0
  34. package/dist/groups/defs/tilemap.js +6 -0
  35. package/dist/groups/defs/tileset.js +22 -0
  36. package/dist/groups/defs/tilesetEdit.js +21 -0
  37. package/dist/groups/defs/userData.js +6 -0
  38. package/dist/groups/extensionGroups.js +182 -0
  39. package/dist/groups/groupActivation.js +210 -0
  40. package/dist/groups/groupCatalogue.js +52 -0
  41. package/dist/groups/groupMatch.js +182 -0
  42. package/dist/groups/groupResult.js +16 -0
  43. package/dist/groups/groupState.js +15 -0
  44. package/dist/groups/groupToolHandlers.js +103 -0
  45. package/dist/groups/groupTypes.js +10 -0
  46. package/dist/groups/groups.js +205 -0
  47. package/dist/index.js +144 -0
  48. package/dist/lsp/lspClient.js +509 -0
  49. package/dist/lsp/lspLabels.js +105 -0
  50. package/dist/lsp/lspProjectScan.js +156 -0
  51. package/dist/lsp/lspSession.js +139 -0
  52. package/dist/lsp/lspStatusReporter.js +76 -0
  53. package/dist/lsp/lspUri.js +68 -0
  54. package/dist/mcp/prompts.js +59 -0
  55. package/dist/mcp/resources.js +113 -0
  56. package/dist/mcp/roots.js +30 -0
  57. package/dist/registration/catalogue.js +123 -0
  58. package/dist/registration/extensionCollision.js +38 -0
  59. package/dist/registration/operations.js +67 -0
  60. package/dist/registration/screenshotResponse.js +71 -0
  61. package/dist/registration/toolDispatch.js +76 -0
  62. package/dist/registration/toolMeta.js +104 -0
  63. package/dist/registration/toolRefs.js +48 -0
  64. package/dist/registration/toolRegistry.js +211 -0
  65. package/dist/registry.js +291 -0
  66. package/dist/registryLiveness.js +113 -0
  67. package/dist/security/pathGuard.js +97 -0
  68. package/dist/security/profiles.js +104 -0
  69. package/dist/security/untrusted.js +23 -0
  70. package/dist/shared/errorContract.js +154 -0
  71. package/dist/shared/errors.js +21 -0
  72. package/dist/shared/pagination.js +135 -0
  73. package/dist/shared/schemaCoercion.js +173 -0
  74. package/dist/shared/stableJson.js +27 -0
  75. package/dist/shared/types.js +1 -0
  76. package/dist/shared/version.js +89 -0
  77. package/dist/startup/cliArgs.js +103 -0
  78. package/dist/startup/configReload.js +57 -0
  79. package/dist/startup/hooks.js +89 -0
  80. package/dist/startup/lifecycle.js +59 -0
  81. package/dist/startup/portConfig.js +127 -0
  82. package/dist/startup/reconcile.js +81 -0
  83. package/dist/startup/registrars.js +59 -0
  84. package/dist/startup/serverMode.js +19 -0
  85. package/dist/startup/startupEnv.js +142 -0
  86. package/dist/tools/animation.js +88 -0
  87. package/dist/tools/asset.js +58 -0
  88. package/dist/tools/assetWrite.js +16 -0
  89. package/dist/tools/audio.js +40 -0
  90. package/dist/tools/classdb.js +43 -0
  91. package/dist/tools/collision.js +23 -0
  92. package/dist/tools/debug.js +46 -0
  93. package/dist/tools/diff.js +18 -0
  94. package/dist/tools/editor.js +222 -0
  95. package/dist/tools/file.js +38 -0
  96. package/dist/tools/folder.js +27 -0
  97. package/dist/tools/inputMap.js +54 -0
  98. package/dist/tools/layerNames.js +29 -0
  99. package/dist/tools/lsp.js +524 -0
  100. package/dist/tools/navigation.js +24 -0
  101. package/dist/tools/node.js +145 -0
  102. package/dist/tools/nodeManagement.js +82 -0
  103. package/dist/tools/particles.js +83 -0
  104. package/dist/tools/path.js +30 -0
  105. package/dist/tools/playtest.js +99 -0
  106. package/dist/tools/procedural.js +101 -0
  107. package/dist/tools/resource.js +47 -0
  108. package/dist/tools/runtime.js +338 -0
  109. package/dist/tools/save.js +64 -0
  110. package/dist/tools/scene.js +102 -0
  111. package/dist/tools/sceneInheritance.js +19 -0
  112. package/dist/tools/sceneQuery.js +38 -0
  113. package/dist/tools/script.js +75 -0
  114. package/dist/tools/signals.js +53 -0
  115. package/dist/tools/sound.js +31 -0
  116. package/dist/tools/spatial.js +41 -0
  117. package/dist/tools/spriteframes.js +94 -0
  118. package/dist/tools/texture.js +35 -0
  119. package/dist/tools/theme.js +35 -0
  120. package/dist/tools/threeD.js +116 -0
  121. package/dist/tools/tilemap.js +51 -0
  122. package/dist/tools/tileset.js +226 -0
  123. package/dist/transport/authHandshake.js +43 -0
  124. package/dist/transport/bridge.js +225 -0
  125. package/dist/transport/channel.js +355 -0
  126. package/dist/transport/heartbeat.js +54 -0
  127. package/dist/transport/runtimeConnection.js +239 -0
  128. package/dist/transport/tokenPath.js +98 -0
  129. package/package.json +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
+ }