genexus-mcp 3.6.1 → 3.7.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 (34) hide show
  1. package/README.md +108 -9
  2. package/cli/commands/axi.js +311 -64
  3. package/cli/lib/config.js +99 -19
  4. package/cli/run.test.js +304 -10
  5. package/config/gx-versions.json +5 -3
  6. package/package.json +1 -1
  7. package/publish/GxMcp.Gateway.deps.json +2 -2
  8. package/publish/GxMcp.Gateway.dll +0 -0
  9. package/publish/GxMcp.Gateway.exe +0 -0
  10. package/publish/config/gx-versions.json +5 -3
  11. package/publish/config.json +12 -12
  12. package/publish/gxmcp-manifest.json +10 -10
  13. package/publish/gxmcp-sbom.json +4 -4
  14. package/publish/tool_definitions.json +5 -5
  15. package/publish/worker/GxMcp.Worker.exe +0 -0
  16. package/publish/worker/GxMcp.Worker.exe.config +12 -0
  17. package/publish/worker/Microsoft.Bcl.AsyncInterfaces.dll +0 -0
  18. package/publish/worker/Microsoft.Bcl.HashCode.dll +0 -0
  19. package/publish/worker/Microsoft.Extensions.DependencyInjection.Abstractions.dll +0 -0
  20. package/publish/worker/Microsoft.Extensions.Logging.Abstractions.dll +0 -0
  21. package/publish/worker/Npgsql.dll +0 -0
  22. package/publish/worker/System.Buffers.dll +0 -0
  23. package/publish/worker/System.Collections.Immutable.dll +0 -0
  24. package/publish/worker/System.Diagnostics.DiagnosticSource.dll +0 -0
  25. package/publish/worker/System.Memory.dll +0 -0
  26. package/publish/worker/System.Numerics.Vectors.dll +0 -0
  27. package/publish/worker/System.Runtime.CompilerServices.Unsafe.dll +0 -0
  28. package/publish/worker/System.Text.Encodings.Web.dll +0 -0
  29. package/publish/worker/System.Text.Json.dll +0 -0
  30. package/publish/worker/System.Threading.Channels.dll +0 -0
  31. package/publish/worker/System.Threading.Tasks.Extensions.dll +0 -0
  32. package/publish/worker/System.ValueTuple.dll +0 -0
  33. package/publish/worker/config/gx-versions.json +5 -3
  34. package/publish/worker/gx-versions.json +5 -3
