genexus-mcp 3.5.3 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,7 +57,7 @@ needed. Existing tool names, arguments, and MCP client configuration formats do
57
57
  not change.
58
58
 
59
59
  <!-- BEGIN GENERATED: gx-compatibility -->
60
- Supported SDK majors: **GeneXus 17, GeneXus 18**.
60
+ Supported SDK majors: **GeneXus 16, GeneXus 17, GeneXus 18**.
61
61
  Primary SDK: **GeneXus 18**.
62
62
  Source of truth: `config/gx-versions.json`.
63
63
  <!-- END GENERATED: gx-compatibility -->
@@ -67,6 +67,13 @@ catalog only after compiling the Worker with that SDK and passing the focused
67
67
  tests plus a live KB smoke. This prevents the server from claiming compatibility
68
68
  based only on a version string.
69
69
 
70
+ ### Legacy GeneXus compatibility (GX8 to GX15)
71
+
72
+ The server also includes best-effort dynamic compatibility for legacy installations:
73
+ - **GeneXus Evolution 1 (10.1), Evolution 2 (10.2), Evolution 3 (10.3), and GeneXus 15**: Driven via runtime reflection (`dotnet-reflection`), dynamically adapting to missing types or structural differences (such as module-less KBs without `QualifiedName`).
74
+ - **GeneXus 8.0 and GeneXus 9.0**: Driven via classic Win32 COM automation (`com-gxpublic`), late-binding to `GXPublic.GXPublic` on an STA thread to open, inspect, and read objects from classic `.gxi` Knowledge Bases.
75
+ - **Graceful degradation**: Modern tools that require features introduced in newer GeneXus versions (such as `genexus_api`, `genexus_gam`, or `genexus_module`) return structured `UNSUPPORTED_IN_GENEXUS_VERSION` errors indicating the required minimum version rather than failing ungracefully.
76
+
70
77
  ---
71
78
 
72
79
  ## What you can do with it