package/cli/lib/config.js CHANGED
@@ -33,6 +33,7 @@ function generateNeutralConfig(gxPath, { workerPath, gatewayMode = 'stdio-isolat
33
33
  Server: {
34
34
  HttpPort: 0,
35
35
  McpStdio: true,
36
+ WorkerSharingMode: 'isolated',
36
37
  SessionIdleTimeoutMinutes: 10,
37
38
  WorkerIdleTimeoutMinutes: 5,
38
39
  EmitStructuredContent: false,
@@ -70,6 +71,10 @@ function getToolDefinitionsPath() {
70
71
  // 3. Fallback alongside the CLI itself (defensive — for unusual layouts).
71
72
  path.join(__dirname, '..', '..', 'publish', 'tool_definitions.json')
72
73
  ];
74
+ // An explicit gateway override is usually a fixed-path or checkout install.
75
+ // Do not hide a missing sibling artifact with this source tree's copy: the
76
+ // Gateway loads the file next to its own executable at runtime.
77
+ if (process.env.GENEXUS_MCP_GATEWAY_EXE) return candidates[0];
73
78
  for (const candidate of candidates) {
74
79
  if (fs.existsSync(candidate)) return candidate;
75
80
  }
@@ -107,14 +112,14 @@ function getGeneXusVersionCatalog() {
107
112
  legacyMajors: [
108
113
  { major: '10.3', displayName: 'GeneXus Evolution 3', defaultInstallPath: 'C:\\Program Files (x86)\\GeneXus\\GeneXusXEv3' },
109
114
  { major: '9', displayName: 'GeneXus 9.0', defaultInstallPath: 'C:\\Program Files (x86)\\ARTech\\GeneXus\\GeneXus 9.0' },
110
- { major: '8', displayName: 'GeneXus 8.0', defaultInstallPath: 'C:\\Program Files (x86)\\ARTech\\GeneXus\\GeneXus 8.0' }
115
+ { major: '8', displayName: 'GeneXus 8.0', defaultInstallPath: 'C:\\Program Files (x86)\\ARTech\\GeneXus\\gxw80' }
111
116
  ],
112
117
  source: 'built-in-fallback'
113
118
  };
114
119
  }
115
120
 
116
121
  function hasGeneXusExecutable(gxPath) {
117
- return Boolean(gxPath) && ['genexus.exe', 'gx.exe']
122
+ return Boolean(gxPath) && ['genexus.exe', 'gx.exe', 'gxw32.exe']
118
123
  .some((name) => fs.existsSync(path.join(gxPath, name)));
119
124
  }
120
125
 
@@ -165,6 +170,24 @@ function discoverGeneXusFromRegistry(preferredMajor = null) {
165
170
  } catch {
166
171
  }
167
172
  }
173
+ // Classic ARTech installers store GX8/GX9 under Setup\\<version>
174
+ // and expose the actual IDE root in the Install value rather
175
+ // than the modern GeneXus <major>\\InstallationDirectory key.
176
+ for (const setupVersion of (entry.legacySetupVersions || [])) {
177
+ const key = `${hive}\\\\${setupVersion}`;
178
+ try {
179
+ const out = execFileSync('reg.exe', ['query', key, '/v', 'Install'], {
180
+ encoding: 'utf8',
181
+ stdio: ['ignore', 'pipe', 'ignore'],
182
+ windowsHide: true,
183
+ timeout: 3000
184
+ });
185
+ const installLine = out.split(String.fromCharCode(10)).map((line) => line.trim()).find((line) => /^Install\s+REG_SZ\s+/i.test(line));
186
+ const candidate = installLine ? installLine.replace(/^Install\s+REG_SZ\s+/i, '').trim() : null;
187
+ if (candidate && hasGeneXusExecutable(candidate) && matchesPreferredGeneXusMajor(candidate, preferredMajor)) return candidate;
188
+ } catch {
189
+ }
190
+ }
168
191
  for (const legacyVersion of (entry.legacyRegistryVersions || [])) {
169
192
  const key = `${hive}\\GeneXus\\${legacyVersion}`;
170
193
  try {
@@ -259,7 +282,7 @@ function discoverGeneXusFromPath(preferredMajor = null) {
259
282
  if (process.platform !== 'win32') return null;
260
283
  try {
261
284
  const { execFileSync } = require('child_process');
262
- for (const executable of ['genexus.exe', 'gx.exe']) {
285
+ for (const executable of ['genexus.exe', 'gx.exe', 'gxw32.exe']) {
263
286
  let out;
264
287
  try {
265
288
  out = execFileSync('where.exe', [executable], {
@@ -361,7 +384,9 @@ function discoverKnowledgeBases(cwd, { maxResults = 25, scanDepth = 2 } = {}) {
361
384
  function directoryLooksLikeKnowledgeBase(dir) {
362
385
  try {
363
386
  const files = fs.readdirSync(dir);
364
- return files.some((f) => f.toLowerCase().endsWith('.gxw') || f.toLowerCase() === 'knowledgebase.connection');
387
+ if (files.some((f) => f.toLowerCase().endsWith('.gxw') || f.toLowerCase() === 'knowledgebase.connection')) return true;
388
+ const classicMarkers = new Set(['data001', 'gxspc001', 'kbdata', 'attribut.dat', 'att.xpw', 'objects.dat', 'objects.idx']);
389
+ return files.filter((f) => classicMarkers.has(f.toLowerCase())).length >= 2;
365
390
  } catch {
366
391
  return false;
367
392
  }
@@ -603,8 +628,11 @@ function migrateLegacyConfig(sourcePath, targetPath, { rejectNonMigratable = fal
603
628
 
604
629
  function resolveConfigPathNoMutate(cwd) {
605
630
  const cwdConfigPath = path.join(cwd, 'config.json');
606
- if (process.env.GX_CONFIG_PATH && fs.existsSync(process.env.GX_CONFIG_PATH)) {
607
- return process.env.GX_CONFIG_PATH;
631
+ // Match the Gateway: an explicit GX_CONFIG_PATH is authoritative even when
632
+ // the file is missing, so diagnostics do not silently inspect an unrelated
633
+ // cwd config while the runtime fails on the explicit path.
634
+ if (process.env.GX_CONFIG_PATH) {
635
+ return path.resolve(process.env.GX_CONFIG_PATH);
608
636
  }
609
637
  if (fs.existsSync(cwdConfigPath)) {
610
638
  return cwdConfigPath;
@@ -1927,6 +1955,9 @@ function readGeneXusInstallationIdentity(gxPath, options = {}) {
1927
1955
  if (!executableVersion) {
1928
1956
  executableVersion = readExecutableVersion(path.join(gxPath, 'gx.exe'));
1929
1957
  }
1958
+ if (!executableVersion) {
1959
+ executableVersion = readExecutableVersion(path.join(gxPath, 'gxw32.exe'));
1960
+ }
1930
1961
  if (executableVersion) {
1931
1962
  return {
1932
1963
  version: executableVersion,
@@ -2013,6 +2044,7 @@ function readGeneXusKbIdentity(kbPath) {
2013
2044
 
2014
2045
  let gxwFiles;
2015
2046
  let gxiFiles = [];
2047
+ let classicMarkerCount = 0;
2016
2048
  try {
2017
2049
  const allFiles = fs.readdirSync(kbPath);
2018
2050
  gxwFiles = allFiles
@@ -2021,6 +2053,8 @@ function readGeneXusKbIdentity(kbPath) {
2021
2053
  gxiFiles = allFiles
2022
2054
  .filter((fileName) => fileName.toLowerCase().endsWith('.gxi'))
2023
2055
  .map((fileName) => path.join(kbPath, fileName));
2056
+ const classicMarkers = new Set(['data001', 'gxspc001', 'kbdata', 'attribut.dat', 'att.xpw', 'objects.dat', 'objects.idx']);
2057
+ classicMarkerCount = allFiles.filter((fileName) => classicMarkers.has(fileName.toLowerCase())).length;
2024
2058
  } catch {
2025
2059
  return { version: null, major: null, source: 'unavailable', reason: 'unreadable-kb-path' };
2026
2060
  }
@@ -2028,6 +2062,9 @@ function readGeneXusKbIdentity(kbPath) {
2028
2062
  if (gxiFiles.length > 0) {
2029
2063
  return { version: '9.0', major: '9', source: 'gxi-classic', reason: null };
2030
2064
  }
2065
+ if (classicMarkerCount >= 2) {
2066
+ return { version: null, major: null, source: 'classic-dat', reason: 'classic-generation-requires-provider' };
2067
+ }
2031
2068
  return { version: null, major: null, source: 'unavailable', reason: 'no-gxw' };
2032
2069
  }
2033
2070
  if (gxwFiles.length > 1) return { version: null, major: null, source: 'unavailable', reason: 'multiple-gxw' };
@@ -2049,34 +2086,65 @@ function readGeneXusKbIdentity(kbPath) {
2049
2086
  }
2050
2087
  }
2051
2088
 
2052
- function compareGeneXusKbAndInstallation(kbPath, gxPath) {
2089
+ function compareGeneXusKbAndInstallation(kbPath, gxPath, options = {}) {
2053
2090
  const kb = readGeneXusKbIdentity(kbPath);
2054
- const gx = readGeneXusInstallationIdentity(gxPath);
2091
+ const gx = readGeneXusInstallationIdentity(gxPath, options);
2055
2092
  let status = 'unresolved';
2056
- if (kb.major && gx.major) status = kb.major === gx.major ? 'match' : 'mismatch';
2093
+ // A classic .gxi is shared by the GX8/GX9 family and does not carry a
2094
+ // reliable generation marker. Do not turn the historical default of 9
2095
+ // into a false GX8-vs-GX9 mismatch; the provider open is the authority.
2096
+ if ((kb.source === 'gxi-classic' || kb.source === 'classic-dat') && (gx.major === '8' || gx.major === '9')) {
2097
+ status = 'unresolved';
2098
+ } else if (kb.major && gx.major) {
2099
+ status = kb.major === gx.major ? 'match' : 'mismatch';
2100
+ }
2057
2101
  return { status, kb, gx };
2058
2102
  }
2059
2103
 
2060
2104
  function normalizeKbCatalog(raw) {
2105
+ const normalized = {};
2106
+ const entries = {};
2107
+ const add = (name, value) => {
2108
+ if (typeof name !== 'string' || !name) return;
2109
+ if (typeof value === 'string' && value) {
2110
+ normalized[name] = value;
2111
+ return;
2112
+ }
2113
+ if (!value || typeof value !== 'object') return;
2114
+ const kbPath = typeof value.path === 'string' ? value.path : value.Path;
2115
+ if (typeof kbPath !== 'string' || !kbPath) return;
2116
+ normalized[name] = kbPath;
2117
+ const metadata = { alias: name, path: kbPath };
2118
+ let hasLegacyMetadata = false;
2119
+ for (const [source, target] of [['driver', 'driver'], ['Driver', 'driver'], ['installationPath', 'installationPath'], ['InstallationPath', 'installationPath'], ['major', 'major'], ['Major', 'major']]) {
2120
+ if (typeof value[source] === 'string' && value[source]) {
2121
+ metadata[target] = value[source];
2122
+ hasLegacyMetadata = true;
2123
+ }
2124
+ }
2125
+ if (hasLegacyMetadata) entries[name] = metadata;
2126
+ };
2127
+
2061
2128
  if (Array.isArray(raw)) {
2062
- const normalized = {};
2063
2129
  for (const entry of raw) {
2064
2130
  if (!entry || typeof entry !== 'object') continue;
2065
2131
  const name = typeof entry.alias === 'string' ? entry.alias : entry.Alias;
2066
- const kbPath = typeof entry.path === 'string' ? entry.path : entry.Path;
2067
- if (typeof name === 'string' && name && typeof kbPath === 'string' && kbPath) {
2068
- normalized[name] = kbPath;
2069
- }
2132
+ add(name, entry);
2070
2133
  }
2134
+ Object.defineProperty(normalized, '__entries', { value: entries, enumerable: false, writable: true });
2071
2135
  return normalized;
2072
2136
  }
2073
2137
 
2074
- return raw && typeof raw === 'object' ? raw : {};
2138
+ if (raw && typeof raw === 'object') {
2139
+ for (const [name, value] of Object.entries(raw)) add(name, value);
2140
+ }
2141
+ Object.defineProperty(normalized, '__entries', { value: entries, enumerable: false, writable: true });
2142
+ return normalized;
2075
2143
  }
2076
2144
 
2077
- function readKbCatalog(configPath) {
2078
- if (!configPath) return { kbs: {}, activeKb: null, kbPath: null };
2079
- const cfg = readJsonFileSafe(configPath);
2145
+ function readKbCatalog(configPath, configOverride = undefined) {
2146
+ if (!configPath && configOverride === undefined) return { kbs: {}, activeKb: null, kbPath: null };
2147
+ const cfg = configOverride === undefined ? readJsonFileSafe(configPath) : configOverride;
2080
2148
  if (!cfg) return { kbs: {}, activeKb: null, kbPath: null };
2081
2149
  const env = cfg.Environment || {};
2082
2150
  const activeKb = typeof env.ActiveKb === 'string' && env.ActiveKb
@@ -2092,7 +2160,19 @@ function readKbCatalog(configPath) {
2092
2160
  function writeKbCatalog(configPath, { kbs, activeKb, kbPath }) {
2093
2161
  const cfg = readJsonFileSafe(configPath) || {};
2094
2162
  cfg.Environment = cfg.Environment || {};
2095
- cfg.Environment.KBs = kbs;
2163
+ const serializedKbs = {};
2164
+ for (const [name, kbPathValue] of Object.entries(kbs || {})) {
2165
+ const metadata = kbs.__entries && kbs.__entries[name];
2166
+ serializedKbs[name] = metadata
2167
+ ? {
2168
+ Path: kbPathValue,
2169
+ ...(metadata.driver ? { Driver: metadata.driver } : {}),
2170
+ ...(metadata.installationPath ? { InstallationPath: metadata.installationPath } : {}),
2171
+ ...(metadata.major ? { Major: metadata.major } : {})
2172
+ }
2173
+ : kbPathValue;
2174
+ }
2175
+ cfg.Environment.KBs = serializedKbs;
2096
2176
  if (activeKb) {
2097
2177
  cfg.Environment.ActiveKb = activeKb;
2098
2178
  cfg.Environment.DefaultKb = activeKb;
package/cli/run.test.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const test = require('node:test');
2
2
  const assert = require('node:assert/strict');
3
- const { spawnSync } = require('node:child_process');
3
+ const { spawn, spawnSync } = require('node:child_process');
4
4
  const { Readable, Writable } = require('node:stream');
5
5
  const path = require('node:path');
6
6
  const os = require('node:os');
@@ -22,8 +22,11 @@ const {
22
22
  getGeneXusCatalogEntries,
23
23
  discoverGeneXusInstallation,
24
24
  readGeneXusInstallationIdentity,
25
+ directoryLooksLikeKnowledgeBase,
25
26
  readGeneXusKbIdentity,
26
27
  compareGeneXusKbAndInstallation,
28
+ readKbCatalog,
29
+ switchActiveKb,
27
30
  patchClientConfig
28
31
  } = require('./lib/config');
29
32
  const { handleInit, resolveMcpSmokeTarget } = require('./commands/axi');
@@ -65,23 +68,29 @@ function removeTempPath(targetPath, options = {}, deps = {}) {
65
68
  }
66
69
  }
67
70
 
68
- // Issue #211 teardown safety net: a probe child that outlives its terminate
71
+ // Issue #211/#248 teardown safety net: a probe child that outlives its terminate
69
72
  // signal keeps the stubbed GxMcp.Gateway.exe image mapped, which turns the
70
73
  // cleanup rmSync into EPERM. Terminate only processes started from this test's
71
74
  // own temp dir — never a machine-wide GxMcp.Gateway.exe sweep, which would hit
72
75
  // other checkouts and the operator's own gateways.
73
76
  function stopLingeringGatewayStubs(dirPath, deps = {}) {
74
77
  const platform = deps.platform || process.platform;
75
- const run = deps.run || ((command, args) => spawnSync(command, args, { encoding: 'utf8', windowsHide: true }));
78
+ const run = deps.run || ((command, args) => spawnSync(command, args, {
79
+ encoding: 'utf8',
80
+ windowsHide: true,
81
+ timeout: 20000
82
+ }));
76
83
  if (platform !== 'win32') return false;
77
84
 
78
- const escapedDir = String(dirPath).replace(/'/g, "''");
79
- const script = [
80
- "$ErrorActionPreference = 'SilentlyContinue'",
85
+ let canonicalDir = String(dirPath);
86
+ try { canonicalDir = fs.realpathSync.native(canonicalDir); } catch { }
87
+ const escapedDir = canonicalDir.replace(/'/g, "''");
88
+ const processPipeline = [
81
89
  `Get-CimInstance Win32_Process -Filter "Name = 'GxMcp.Gateway.exe'"`,
82
- `Where-Object { $_.ExecutablePath -and ([System.IO.Path]::GetDirectoryName($_.ExecutablePath) -eq '${escapedDir}') }`,
83
- 'ForEach-Object { Stop-Process -Id $_.ProcessId -Force }'
90
+ 'Where-Object { if (-not $_.ExecutablePath -or -not $targetDirectory) { return $false }; $processDirectory = (Get-Item -LiteralPath ([System.IO.Path]::GetDirectoryName($_.ExecutablePath)) -ErrorAction SilentlyContinue).FullName; $processDirectory -and [System.StringComparer]::OrdinalIgnoreCase.Equals($processDirectory, $targetDirectory) }',
91
+ 'ForEach-Object { $processId = $_.ProcessId; Stop-Process -Id $processId -Force; try { Wait-Process -Id $processId -Timeout 5 -ErrorAction SilentlyContinue } catch { } }'
84
92
  ].join(' | ');
93
+ const script = `$ErrorActionPreference = 'SilentlyContinue'; $targetDirectory = (Get-Item -LiteralPath '${escapedDir}' -ErrorAction SilentlyContinue).FullName; ${processPipeline}`;
85
94
  try {
86
95
  run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
87
96
  } catch { }
@@ -89,9 +98,16 @@ function stopLingeringGatewayStubs(dirPath, deps = {}) {
89
98
  }
90
99
 
91
100
  test.after(() => {
92
- removeTempPath(testGxPath, { recursive: true, force: true });
93
101
  stopLingeringGatewayStubs(testGatewayDir);
94
- removeTempPath(testGatewayDir, { recursive: true, force: true });
102
+ let firstError = null;
103
+ for (const directory of [testGxPath, testGatewayDir]) {
104
+ try {
105
+ removeTempPath(directory, { recursive: true, force: true });
106
+ } catch (error) {
107
+ if (!firstError) firstError = error;
108
+ }
109
+ }
110
+ if (firstError) throw firstError;
95
111
  });
96
112
 
97
113
  function runCli(args, opts = {}) {
@@ -173,11 +189,66 @@ test('lingering gateway stub cleanup stays scoped to the test temp dir', () => {
173
189
  assert.equal(windowsCalls[0][0], 'powershell.exe');
174
190
 
175
191
  const script = windowsCalls[0][1].join(' ');
192
+ assert.ok(script.includes("'SilentlyContinue'; $targetDirectory") && script.includes('; Get-CimInstance'), 'must set PowerShell error handling before starting the process pipeline');
176
193
  assert.ok(script.includes("GxMcp.Gateway.exe"), 'must target the stubbed gateway image');
177
194
  assert.ok(script.includes('C:\\Temp\\genexus-mcp-test-1'), 'must be scoped to the test temp dir');
195
+ assert.ok(script.includes('Wait-Process -Id $processId -Timeout 5'), 'must wait for targeted processes to exit before removing the temp directory');
178
196
  assert.ok(!script.includes('Stop-Process -Name'), 'must never terminate by process name machine-wide');
179
197
  });
180
198
 
199
+ test('lingering gateway stub cleanup escapes PowerShell path literals', () => {
200
+ const calls = [];
201
+ stopLingeringGatewayStubs("C:\\Temp\\o'brien", {
202
+ platform: 'win32',
203
+ run: (_command, args) => calls.push(args.join(' '))
204
+ });
205
+ assert.equal(calls.length, 1);
206
+ assert.ok(calls[0].includes("C:\\Temp\\o''brien"), 'single quotes must be doubled inside the PowerShell literal');
207
+ });
208
+
209
+ function waitForChildExit(child, timeoutMs = 10000) {
210
+ return new Promise((resolve) => {
211
+ if (!child || child.exitCode !== null || child.signalCode !== null) {
212
+ resolve(true);
213
+ return;
214
+ }
215
+ const timer = setTimeout(() => resolve(false), timeoutMs);
216
+ child.once('exit', () => {
217
+ clearTimeout(timer);
218
+ resolve(true);
219
+ });
220
+ });
221
+ }
222
+
223
+ test('windows cleanup stops a real scoped gateway stub before removal', { skip: process.platform !== 'win32' }, async () => {
224
+ const probeDir = path.join(testGatewayDir, 'wait-probe');
225
+ const probePath = path.join(probeDir, 'GxMcp.Gateway.exe');
226
+ fs.mkdirSync(probeDir, { recursive: true });
227
+ fs.copyFileSync(testGatewayPath, probePath);
228
+ const child = spawn(probePath, ['/c', 'ping 127.0.0.1 -n 5 > nul'], {
229
+ stdio: 'ignore',
230
+ windowsHide: true
231
+ });
232
+ await new Promise((resolve, reject) => {
233
+ child.once('spawn', resolve);
234
+ child.once('error', reject);
235
+ });
236
+
237
+ stopLingeringGatewayStubs(probeDir);
238
+ let removeError = null;
239
+ try {
240
+ fs.rmSync(probeDir, { recursive: true, force: true });
241
+ } catch (error) {
242
+ removeError = error;
243
+ }
244
+ const exited = await waitForChildExit(child);
245
+ if (!exited) {
246
+ try { child.kill(); } catch { }
247
+ }
248
+ assert.equal(removeError, null, `the scoped cleanup must release the gateway image before removal: ${removeError?.message || removeError}`);
249
+ assert.equal(exited, true, 'the scoped cleanup must terminate the gateway stub before directory removal');
250
+ });
251
+
181
252
  test('a failed stub cleanup does not mask the test result', () => {
182
253
  const ran = stopLingeringGatewayStubs('C:\\Temp\\genexus-mcp-test-1', {
183
254
  platform: 'win32',
@@ -571,6 +642,23 @@ test('classic GeneXus installation identity reads gx.exe metadata', () => {
571
642
  }
572
643
  });
573
644
 
645
+ test('GeneXus 8 installation identity reads gxw32.exe metadata', () => {
646
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-gx8-version-'));
647
+ try {
648
+ fs.writeFileSync(path.join(tempRoot, 'gxw32.exe'), 'not-a-real-executable');
649
+ const identity = readGeneXusInstallationIdentity(tempRoot, {
650
+ readExecutableVersion: (exePath) => exePath.endsWith('gxw32.exe') ? '8.0.0.632' : null
651
+ });
652
+ assert.deepEqual(identity, {
653
+ version: '8.0.0.632',
654
+ major: '8',
655
+ source: 'executable-metadata'
656
+ });
657
+ } finally {
658
+ removeTempPath(tempRoot, { recursive: true, force: true });
659
+ }
660
+ });
661
+
574
662
  test('classic GeneXus installation discovery accepts GENEXUS_HOME with gx.exe', () => {
575
663
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-classic-home-'));
576
664
  const previousHome = process.env.GENEXUS_HOME;
@@ -692,6 +780,72 @@ test('KB identity reads GeneXus 9.0 classic major from gxi when gxw is absent',
692
780
  }
693
781
  });
694
782
 
783
+ test('classic DAT KB identity recognizes GX8/9 model markers without inventing a major', () => {
784
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-kb-classic-dat-'));
785
+ try {
786
+ for (const marker of ['DATA001', 'GXSPC001', 'ATTRIBUT.DAT', 'ATT.XPW']) {
787
+ fs.writeFileSync(path.join(tempRoot, marker), 'classic');
788
+ }
789
+ const identity = readGeneXusKbIdentity(tempRoot);
790
+ assert.equal(identity.major, null);
791
+ assert.equal(identity.source, 'classic-dat');
792
+ assert.equal(identity.reason, 'classic-generation-requires-provider');
793
+ assert.equal(directoryLooksLikeKnowledgeBase(tempRoot), true);
794
+ } finally {
795
+ removeTempPath(tempRoot, { recursive: true, force: true });
796
+ }
797
+ });
798
+
799
+ test('KB catalog edits preserve per-KB legacy driver metadata', () => {
800
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-kb-catalog-'));
801
+ const configPath = path.join(tempRoot, 'config.json');
802
+ try {
803
+ fs.writeFileSync(configPath, JSON.stringify({
804
+ Environment: {
805
+ KBs: {
806
+ sect80: {
807
+ Path: 'D:\\\\GX80\\\\SECT',
808
+ Driver: 'com-gxpublic',
809
+ InstallationPath: 'C:\\\\gxw80',
810
+ Major: '8'
811
+ }
812
+ },
813
+ ActiveKb: 'sect80'
814
+ }
815
+ }));
816
+
817
+ const catalog = readKbCatalog(configPath);
818
+ assert.equal(catalog.kbs.sect80, 'D:\\\\GX80\\\\SECT');
819
+ assert.equal(Object.keys(catalog.kbs).length, 1);
820
+ const switched = switchActiveKb(configPath, { name: 'sect80' });
821
+ assert.equal(switched.ok, true);
822
+ const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8'));
823
+ assert.equal(persisted.Environment.KBs.sect80.Driver, 'com-gxpublic');
824
+ assert.equal(persisted.Environment.KBs.sect80.InstallationPath, 'C:\\\\gxw80');
825
+ assert.equal(persisted.Environment.KBs.sect80.Major, '8');
826
+ } finally {
827
+ removeTempPath(tempRoot, { recursive: true, force: true });
828
+ }
829
+ });
830
+
831
+ test('classic gxi identity does not invent a GX8/GX9 mismatch before provider open', () => {
832
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-kb-gxi-compare-'));
833
+ const gx8Root = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-gx8-compare-'));
834
+ try {
835
+ fs.writeFileSync(path.join(tempRoot, 'Legacy.gxi'), 'classic-kb');
836
+ fs.writeFileSync(path.join(gx8Root, 'gxw32.exe'), 'not-a-real-executable');
837
+ const compatibility = compareGeneXusKbAndInstallation(tempRoot, gx8Root, {
838
+ readExecutableVersion: (exePath) => exePath.endsWith('gxw32.exe') ? '8.0.0.632' : null
839
+ });
840
+ assert.equal(compatibility.status, 'unresolved');
841
+ assert.equal(compatibility.kb.source, 'gxi-classic');
842
+ assert.equal(compatibility.gx.major, '8');
843
+ } finally {
844
+ removeTempPath(tempRoot, { recursive: true, force: true });
845
+ removeTempPath(gx8Root, { recursive: true, force: true });
846
+ }
847
+ });
848
+
695
849
  test('init rejects a known KB and SDK major mismatch before writing config', () => {
696
850
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-mismatch-'));
697
851
  const kbDir = path.join(tempRoot, 'kb17');
@@ -1088,6 +1242,146 @@ test('--fields validation returns usage error for invalid doctor field', () => {
1088
1242
  assert.equal(parsed.error.code, 'usage_error');
1089
1243
  });
1090
1244
 
1245
+ test('doctor client_config_sync describes the effective gateway and its source', () => {
1246
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-doctor-gateway-source-'));
1247
+ try {
1248
+ const env = sandboxHomeEnv(tempRoot);
1249
+ const configuredGateway = path.join(tempRoot, 'checkout', 'publish', 'GxMcp.Gateway.exe');
1250
+ const mismatchedGateway = path.join(tempRoot, 'other', 'GxMcp.Gateway.exe');
1251
+ const clientConfig = path.join(tempRoot, '.gemini', 'settings.json');
1252
+ fs.mkdirSync(path.dirname(configuredGateway), { recursive: true });
1253
+ fs.mkdirSync(path.dirname(clientConfig), { recursive: true });
1254
+ fs.writeFileSync(configuredGateway, '');
1255
+ fs.writeFileSync(clientConfig, JSON.stringify({
1256
+ mcpServers: {
1257
+ genexus18mcp: { command: configuredGateway, args: [] }
1258
+ }
1259
+ }));
1260
+
1261
+ const overrideEnv = { ...env, GENEXUS_MCP_GATEWAY_EXE: configuredGateway };
1262
+ const matching = runCli(['doctor', '--format', 'json'], { env: overrideEnv });
1263
+ assert.equal(matching.status, 0);
1264
+ const matchingCheck = JSON.parse(matching.stdout).ok.checks.find((c) => c.id === 'client_config_sync');
1265
+ assert.ok(matchingCheck);
1266
+ assert.equal(matchingCheck.status, 'pass');
1267
+ assert.ok(matchingCheck.detail.includes(`Configured Gateway: ${configuredGateway} (source: GENEXUS_MCP_GATEWAY_EXE)`));
1268
+ assert.doesNotMatch(matchingCheck.detail, /npm-package gateway exe/);
1269
+
1270
+ fs.writeFileSync(clientConfig, JSON.stringify({
1271
+ mcpServers: {
1272
+ genexus18mcp: { command: mismatchedGateway, args: [] }
1273
+ }
1274
+ }));
1275
+ const mismatching = runCli(['doctor', '--format', 'json'], { env: overrideEnv });
1276
+ assert.equal(mismatching.status, 0);
1277
+ const mismatchingCheck = JSON.parse(mismatching.stdout).ok.checks.find((c) => c.id === 'client_config_sync');
1278
+ assert.ok(mismatchingCheck);
1279
+ assert.equal(mismatchingCheck.status, 'warn');
1280
+ assert.ok(mismatchingCheck.detail.includes(`Configured Gateway: ${configuredGateway} (source: GENEXUS_MCP_GATEWAY_EXE)`));
1281
+ assert.doesNotMatch(mismatchingCheck.detail, /this npm package's bundled exe/);
1282
+ } finally {
1283
+ removeTempPath(tempRoot, { recursive: true, force: true });
1284
+ }
1285
+ });
1286
+
1287
+ test('doctor fails closed when GX_CONFIG_PATH points at a missing file', () => {
1288
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-doctor-config-selection-'));
1289
+ try {
1290
+ const cwdConfig = path.join(tempRoot, 'config.json');
1291
+ const missingConfig = path.join(tempRoot, 'missing.json');
1292
+ fs.writeFileSync(cwdConfig, JSON.stringify({ Environment: { KBPath: 'C:\\wrong-context' } }));
1293
+
1294
+ const result = runCli(['doctor', '--mcp-smoke', '--format', 'json'], {
1295
+ cwd: tempRoot,
1296
+ env: {
1297
+ ...sandboxHomeEnv(tempRoot),
1298
+ GX_CONFIG_PATH: missingConfig,
1299
+ GENEXUS_MCP_GATEWAY_EXE: process.execPath
1300
+ }
1301
+ });
1302
+ assert.equal(result.status, 0);
1303
+ const checks = JSON.parse(result.stdout).ok.checks;
1304
+ const configCheck = checks.find((c) => c.id === 'config_file');
1305
+ const probeCheck = checks.find((c) => c.id === 'gateway_spawn_probe');
1306
+ const smokeCheck = checks.find((c) => c.id === 'mcp_smoke');
1307
+ assert.ok(configCheck);
1308
+ assert.equal(configCheck.status, 'fail');
1309
+ assert.ok(configCheck.detail.includes(missingConfig));
1310
+ assert.ok(probeCheck);
1311
+ assert.equal(probeCheck.status, 'not_applicable');
1312
+ assert.ok(smokeCheck);
1313
+ assert.equal(smokeCheck.status, 'not_applicable');
1314
+ } finally {
1315
+ removeTempPath(tempRoot, { recursive: true, force: true });
1316
+ }
1317
+ });
1318
+
1319
+ test('doctor rejects malformed tool definitions instead of reporting only file presence', () => {
1320
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-doctor-tool-defs-'));
1321
+ try {
1322
+ const configPath = path.join(tempRoot, 'config.json');
1323
+ const toolDefinitionsPath = path.join(tempRoot, 'tool_definitions.json');
1324
+ fs.writeFileSync(configPath, JSON.stringify({}));
1325
+ fs.writeFileSync(toolDefinitionsPath, '{ invalid json');
1326
+
1327
+ const result = runCli(['doctor', '--format', 'json'], {
1328
+ cwd: tempRoot,
1329
+ env: {
1330
+ ...sandboxHomeEnv(tempRoot),
1331
+ GX_CONFIG_PATH: configPath,
1332
+ GENEXUS_MCP_GATEWAY_EXE: path.join(tempRoot, 'missing', 'GxMcp.Gateway.exe'),
1333
+ GENEXUS_MCP_TOOL_DEFINITIONS: toolDefinitionsPath
1334
+ }
1335
+ });
1336
+ assert.equal(result.status, 0);
1337
+ const check = JSON.parse(result.stdout).ok.checks.find((c) => c.id === 'tool_definitions');
1338
+ assert.ok(check);
1339
+ assert.equal(check.status, 'fail');
1340
+ assert.ok(check.detail.includes(toolDefinitionsPath));
1341
+ } finally {
1342
+ removeTempPath(tempRoot, { recursive: true, force: true });
1343
+ }
1344
+ });
1345
+
1346
+ test('doctor reports strict KB catalog ambiguity instead of ignoring Environment.KBs', () => {
1347
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-doctor-kb-catalog-'));
1348
+ try {
1349
+ const kbOne = path.join(tempRoot, 'kb-one');
1350
+ const kbTwo = path.join(tempRoot, 'kb-two');
1351
+ const configPath = path.join(tempRoot, 'config.json');
1352
+ fs.mkdirSync(kbOne, { recursive: true });
1353
+ fs.mkdirSync(kbTwo, { recursive: true });
1354
+ fs.writeFileSync(path.join(kbOne, 'model.gxw'), '');
1355
+ fs.writeFileSync(path.join(kbTwo, 'model.gxw'), '');
1356
+ fs.writeFileSync(configPath, JSON.stringify({
1357
+ GatewayMode: 'stdio-isolated',
1358
+ Environment: {
1359
+ ResolutionPolicy: 'strict',
1360
+ KBs: {
1361
+ one: { Path: kbOne },
1362
+ two: { Path: kbTwo }
1363
+ }
1364
+ }
1365
+ }));
1366
+
1367
+ const result = runCli(['doctor', '--format', 'json'], {
1368
+ cwd: tempRoot,
1369
+ env: {
1370
+ ...sandboxHomeEnv(tempRoot),
1371
+ GX_CONFIG_PATH: configPath,
1372
+ GENEXUS_MCP_GATEWAY_EXE: path.join(tempRoot, 'missing', 'GxMcp.Gateway.exe')
1373
+ }
1374
+ });
1375
+ assert.equal(result.status, 0);
1376
+ const check = JSON.parse(result.stdout).ok.checks.find((c) => c.id === 'kb_catalog');
1377
+ assert.ok(check);
1378
+ assert.equal(check.status, 'warn');
1379
+ assert.match(check.detail, /neither ActiveKb nor DefaultKb/);
1380
+ } finally {
1381
+ removeTempPath(tempRoot, { recursive: true, force: true });
1382
+ }
1383
+ });
1384
+
1091
1385
  test('doctor finds tool_definitions.json next to the gateway exe (not just dev-tree)', () => {
1092
1386
  // Regression for v2.6.6 bug: getToolDefinitionsPath() hard-coded the dev-tree
1093
1387
  // location, so every installed copy reported "tool_definitions.json is missing"
@@ -63,15 +63,17 @@
63
63
  "driver": "com-gxpublic",
64
64
  "defaultInstallPath": "C:\\Program Files (x86)\\ARTech\\GeneXus\\GeneXus 9.0",
65
65
  "registryNames": ["GeneXus 9.0"],
66
- "legacyRegistryVersions": ["9.0"]
66
+ "legacyRegistryVersions": ["9.0"],
67
+ "legacySetupVersions": ["90"]
67
68
  },
68
69
  {
69
70
  "major": "8",
70
71
  "displayName": "GeneXus 8.0",
71
72
  "driver": "com-gxpublic",
72
- "defaultInstallPath": "C:\\Program Files (x86)\\ARTech\\GeneXus\\GeneXus 8.0",
73
+ "defaultInstallPath": "C:\\Program Files (x86)\\ARTech\\GeneXus\\gxw80",
73
74
  "registryNames": ["GeneXus 8.0"],
74
- "legacyRegistryVersions": ["8.0"]
75
+ "legacyRegistryVersions": ["8.0"],
76
+ "legacySetupVersions": ["80"]
75
77
  }
76
78
  ]
77
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genexus-mcp",
3
- "version": "3.6.1",
3
+ "version": "3.7.1",
4
4
  "mcpName": "io.github.lennix1337/genexus",
5
5
  "description": "GeneXus 16, GeneXus 17, GeneXus 18 MCP server with native SDK support; basic legacy compatibility for GeneXus Evolution 3, GeneXus Evolution 2, GeneXus Evolution 1, GeneXus 15, GeneXus 9.0, GeneXus 8.0 via com-gxpublic and dotnet-reflection drivers — read, edit, and analyze Knowledge Base objects directly from Claude, Cursor, and other AI agents over the Model Context Protocol.",
6
6
  "keywords": [
@@ -7,7 +7,7 @@
7
7
  "targets": {
8
8
  ".NETCoreApp,Version=v10.0": {},
9
9
  ".NETCoreApp,Version=v10.0/win-x64": {
10
- "GxMcp.Gateway/3.6.1": {
10
+ "GxMcp.Gateway/3.7.1": {
11
11
  "dependencies": {
12
12
  "Newtonsoft.Json": "13.0.3",
13
13
  "System.Management": "10.0.5",
@@ -66,7 +66,7 @@
66
66
  }
67
67
  },
68
68
  "libraries": {
69
- "GxMcp.Gateway/3.6.1": {
69
+ "GxMcp.Gateway/3.7.1": {
70
70
  "type": "project",
71
71
  "serviceable": false,
72
72
  "sha512": ""
Binary file
Binary file