@@ -59,7 +59,9 @@ function validateClientIds(ids) {
59
59
 
60
60
  function isSupportedCatalogMajor(catalog, major) {
61
61
  if (!major) return true;
62
- return catalog.supportedMajors.some((entry) => String(entry.major) === String(major));
62
+ const inSupported = catalog.supportedMajors && catalog.supportedMajors.some((entry) => String(entry.major) === String(major));
63
+ if (inSupported) return true;
64
+ return Array.isArray(catalog.legacyMajors) && catalog.legacyMajors.some((entry) => String(entry.major) === String(major));
63
65
  }
64
66
 
65
67
  function parseFieldSelection(raw) {
@@ -176,6 +178,37 @@ function buildStatusData(cwd) {
176
178
  return { ready, configFound, gatewayExeFound, kbLooksValid, configPath, gatewayExePath, kbPath, gxPath, configSource };
177
179
  }
178
180
 
181
+ const PROBE_EXIT_GRACE_MS = 2000;
182
+
183
+ // Stop a probe child and wait for it to actually exit. On Windows child.kill()
184
+ // only signals the direct process and returns before the OS releases the
185
+ // executable image, so reporting success right after kill() left the exe file
186
+ // handle open (Issue #211: the shared gateway stub in cli/run.test.js made the
187
+ // suite teardown fail with EPERM while every assertion passed). Resolves true
188
+ // once the child exited, false when it did not within the bounded grace.
189
+ function stopProbeChild(child, graceMs = PROBE_EXIT_GRACE_MS) {
190
+ return new Promise((resolve) => {
191
+ if (!child || child.exitCode !== null || child.signalCode !== null) {
192
+ resolve(true);
193
+ return;
194
+ }
195
+ let settled = false;
196
+ const finish = (exited) => {
197
+ if (settled) return;
198
+ settled = true;
199
+ clearTimeout(timer);
200
+ resolve(exited);
201
+ };
202
+ const timer = setTimeout(() => finish(false), graceMs);
203
+ child.once('exit', () => finish(true));
204
+ try {
205
+ child.kill();
206
+ } catch {
207
+ finish(true);
208
+ }
209
+ });
210
+ }
211
+
179
212
  async function spawnGatewayProbe({ env = process.env, spawnHoldMs, timeoutMs, label, successDetail }) {
180
213
  const gatewayExePath = getGatewayExePath();
181
214
 
@@ -209,17 +242,25 @@ async function spawnGatewayProbe({ env = process.env, spawnHoldMs, timeoutMs, la
209
242
  });
210
243
 
211
244
  child.once('spawn', () => {
212
- setTimeout(() => {
213
- try { child.kill(); } catch { }
214
- finish({ status: 'pass', detail: successDetail });
245
+ setTimeout(async () => {
246
+ const exited = await stopProbeChild(child);
247
+ if (exited) {
248
+ finish({ status: 'pass', detail: successDetail });
249
+ } else {
250
+ finish({ status: 'warn', detail: `${label}: process did not exit after the stop signal; it may still hold the gateway exe.` });
251
+ }
215
252
  }, spawnHoldMs);
216
253
  });
217
254
 
218
- setTimeout(() => {
219
- if (!done) {
220
- try { child.kill(); } catch { }
221
- finish({ status: 'warn', detail: `${label} timed out; process was force-stopped.` });
222
- }
255
+ setTimeout(async () => {
256
+ if (done) return;
257
+ const exited = await stopProbeChild(child);
258
+ finish({
259
+ status: 'warn',
260
+ detail: exited
261
+ ? `${label} timed out; process was force-stopped.`
262
+ : `${label} timed out and did not exit after the stop signal; it may still hold the gateway exe.`
263
+ });
223
264
  }, timeoutMs);
224
265
  } catch (err) {
225
266
  finish({ status: 'fail', detail: `${label} threw: ${err.message}` });
@@ -562,6 +603,50 @@ function buildInProcessBuildAssemblyLoadCheck(gxPath) {
562
603
  };
563
604
  }
564
605
 
606
+ function buildGxPublicComCheck(gxMajor, gxPath) {
607
+ if (gxMajor !== '8' && gxMajor !== '9') {
608
+ return { id: 'gxpublic_com_registration', status: 'not_applicable', detail: 'Not a GeneXus 8.0/9.0 installation.' };
609
+ }
610
+ const child_process = require('child_process');
611
+ let isRegistered = false;
612
+ try {
613
+ const res = child_process.spawnSync('reg', ['query', 'HKCR\\GXPublic.GXPublic'], { encoding: 'utf8', timeout: 2000 });
614
+ isRegistered = res.status === 0;
615
+ } catch { }
616
+
617
+ if (isRegistered) {
618
+ return { id: 'gxpublic_com_registration', status: 'pass', detail: 'GXPublic COM automation server (GXPublic.GXPublic) is registered.' };
619
+ }
620
+ const dllHint = gxPath ? path.join(gxPath, 'GxPublic.dll') : 'GxPublic.dll';
621
+ return {
622
+ id: 'gxpublic_com_registration',
623
+ status: 'warn',
624
+ detail: `GXPublic COM component is not registered. Run 'regsvr32 "${dllHint}"' to enable GeneXus ${gxMajor}.0 COM automation.`
625
+ };
626
+ }
627
+
628
+ function buildLegacyIdeLockCheck(gxMajor) {
629
+ if (gxMajor !== '8' && gxMajor !== '9' && gxMajor !== '10.1' && gxMajor !== '10.2') {
630
+ return { id: 'legacy_ide_lock', status: 'not_applicable', detail: 'Lock check not applicable for modern GeneXus versions.' };
631
+ }
632
+ const child_process = require('child_process');
633
+ let ideRunning = false;
634
+ try {
635
+ const res = child_process.spawnSync('tasklist', ['/FI', 'IMAGENAME eq gx.exe', '/NH'], { encoding: 'utf8', timeout: 2000 });
636
+ if (res.stdout && res.stdout.toLowerCase().includes('gx.exe')) {
637
+ ideRunning = true;
638
+ }
639
+ } catch { }
640
+ if (ideRunning) {
641
+ return {
642
+ id: 'legacy_ide_lock',
643
+ status: 'warn',
644
+ detail: 'GeneXus IDE (gx.exe) process is currently running. Legacy engines hold exclusive file locks on the KB.'
645
+ };
646
+ }
647
+ return { id: 'legacy_ide_lock', status: 'pass', detail: 'No conflicting GeneXus IDE process (gx.exe) detected.' };
648
+ }
649
+
565
650
  function redactConfig(cfg) {
566
651
  // Replace absolute paths with `<redacted:hash8>` so the structure is preserved
567
652
  // but filesystem layout, usernames, and KB names are not leaked. Hash is stable
@@ -710,7 +795,7 @@ async function handleDoctor(options, ctx) {
710
795
  const kbPath = data.kbPath;
711
796
  const gxPath = data.gxPath;
712
797
  const kbExists = !!(kbPath && fs.existsSync(kbPath));
713
- const gxExeExists = !!(gxPath && fs.existsSync(path.join(gxPath, 'genexus.exe')));
798
+ const gxExeExists = !!(gxPath && (fs.existsSync(path.join(gxPath, 'genexus.exe')) || fs.existsSync(path.join(gxPath, 'gx.exe'))));
714
799
  const kbSdkCompatibility = kbExists && gxExeExists
715
800
  ? compareGeneXusKbAndInstallation(kbPath, gxPath)
716
801
  : null;
@@ -731,15 +816,18 @@ async function handleDoctor(options, ctx) {
731
816
  compatibilityDetail = `${unsupportedCompatibilityMajor} is not supported by this MCP distribution. Supported majors: ${catalog.supportedMajors.map((entry) => entry.major).join(', ')}.`;
732
817
  } else if (kbSdkCompatibility.status === 'mismatch') {
733
818
  compatibilityStatus = 'fail';
734
- compatibilityDetail = `KB major ${kbSdkCompatibility.kb.major} does not match GeneXus SDK major ${kbSdkCompatibility.gx.major}. Re-run init with the matching --gx path.`;
819
+ compatibilityDetail = `KB major ${kbSdkCompatibility.kb.major} does not match GeneXus SDK major ${kbSdkCompatibility.gx.major}${kbSdkCompatibility.gx.version ? ` (${kbSdkCompatibility.gx.version})` : ''}. Re-run init with the matching --gx path.`;
735
820
  } else if (kbSdkCompatibility.status === 'match') {
736
821
  compatibilityStatus = 'pass';
737
- compatibilityDetail = `KB major ${kbSdkCompatibility.kb.major} matches GeneXus SDK major ${kbSdkCompatibility.gx.major}.`;
822
+ compatibilityDetail = `KB major ${kbSdkCompatibility.kb.major} matches GeneXus SDK major ${kbSdkCompatibility.gx.major}${kbSdkCompatibility.gx.version ? ` (${kbSdkCompatibility.gx.version})` : ''}.`;
738
823
  } else {
739
824
  compatibilityDetail = `KB/SDK major compatibility could not be verified (KB: ${kbSdkCompatibility.kb.source}; SDK: ${kbSdkCompatibility.gx.source}).`;
740
825
  }
741
826
  }
742
827
 
828
+ const gxIdent = gxPath ? readGeneXusInstallationIdentity(gxPath) : null;
829
+ const gxVersionLabel = gxIdent && gxIdent.version ? ` (GeneXus ${gxIdent.major || ''} version ${gxIdent.version})` : (gxIdent && gxIdent.major ? ` (GeneXus ${gxIdent.major})` : '');
830
+
743
831
  const riskyZone = isPathLikelyAppLockerBlocked(gatewayExePath);
744
832
  const clientCrossCheck = buildClientExeCrossCheck(gatewayExePath);
745
833
 
@@ -767,7 +855,7 @@ async function handleDoctor(options, ctx) {
767
855
  // Same logic for the GeneXus install: missing genexus.exe at a configured path
768
856
  // guarantees a worker crash on first MCP call. Promote from warn to fail so init
769
857
  // exits non-zero and the caller (install.ps1, AI client) actually sees the problem.
770
- { id: 'gx_installation', status: gxExeExists ? 'pass' : (gxPath ? 'fail' : 'warn'), detail: gxExeExists ? 'GeneXus installation has genexus.exe.' : (gxPath ? `Configured GeneXus installation is missing genexus.exe at: ${gxPath}` : 'No GeneXus installation path is configured.') },
858
+ { id: 'gx_installation', status: gxExeExists ? 'pass' : (gxPath ? 'fail' : 'warn'), detail: gxExeExists ? `GeneXus installation has ${gxPath && fs.existsSync(path.join(gxPath, 'gx.exe')) && !fs.existsSync(path.join(gxPath, 'genexus.exe')) ? 'gx.exe' : 'genexus.exe'}${gxVersionLabel}.` : (gxPath ? `Configured GeneXus installation is missing executable at: ${gxPath}` : 'No GeneXus installation path is configured.') },
771
859
  {
772
860
  id: 'kb_sdk_compatibility',
773
861
  status: compatibilityStatus,
@@ -784,6 +872,13 @@ async function handleDoctor(options, ctx) {
784
872
  const inProcessLoad = buildInProcessBuildAssemblyLoadCheck(gxPath);
785
873
  checks.push({ id: 'in_process_build_assembly_load', status: inProcessLoad.status, detail: inProcessLoad.detail });
786
874
 
875
+ // Legacy GeneXus checks (GX 8.0, 9.0, Ev1, Ev2)
876
+ const gxMajor = (gxIdent && gxIdent.major) || (kbSdkCompatibility && kbSdkCompatibility.gx && kbSdkCompatibility.gx.major) || null;
877
+ const comCheck = buildGxPublicComCheck(gxMajor, gxPath);
878
+ checks.push({ id: 'gxpublic_com_registration', status: comCheck.status, detail: comCheck.detail });
879
+ const ideLockCheck = buildLegacyIdeLockCheck(gxMajor);
880
+ checks.push({ id: 'legacy_ide_lock', status: ideLockCheck.status, detail: ideLockCheck.detail });
881
+
787
882
  // Client registration summary — one line answering "are my AI agents wired up?".
788
883
  const clientRows = clientsStatus();
789
884
  const installedRows = clientRows.filter((r) => r.installed);
@@ -1344,6 +1439,7 @@ function buildClientLauncherHelp(patchResult) {
1344
1439
  }
1345
1440
  if (patched.length > 0 && process.platform === 'win32' && !process.env.GENEXUS_MCP_GATEWAY_EXE) {
1346
1441
  help.push('Windows launcher paths may resolve under the npm cache and be blocked by AppLocker/SRP. Use scripts/install.ps1 for a stable whitelisted path.');
1442
+ help.push('Working from a local checkout? Register the clients with the checkout gateway from the repo root instead: `$env:GENEXUS_MCP_GATEWAY_EXE="<repoRoot>\\publish\\GxMcp.Gateway.exe"; node cli\\run.js clients add --clients <ids>` (or re-run `.\\install.ps1`). Avoid `npx @latest clients add` there, because it rewrites the registration to this npm package\'s launcher; validate with the same checkout CLI (`node cli\\run.js clients` / `doctor`).');
1347
1443
  }
1348
1444
  return help;
1349
1445
  }
@@ -1939,6 +2035,7 @@ async function probeWorkerStartup({ configPath, observeMs = 2500 }) {
1939
2035
  };
1940
2036
 
1941
2037
  let child;
2038
+ let stopping = false;
1942
2039
  try {
1943
2040
  child = spawn(gatewayExePath, [], {
1944
2041
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -1957,6 +2054,9 @@ async function probeWorkerStartup({ configPath, observeMs = 2500 }) {
1957
2054
  });
1958
2055
 
1959
2056
  child.once('exit', (code) => {
2057
+ // The observation timeout stops the gateway itself; that deliberate
2058
+ // signal is reported by the timeout branch, not as a crash.
2059
+ if (stopping) return;
1960
2060
  if (code === 0) {
1961
2061
  finish({ status: 'pass', detail: 'Worker smoke: gateway exited cleanly during observation window.' });
1962
2062
  } else {
@@ -1970,10 +2070,15 @@ async function probeWorkerStartup({ configPath, observeMs = 2500 }) {
1970
2070
  }
1971
2071
  });
1972
2072
 
1973
- setTimeout(() => {
1974
- try { child.kill(); } catch { }
1975
- // Still alive after observeMs → worker bootstrapped without crashing.
1976
- finish({ status: 'pass', detail: `Worker smoke: gateway stayed alive for ${observeMs}ms with KB and GX configured.` });
2073
+ setTimeout(async () => {
2074
+ stopping = true;
2075
+ const exited = await stopProbeChild(child);
2076
+ if (exited) {
2077
+ // Still alive after observeMs → worker bootstrapped without crashing.
2078
+ finish({ status: 'pass', detail: `Worker smoke: gateway stayed alive for ${observeMs}ms with KB and GX configured.` });
2079
+ } else {
2080
+ finish({ status: 'warn', detail: 'Worker smoke: gateway did not exit after the stop signal; it may still hold the gateway exe.' });
2081
+ }
1977
2082
  }, observeMs);
1978
2083
  });
1979
2084
  }
@@ -2150,11 +2255,12 @@ async function handleClients(subcommand, options, ctx) {
2150
2255
  if (invalidLaunchers.length > 0) {
2151
2256
  help.push(`These clients have a known-invalid MCP launcher (check command and args) — re-register: genexus-mcp clients add --clients ${invalidLaunchers.map((r) => r.id).join(',')}${serverName !== DEFAULT_MCP_SERVER_NAME ? ` --server-name ${serverName}` : ''}`);
2152
2257
  }
2153
- const otherStale = stale.filter((r) =>
2154
- r.launcherStructuralState !== 'missing' && r.launcherSemanticState !== 'invalid'
2155
- );
2156
- if (otherStale.length > 0) {
2157
- help.push(`These clients have a stale launcher — re-register: genexus-mcp clients add --clients ${otherStale.map((r) => r.id).join(',')}${serverName !== DEFAULT_MCP_SERVER_NAME ? ` --server-name ${serverName}` : ''}`);
2258
+ // A launcher that exists but is not this CLI's gateway is a different working
2259
+ // install (checkout, fixed-path, other package cache), so it is reported as
2260
+ // informational drift instead of a stale registration that needs repair.
2261
+ const driftedLaunchers = rows.filter((r) => r.launcherPathDrift && !r.commandStale);
2262
+ if (driftedLaunchers.length > 0) {
2263
+ help.push(`Note: ${driftedLaunchers.map((r) => r.name).join(', ')} point at a gateway that is not this CLI's (informational — the launcher exists and is valid). Re-register with this CLI only if you want them to track it: genexus-mcp clients add --clients ${driftedLaunchers.map((r) => r.id).join(',')}${serverName !== DEFAULT_MCP_SERVER_NAME ? ` --server-name ${serverName}` : ''}`);
2158
2264
  }
2159
2265
  const semanticValidCount = rows.filter((r) => r.launcherSemanticState === 'valid').length;
2160
2266
  const semanticInvalidCount = rows.filter((r) => r.launcherSemanticState === 'invalid').length;
@@ -78,10 +78,16 @@ class OpenCodeJsoncAdapter extends BaseClientAdapter {
78
78
  this.config = configModule || require('./config');
79
79
  }
80
80
  apply(client, launcher, targetConfigPath, opts = {}) {
81
- return this.config.applyOpenCodeJson(client.path, launcher, targetConfigPath, opts);
81
+ return this.config.applyOpenCodeJson(client.path, launcher, targetConfigPath, {
82
+ ...opts,
83
+ alternatePaths: client.alternatePaths || []
84
+ });
82
85
  }
83
86
  remove(client, opts = {}) {
84
- return this.config.removeOpenCodeJson(client.path, opts);
87
+ return this.config.removeOpenCodeJson(client.path, {
88
+ ...opts,
89
+ alternatePaths: client.alternatePaths || []
90
+ });
85
91
  }
86
92
  read(client, serverName) {
87
93
  if (!fs.existsSync(client.path)) return null;
package/cli/lib/config.js CHANGED
@@ -88,9 +88,12 @@ function getGeneXusVersionCatalog() {
88
88
  const catalog = JSON.parse(fs.readFileSync(candidate, 'utf8'));
89
89
  if (!catalog || !catalog.primaryMajor || !Array.isArray(catalog.supportedMajors)) continue;
90
90
  const entries = catalog.supportedMajors.filter((entry) => entry && /^\d+$/.test(String(entry.major)));
91
+ const legacyEntries = Array.isArray(catalog.legacyMajors)
92
+ ? catalog.legacyMajors.filter((entry) => entry && /^(?:10\.[1-3]|\d+)$/.test(String(entry.major)))
93
+ : [];
91
94
  const primary = entries.find((entry) => String(entry.major) === String(catalog.primaryMajor));
92
95
  if (entries.length > 0 && primary?.defaultInstallPath) {
93
- return { ...catalog, supportedMajors: entries };
96
+ return { ...catalog, supportedMajors: entries, legacyMajors: legacyEntries };
94
97
  }
95
98
  } catch {
96
99
  }
@@ -101,13 +104,19 @@ function getGeneXusVersionCatalog() {
101
104
  { major: '17', displayName: 'GeneXus 17', defaultInstallPath: 'C:\\Program Files (x86)\\GeneXus\\GeneXus17Trial' },
102
105
  { major: '18', displayName: 'GeneXus 18', defaultInstallPath: 'C:\\Program Files (x86)\\GeneXus\\GeneXus18' }
103
106
  ],
107
+ legacyMajors: [
108
+ { major: '10.3', displayName: 'GeneXus Evolution 3', defaultInstallPath: 'C:\\Program Files (x86)\\GeneXus\\GeneXusXEv3' },
109
+ { 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' }
111
+ ],
104
112
  source: 'built-in-fallback'
105
113
  };
106
114
  }
107
115
 
108
116
  function getGeneXusCatalogEntries(preferredMajor = null) {
109
117
  const catalog = getGeneXusVersionCatalog();
110
- return [...catalog.supportedMajors].sort((left, right) => {
118
+ const all = [...catalog.supportedMajors, ...(catalog.legacyMajors || [])];
119
+ return all.sort((left, right) => {
111
120
  const leftPreferred = preferredMajor !== null && String(left.major) === String(preferredMajor);
112
121
  const rightPreferred = preferredMajor !== null && String(right.major) === String(preferredMajor);
113
122
  if (leftPreferred !== rightPreferred) return Number(rightPreferred) - Number(leftPreferred);
@@ -708,6 +717,12 @@ function resolveOpenCodeConfigPath(xdgConfig) {
708
717
  return path.join(xdgConfig, 'opencode', 'opencode.json');
709
718
  }
710
719
 
720
+ function alternateOpenCodeConfigPath(filePath) {
721
+ return path.extname(filePath).toLowerCase() === '.jsonc'
722
+ ? filePath.slice(0, -1)
723
+ : filePath.replace(/\.json$/i, '.jsonc');
724
+ }
725
+
711
726
  // VS Code stores its user profile (and native MCP mcp.json) in a per-platform
712
727
  // location. `variant` is 'Code' (stable) or 'Code - Insiders'.
713
728
  function vscodeUserDir(variant, { appData, macAppSupport, xdgConfig }) {
@@ -807,6 +822,7 @@ function getClientConfigTargets() {
807
822
  name: 'OpenCode (CLI)',
808
823
  format: 'opencode',
809
824
  path: resolveOpenCodeConfigPath(xdgConfig),
825
+ alternatePaths: [alternateOpenCodeConfigPath(resolveOpenCodeConfigPath(xdgConfig))],
810
826
  installMarkers: [
811
827
  path.join(xdgConfig, 'opencode'),
812
828
  path.join(home, '.local', 'share', 'opencode')
@@ -826,6 +842,7 @@ function getClientConfigTargets() {
826
842
  name: 'OpenCode Desktop',
827
843
  format: 'opencode',
828
844
  path: resolveOpenCodeConfigPath(xdgConfig),
845
+ alternatePaths: [alternateOpenCodeConfigPath(resolveOpenCodeConfigPath(xdgConfig))],
829
846
  detectByMarkerOnly: true,
830
847
  installMarkers: [
831
848
  path.join(localAppData, 'Programs', '@opencode-aidesktop'),
@@ -937,21 +954,27 @@ function clientCommandHealth(entry, client = null, { fs: fileSystem = fs } = {})
937
954
  reason: null,
938
955
  structuralState: 'indeterminate',
939
956
  semanticState: 'unknown',
940
- semanticReason: null
957
+ semanticReason: null,
958
+ pathDrift: false,
959
+ pathDriftReason: null
941
960
  };
942
961
  const invalid = (reason, structuralState = 'present') => ({
943
962
  stale: true,
944
963
  reason,
945
964
  structuralState,
946
965
  semanticState: 'invalid',
947
- semanticReason: reason
966
+ semanticReason: reason,
967
+ pathDrift: false,
968
+ pathDriftReason: null
948
969
  });
949
970
  const valid = (structuralState = 'present') => ({
950
971
  stale: false,
951
972
  reason: null,
952
973
  structuralState,
953
974
  semanticState: 'valid',
954
- semanticReason: null
975
+ semanticReason: null,
976
+ pathDrift: false,
977
+ pathDriftReason: null
955
978
  });
956
979
  const missingLauncherReason = 'configured launcher does not exist on disk';
957
980
 
@@ -961,7 +984,9 @@ function clientCommandHealth(entry, client = null, { fs: fileSystem = fs } = {})
961
984
  reason: null,
962
985
  structuralState: 'not-registered',
963
986
  semanticState: 'not-applicable',
964
- semanticReason: null
987
+ semanticReason: null,
988
+ pathDrift: false,
989
+ pathDriftReason: null
965
990
  };
966
991
  }
967
992
  if (typeof entry !== 'object') {
@@ -973,7 +998,9 @@ function clientCommandHealth(entry, client = null, { fs: fileSystem = fs } = {})
973
998
  reason: null,
974
999
  structuralState: 'not-applicable',
975
1000
  semanticState: 'not-applicable',
976
- semanticReason: null
1001
+ semanticReason: null,
1002
+ pathDrift: false,
1003
+ pathDriftReason: null
977
1004
  };
978
1005
  }
979
1006
 
@@ -1004,13 +1031,21 @@ function clientCommandHealth(entry, client = null, { fs: fileSystem = fs } = {})
1004
1031
  };
1005
1032
  }
1006
1033
  if (!exists) return invalid(missingLauncherReason, 'missing');
1034
+ // A gateway exe that exists at a different absolute path is a *different,
1035
+ // working* install — a local checkout's publish/ copy, a fixed-path install,
1036
+ // or another package cache — not a broken registration. Reporting it as
1037
+ // `stale` made `npx genexus-mcp clients` tell a checkout operator to
1038
+ // re-register, which rewrote the harness away from the checkout gateway
1039
+ // (Issue #210). The drift is still surfaced (as `pathDrift`, never `stale`)
1040
+ // and doctor's `client_config_sync` check keeps the "not this CLI's packaged
1041
+ // gateway" warning with its own remediation.
1007
1042
  if (client && client.preferDirectGateway) {
1008
1043
  const currentPackageGateway = getGatewayExePath();
1009
- if (fs.existsSync(currentPackageGateway) && normalizeExePath(command) !== normalizeExePath(currentPackageGateway)) {
1044
+ if (fileSystem.existsSync(currentPackageGateway) && normalizeExePath(command) !== normalizeExePath(currentPackageGateway)) {
1010
1045
  return {
1011
1046
  ...valid('present'),
1012
- stale: true,
1013
- reason: 'configured launcher points at a different package gateway; re-register the client'
1047
+ pathDrift: true,
1048
+ pathDriftReason: `configured gateway differs from this CLI's gateway (${currentPackageGateway})`
1014
1049
  };
1015
1050
  }
1016
1051
  }
@@ -1131,13 +1166,16 @@ function clientsStatus(opts = {}) {
1131
1166
  serverName,
1132
1167
  isThirdParty: Boolean(isThirdParty),
1133
1168
  writeSupported: client.writeSupported !== false,
1134
- configPath: client.path,
1169
+ configPath: entry && entry.configPath ? entry.configPath : client.path,
1170
+ configPaths: entry && entry.configPath ? [entry.configPath] : [client.path],
1135
1171
  command: entry && entry.command ? entry.command : null,
1136
1172
  args: entry && Array.isArray(entry.args) ? entry.args : [],
1137
1173
  url: entry && entry.url ? entry.url : null,
1138
1174
  launcherStructuralState: health.structuralState,
1139
1175
  launcherSemanticState: health.semanticState,
1140
1176
  launcherSemanticReason: health.semanticReason,
1177
+ launcherPathDrift: health.pathDrift,
1178
+ launcherPathDriftReason: health.pathDriftReason,
1141
1179
  commandStale: health.stale,
1142
1180
  commandStaleReason: health.reason || (isThirdParty ? 'configured as third-party / HTTP MCP server (e.g. official GeneXus MCP)' : null),
1143
1181
  detectedAt: det.markerHit || (det.hasConfig ? client.path : null),
@@ -1426,7 +1464,7 @@ function getOpenCodeMcpContainer(cfgObj) {
1426
1464
  };
1427
1465
  }
1428
1466
 
1429
- function applyOpenCodeJson(filePath, launcher, targetConfigPath, { serverName = DEFAULT_MCP_SERVER_NAME, force = false, globalConfig = false, fs: fileSystem = fs } = {}) {
1467
+ function applyOpenCodeJson(filePath, launcher, targetConfigPath, { serverName = DEFAULT_MCP_SERVER_NAME, force = false, globalConfig = false, alternatePaths = [], fs: fileSystem = fs } = {}) {
1430
1468
  const parsed = fileSystem.existsSync(filePath) ? readJsonFileSafe(filePath, fileSystem) : {};
1431
1469
  if (parsed === null) throw new Error('Invalid JSON');
1432
1470
  const cfgObj = parsed || {};
@@ -1470,10 +1508,20 @@ function applyOpenCodeJson(filePath, launcher, targetConfigPath, { serverName =
1470
1508
  }
1471
1509
  }
1472
1510
  writeClientJson(filePath, cfgObj, fileSystem);
1511
+ for (const alternatePath of alternatePaths || []) {
1512
+ if (!fileSystem.existsSync(alternatePath)) continue;
1513
+ applyOpenCodeJson(alternatePath, launcher, targetConfigPath, {
1514
+ serverName,
1515
+ force,
1516
+ globalConfig,
1517
+ alternatePaths: [],
1518
+ fs: fileSystem
1519
+ });
1520
+ }
1473
1521
  }
1474
1522
 
1475
- function removeOpenCodeJson(filePath, { serverName = DEFAULT_MCP_SERVER_NAME, fs: fileSystem = fs } = {}) {
1476
- const parsed = readJsonFileSafe(filePath);
1523
+ function removeOpenCodeJson(filePath, { serverName = DEFAULT_MCP_SERVER_NAME, alternatePaths = [], fs: fileSystem = fs } = {}) {
1524
+ const parsed = readJsonFileSafe(filePath, fileSystem);
1477
1525
  if (parsed === null) throw new Error('Invalid JSON');
1478
1526
  const cfgObj = parsed || {};
1479
1527
  if (!cfgObj.mcp || typeof cfgObj.mcp !== 'object') return false;
@@ -1503,9 +1551,17 @@ function removeOpenCodeJson(filePath, { serverName = DEFAULT_MCP_SERVER_NAME, fs
1503
1551
  }
1504
1552
  }
1505
1553
  }
1506
- if (!removedAny) return false;
1507
- writeClientJson(filePath, cfgObj, fileSystem);
1508
- return true;
1554
+ if (removedAny) writeClientJson(filePath, cfgObj, fileSystem);
1555
+ let removedAlternate = false;
1556
+ for (const alternatePath of alternatePaths || []) {
1557
+ if (!fileSystem.existsSync(alternatePath)) continue;
1558
+ removedAlternate = removeOpenCodeJson(alternatePath, {
1559
+ serverName,
1560
+ alternatePaths: [],
1561
+ fs: fileSystem
1562
+ }) || removedAlternate;
1563
+ }
1564
+ return removedAny || removedAlternate;
1509
1565
  }
1510
1566
 
1511
1567
  function extractCodexTomlEntry(content, serverName = DEFAULT_MCP_SERVER_NAME) {
@@ -1650,8 +1706,49 @@ function normalizeExePath(p) {
1650
1706
  return s;
1651
1707
  }
1652
1708
 
1709
+ function readOpenCodeCommandEntry(filePath, serverName, fileSystem) {
1710
+ if (!fileSystem.existsSync(filePath)) return null;
1711
+ const parsed = readJsonFileSafe(filePath, fileSystem);
1712
+ if (!parsed || typeof parsed !== 'object') return null;
1713
+ let entry = parsed.mcp?.servers?.[serverName] || parsed.mcp?.[serverName];
1714
+ if (!entry && serverName === DEFAULT_MCP_SERVER_NAME) {
1715
+ const legacyGx = parsed.mcp?.servers?.genexus || parsed.mcp?.genexus;
1716
+ if (legacyGx && !isThirdPartyMcpEntry(legacyGx)) entry = legacyGx;
1717
+ else entry = parsed.mcp?.servers?.genexus18 || parsed.mcp?.genexus18;
1718
+ }
1719
+ if (!entry) return null;
1720
+ if (Array.isArray(entry.command) && entry.command.length > 0) {
1721
+ return {
1722
+ command: entry.command[0],
1723
+ args: entry.command.slice(1),
1724
+ url: entry.url || null,
1725
+ type: entry.type || null,
1726
+ environment: entry.environment || null,
1727
+ raw: entry,
1728
+ configPath: filePath
1729
+ };
1730
+ }
1731
+ return {
1732
+ command: null,
1733
+ args: [],
1734
+ url: entry.url || null,
1735
+ type: entry.type || null,
1736
+ environment: entry.environment || null,
1737
+ raw: entry,
1738
+ configPath: filePath
1739
+ };
1740
+ }
1741
+
1653
1742
  function readClientCommandEntry(client, serverName = DEFAULT_MCP_SERVER_NAME, { fs: fileSystem = fs } = {}) {
1654
1743
  if (client.writeSupported === false) return null;
1744
+ if (client.format === 'opencode') {
1745
+ const paths = [client.path, ...(client.alternatePaths || [])];
1746
+ for (const configPath of paths) {
1747
+ const entry = readOpenCodeCommandEntry(configPath, serverName, fileSystem);
1748
+ if (entry) return entry;
1749
+ }
1750
+ return null;
1751
+ }
1655
1752
  if (!fileSystem.existsSync(client.path)) return null;
1656
1753
  try {
1657
1754
  if (client.format === 'mcpServers') {
@@ -1752,7 +1849,7 @@ function getLocalAppDataCacheDir() {
1752
1849
  }
1753
1850
 
1754
1851
  function getGeneXusMajor(version) {
1755
- const match = String(version || '').match(/^\s*(\d+)/);
1852
+ const match = String(version || '').match(/^\s*(10\.[1-3](?!\d)|\d+)/);
1756
1853
  return match ? match[1] : null;
1757
1854
  }
1758
1855
 
@@ -1809,7 +1906,10 @@ function readGeneXusInstallationIdentity(gxPath, options = {}) {
1809
1906
  const readExecutableVersion = typeof options.readExecutableVersion === 'function'
1810
1907
  ? options.readExecutableVersion
1811
1908
  : readExecutableProductVersion;
1812
- const executableVersion = readExecutableVersion(path.join(gxPath, 'GeneXus.exe'));
1909
+ let executableVersion = readExecutableVersion(path.join(gxPath, 'GeneXus.exe'));
1910
+ if (!executableVersion) {
1911
+ executableVersion = readExecutableVersion(path.join(gxPath, 'gx.exe'));
1912
+ }
1813
1913
  if (executableVersion) {
1814
1914
  return {
1815
1915
  version: executableVersion,
@@ -1895,14 +1995,24 @@ function readGeneXusKbIdentity(kbPath) {
1895
1995
  if (!kbPath) return { version: null, major: null, source: 'unavailable', reason: 'missing-kb-path' };
1896
1996
 
1897
1997
  let gxwFiles;
1998
+ let gxiFiles = [];
1898
1999
  try {
1899
- gxwFiles = fs.readdirSync(kbPath)
2000
+ const allFiles = fs.readdirSync(kbPath);
2001
+ gxwFiles = allFiles
1900
2002
  .filter((fileName) => fileName.toLowerCase().endsWith('.gxw'))
1901
2003
  .map((fileName) => path.join(kbPath, fileName));
2004
+ gxiFiles = allFiles
2005
+ .filter((fileName) => fileName.toLowerCase().endsWith('.gxi'))
2006
+ .map((fileName) => path.join(kbPath, fileName));
1902
2007
  } catch {
1903
2008
  return { version: null, major: null, source: 'unavailable', reason: 'unreadable-kb-path' };
1904
2009
  }
1905
- if (gxwFiles.length === 0) return { version: null, major: null, source: 'unavailable', reason: 'no-gxw' };
2010
+ if (gxwFiles.length === 0) {
2011
+ if (gxiFiles.length > 0) {
2012
+ return { version: '9.0', major: '9', source: 'gxi-classic', reason: null };
2013
+ }
2014
+ return { version: null, major: null, source: 'unavailable', reason: 'no-gxw' };
2015
+ }
1906
2016
  if (gxwFiles.length > 1) return { version: null, major: null, source: 'unavailable', reason: 'multiple-gxw' };
1907
2017
 
1908
2018
  try {