genexus-mcp 3.2.0 → 3.2.2

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/cli/lib/config.js CHANGED
@@ -22,6 +22,28 @@ function generateConfig(gxPath, kbPath) {
22
22
  };
23
23
  }
24
24
 
25
+ function generateNeutralConfig(gxPath, { workerPath, gatewayMode = 'stdio-isolated', resolutionPolicy = 'strict' } = {}) {
26
+ return {
27
+ ConfigSchemaVersion: 2,
28
+ GatewayMode: gatewayMode,
29
+ GeneXus: {
30
+ InstallationPath: gxPath,
31
+ WorkerExecutable: workerPath || path.join(path.dirname(getGatewayExePath()), 'worker', 'GxMcp.Worker.exe')
32
+ },
33
+ Server: {
34
+ HttpPort: 0,
35
+ McpStdio: true,
36
+ SessionIdleTimeoutMinutes: 10,
37
+ WorkerIdleTimeoutMinutes: 5,
38
+ EmitStructuredContent: false,
39
+ TerseResponses: true
40
+ },
41
+ Environment: {
42
+ ResolutionPolicy: resolutionPolicy
43
+ }
44
+ };
45
+ }
46
+
25
47
  function getGatewayExePath() {
26
48
  if (process.env.GENEXUS_MCP_GATEWAY_EXE) {
27
49
  return process.env.GENEXUS_MCP_GATEWAY_EXE;
@@ -381,9 +403,9 @@ function stripJsonComments(text) {
381
403
  return out;
382
404
  }
383
405
 
384
- function readJsonFileSafe(filePath) {
406
+ function readJsonFileSafe(filePath, fileSystem = fs) {
385
407
  try {
386
- const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
408
+ const raw = fileSystem.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
387
409
  if (!raw.trim()) return {};
388
410
  try {
389
411
  return JSON.parse(raw);
@@ -406,69 +428,151 @@ function readJsonFileSafe(filePath) {
406
428
 
407
429
  // Atomic write: stage to a temp file then rename over the target, so a crash
408
430
  // mid-write can never leave a client's config truncated.
409
- function writeFileAtomic(filePath, content) {
431
+ function writeFileAtomic(filePath, content, fileSystem = fs) {
410
432
  const tmp = `${filePath}.tmp-${process.pid}`;
411
- fs.writeFileSync(tmp, content);
433
+ fileSystem.writeFileSync(tmp, content);
412
434
  try {
413
- fs.renameSync(tmp, filePath);
435
+ fileSystem.renameSync(tmp, filePath);
414
436
  } catch (err) {
415
- try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
437
+ try { fileSystem.rmSync(tmp, { force: true }); } catch { /* ignore */ }
416
438
  throw err;
417
439
  }
418
440
  }
419
441
 
420
442
  // Back up a client config once per process run before the first mutation, so the
421
443
  // user has a restore point (the old build-from-source install.ps1 did this; the
422
- // CLI now owns it). Best-effort \u2014 a failed backup never blocks the write.
444
+ // CLI now owns it). A failed backup blocks the mutation so the result remains
445
+ // recoverable.
423
446
  // After writing a new backup, prune old .bak files for the same config so at
424
447
  // most BAK_KEEP_COUNT backups exist (oldest removed first).
425
448
  const BAK_KEEP_COUNT = 5;
426
449
  const _backedUpThisRun = new Set();
427
- function backupClientConfigOnce(filePath) {
428
- if (!fs.existsSync(filePath)) return null;
450
+ function backupClientConfigOnce(filePath, fileSystem = fs) {
451
+ if (!fileSystem.existsSync(filePath)) return null;
429
452
  // Case-fold the dedupe key only on Windows; lowercasing on a case-sensitive
430
453
  // filesystem could merge two genuinely distinct paths.
431
454
  const resolved = path.resolve(filePath);
432
455
  const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
433
456
  if (_backedUpThisRun.has(key)) return null;
434
- try {
435
- const d = new Date();
457
+ const d = new Date();
436
458
  const stamp = d.toISOString().replace(/[-:T]/g, '').slice(0, 14);
437
- const bak = `${filePath}.${stamp}.bak`;
438
- fs.copyFileSync(filePath, bak);
459
+ let bak = `${filePath}.${stamp}.bak`;
460
+ let suffix = 1;
461
+ while (fileSystem.existsSync(bak)) bak = `${filePath}.${stamp}-${suffix++}.bak`;
462
+ fileSystem.copyFileSync(filePath, bak);
439
463
  _backedUpThisRun.add(key);
440
464
  // Prune: keep only the BAK_KEEP_COUNT most-recent .bak files for this config.
441
465
  try {
442
466
  const dir = path.dirname(resolved);
443
467
  const base = path.basename(resolved);
444
468
  const bakPattern = new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.\\d{14}\\.bak$`);
445
- const existing = fs.readdirSync(dir)
469
+ const existing = fileSystem.readdirSync(dir)
446
470
  .filter(f => bakPattern.test(f))
447
471
  .map(f => path.join(dir, f))
448
472
  .sort(); // ISO timestamp stamps sort lexicographically = chronologically
449
473
  if (existing.length > BAK_KEEP_COUNT) {
450
474
  const toRemove = existing.slice(0, existing.length - BAK_KEEP_COUNT);
451
475
  for (const old of toRemove) {
452
- try { fs.rmSync(old, { force: true }); } catch { /* best-effort */ }
476
+ try { fileSystem.rmSync(old, { force: true }); } catch { /* best-effort */ }
453
477
  }
454
478
  }
455
479
  } catch { /* pruning is best-effort; never block the backup */ }
456
- return bak;
457
- } catch {
458
- return null;
459
- }
480
+ return bak;
460
481
  }
461
482
 
462
483
  // Write JSON to a client config: back up, serialize, write atomically.
463
- function writeClientJson(filePath, obj) {
464
- backupClientConfigOnce(filePath);
465
- writeFileAtomic(filePath, JSON.stringify(obj, null, 2));
484
+ function writeClientJson(filePath, obj, fileSystem = fs) {
485
+ backupClientConfigOnce(filePath, fileSystem);
486
+ writeFileAtomic(filePath, JSON.stringify(obj, null, 2), fileSystem);
466
487
  }
467
488
 
468
489
  // Write raw text to a client config (e.g. Codex TOML): back up + write atomically.
469
- function writeClientText(filePath, content) {
470
- backupClientConfigOnce(filePath);
471
- writeFileAtomic(filePath, content);
490
+ function writeClientText(filePath, content, fileSystem = fs) {
491
+ backupClientConfigOnce(filePath, fileSystem);
492
+ writeFileAtomic(filePath, content, fileSystem);
493
+ }
494
+
495
+ function copyFileAtomic(sourcePath, targetPath, fileSystem = fs) {
496
+ const tmp = `${targetPath}.tmp-${process.pid}`;
497
+ fileSystem.copyFileSync(sourcePath, tmp);
498
+ try {
499
+ fileSystem.renameSync(tmp, targetPath);
500
+ } catch (err) {
501
+ try { fileSystem.rmSync(tmp, { force: true }); } catch { /* ignore */ }
502
+ throw err;
503
+ }
504
+ }
505
+
506
+ function migrateLegacyConfig(sourcePath, targetPath, { rejectNonMigratable = false, fileSystem = fs } = {}) {
507
+ const source = path.resolve(sourcePath);
508
+ const target = path.resolve(targetPath);
509
+ const legacy = readJsonFileSafe(source, fileSystem);
510
+ if (!legacy || typeof legacy !== 'object') {
511
+ const err = new Error(`Legacy config is missing or invalid: ${source}`);
512
+ err.code = 'INVALID_CONFIG';
513
+ throw err;
514
+ }
515
+
516
+ const env = legacy.Environment && typeof legacy.Environment === 'object' ? legacy.Environment : {};
517
+ const notMigrated = ['KBPath', 'KBs', 'DefaultKb', 'ActiveKb']
518
+ .filter((key) => Object.prototype.hasOwnProperty.call(env, key))
519
+ .map((key) => `Environment.${key}`);
520
+ if (rejectNonMigratable && notMigrated.length > 0) {
521
+ const err = new Error(`Migration rejected non-migratable fields: ${notMigrated.join(', ')}.`);
522
+ err.code = 'NON_MIGRATABLE_FIELDS';
523
+ err.notMigrated = notMigrated;
524
+ throw err;
525
+ }
526
+
527
+ const migrated = {
528
+ ConfigSchemaVersion: 2,
529
+ GatewayMode: legacy.GatewayMode || 'stdio-isolated',
530
+ GeneXus: { ...(legacy.GeneXus && typeof legacy.GeneXus === 'object' ? legacy.GeneXus : {}) },
531
+ Server: { ...(legacy.Server && typeof legacy.Server === 'object' ? legacy.Server : {}) },
532
+ Environment: {
533
+ ResolutionPolicy: env.ResolutionPolicy || 'strict'
534
+ }
535
+ };
536
+ const backupPath = (() => {
537
+ let candidate = `${source}.pre-migrate.bak`;
538
+ let suffix = 1;
539
+ while (fileSystem.existsSync(candidate)) candidate = `${source}.pre-migrate-${suffix++}.bak`;
540
+ return candidate;
541
+ })();
542
+ const targetExisted = fileSystem.existsSync(target);
543
+ const targetBackup = targetExisted ? `${target}.rollback-${process.pid}-${Date.now()}.bak` : null;
544
+ let wroteTarget = false;
545
+ try {
546
+ copyFileAtomic(source, backupPath, fileSystem);
547
+ if (targetExisted) copyFileAtomic(target, targetBackup, fileSystem);
548
+ fileSystem.mkdirSync(path.dirname(target), { recursive: true });
549
+ writeFileAtomic(target, JSON.stringify(migrated, null, 2), fileSystem);
550
+ wroteTarget = true;
551
+ const readBack = process.env.GENEXUS_MCP_MIGRATE_FAIL_READBACK
552
+ ? null
553
+ : readJsonFileSafe(target, fileSystem);
554
+ if (!readBack || readBack.ConfigSchemaVersion !== 2 || JSON.stringify(readBack) !== JSON.stringify(migrated)) {
555
+ throw new Error('Migration read-back verification failed.');
556
+ }
557
+ if (targetBackup) {
558
+ try { fileSystem.rmSync(targetBackup, { force: true }); } catch { /* best effort */ }
559
+ }
560
+ return { sourcePath: source, targetPath: target, backupPath, readBack: true, migrated: Object.keys(migrated), notMigrated, rolledBack: false };
561
+ } catch (err) {
562
+ let rolledBack = false;
563
+ try {
564
+ if (targetExisted && targetBackup) copyFileAtomic(targetBackup, target, fileSystem);
565
+ else if (wroteTarget) fileSystem.rmSync(target, { force: true });
566
+ rolledBack = true;
567
+ } catch { /* report rollback failure */ }
568
+ if (targetBackup) {
569
+ try { fileSystem.rmSync(targetBackup, { force: true }); } catch { /* best effort */ }
570
+ }
571
+ err.rollback = { rolledBack };
572
+ err.backupPath = backupPath;
573
+ err.notMigrated = notMigrated;
574
+ throw err;
575
+ }
472
576
  }
473
577
 
474
578
  function resolveConfigPathNoMutate(cwd) {
@@ -869,6 +973,7 @@ function patchClientConfig(targetConfigPath, opts = {}) {
869
973
  // user only finds out when each one fails with "Failed to connect".
870
974
  const serverName = opts.serverName || DEFAULT_MCP_SERVER_NAME;
871
975
  const force = Boolean(opts.force);
976
+ const fileSystem = opts.fs || fs;
872
977
  const onlyExisting = opts.onlyExisting !== false;
873
978
  const candidates = filterClientTargets(getClientConfigTargets(), {
874
979
  ids: opts.ids,
@@ -923,11 +1028,16 @@ function patchClientConfig(targetConfigPath, opts = {}) {
923
1028
  continue;
924
1029
  }
925
1030
  try {
926
- fs.mkdirSync(path.dirname(client.path), { recursive: true });
927
- applyClientEntry(client, getLauncher(client), targetConfigPath, { serverName, force });
1031
+ fileSystem.mkdirSync(path.dirname(client.path), { recursive: true });
1032
+ applyClientEntry(client, getLauncher(client), targetConfigPath, {
1033
+ serverName,
1034
+ force,
1035
+ globalConfig: Boolean(opts.globalConfig),
1036
+ fs: fileSystem
1037
+ });
928
1038
  // Read-back: confirm the entry is actually present and the file still
929
1039
  // parses, so a silently-corrupted write is reported as a failure.
930
- if (!readClientCommandEntry(client, serverName)) {
1040
+ if (!readClientCommandEntry(client, serverName, { fs: fileSystem })) {
931
1041
  throw new Error(`post-write verification failed (${serverName} entry not found after write)`);
932
1042
  }
933
1043
  patched.push(client.name);
@@ -978,8 +1088,8 @@ function removeClientEntry(client, opts = {}) {
978
1088
  return getClientAdapter(client.format).remove(client, opts);
979
1089
  }
980
1090
 
981
- function applyMcpServersJson(filePath, launcher, targetConfigPath, { serverName = DEFAULT_MCP_SERVER_NAME, force = false } = {}) {
982
- const parsed = fs.existsSync(filePath) ? readJsonFileSafe(filePath) : {};
1091
+ function applyMcpServersJson(filePath, launcher, targetConfigPath, { serverName = DEFAULT_MCP_SERVER_NAME, force = false, globalConfig = false, fs: fileSystem = fs } = {}) {
1092
+ const parsed = fileSystem.existsSync(filePath) ? readJsonFileSafe(filePath, fileSystem) : {};
983
1093
  if (parsed === null) throw new Error('Invalid JSON');
984
1094
  const cfgObj = parsed || {};
985
1095
  cfgObj.mcpServers = cfgObj.mcpServers || {};
@@ -989,7 +1099,11 @@ function applyMcpServersJson(filePath, launcher, targetConfigPath, { serverName
989
1099
  err.code = 'MCP_SERVER_COLLISION';
990
1100
  throw err;
991
1101
  }
992
- cfgObj.mcpServers[serverName] = { ...launcher, env: { GX_CONFIG_PATH: targetConfigPath } };
1102
+ const serverEntry = { ...launcher };
1103
+ if (globalConfig && targetConfigPath) {
1104
+ serverEntry.env = { ...(serverEntry.env || {}), GX_CONFIG_PATH: targetConfigPath };
1105
+ }
1106
+ cfgObj.mcpServers[serverName] = serverEntry;
993
1107
  // If registering default genexus18mcp, clean up legacy genexus/genexus18 entries only if they are not foreign HTTP servers
994
1108
  if (serverName === DEFAULT_MCP_SERVER_NAME) {
995
1109
  if (cfgObj.mcpServers.genexus && !isThirdPartyMcpEntry(cfgObj.mcpServers.genexus)) {
@@ -1003,10 +1117,10 @@ function applyMcpServersJson(filePath, launcher, targetConfigPath, { serverName
1003
1117
  delete cfgObj.mcpServers.genexus18;
1004
1118
  }
1005
1119
  }
1006
- writeClientJson(filePath, cfgObj);
1120
+ writeClientJson(filePath, cfgObj, fileSystem);
1007
1121
  }
1008
1122
 
1009
- function removeMcpServersJson(filePath, { serverName = DEFAULT_MCP_SERVER_NAME } = {}) {
1123
+ function removeMcpServersJson(filePath, { serverName = DEFAULT_MCP_SERVER_NAME, fs: fileSystem = fs } = {}) {
1010
1124
  const parsed = readJsonFileSafe(filePath);
1011
1125
  if (parsed === null) throw new Error('Invalid JSON');
1012
1126
  const cfgObj = parsed || {};
@@ -1026,14 +1140,14 @@ function removeMcpServersJson(filePath, { serverName = DEFAULT_MCP_SERVER_NAME }
1026
1140
  }
1027
1141
  }
1028
1142
  if (!removedAny) return false;
1029
- writeClientJson(filePath, cfgObj);
1143
+ writeClientJson(filePath, cfgObj, fileSystem);
1030
1144
  return true;
1031
1145
  }
1032
1146
 
1033
1147
  // VS Code native MCP lives in User\mcp.json and uses a top-level `servers` map
1034
1148
  // with `type: "stdio"` (distinct from the `mcpServers` shape Claude/Cursor use).
1035
- function applyVsCodeServersJson(filePath, launcher, targetConfigPath, { serverName = DEFAULT_MCP_SERVER_NAME, force = false } = {}) {
1036
- const parsed = fs.existsSync(filePath) ? readJsonFileSafe(filePath) : {};
1149
+ function applyVsCodeServersJson(filePath, launcher, targetConfigPath, { serverName = DEFAULT_MCP_SERVER_NAME, force = false, globalConfig = false, fs: fileSystem = fs } = {}) {
1150
+ const parsed = fileSystem.existsSync(filePath) ? readJsonFileSafe(filePath, fileSystem) : {};
1037
1151
  if (parsed === null) throw new Error('Invalid JSON');
1038
1152
  const cfgObj = parsed || {};
1039
1153
  cfgObj.servers = cfgObj.servers || {};
@@ -1043,11 +1157,14 @@ function applyVsCodeServersJson(filePath, launcher, targetConfigPath, { serverNa
1043
1157
  err.code = 'MCP_SERVER_COLLISION';
1044
1158
  throw err;
1045
1159
  }
1046
- cfgObj.servers[serverName] = {
1160
+ const serverEntry = {
1047
1161
  type: 'stdio',
1048
- ...launcher,
1049
- env: { GX_CONFIG_PATH: targetConfigPath }
1162
+ ...launcher
1050
1163
  };
1164
+ if (globalConfig && targetConfigPath) {
1165
+ serverEntry.env = { ...(serverEntry.env || {}), GX_CONFIG_PATH: targetConfigPath };
1166
+ }
1167
+ cfgObj.servers[serverName] = serverEntry;
1051
1168
  if (serverName === DEFAULT_MCP_SERVER_NAME) {
1052
1169
  if (cfgObj.servers.genexus && !isThirdPartyMcpEntry(cfgObj.servers.genexus)) {
1053
1170
  delete cfgObj.servers.genexus;
@@ -1058,10 +1175,10 @@ function applyVsCodeServersJson(filePath, launcher, targetConfigPath, { serverNa
1058
1175
  } else if (serverName === 'genexus' && cfgObj.servers.genexus18 && !isThirdPartyMcpEntry(cfgObj.servers.genexus18)) {
1059
1176
  delete cfgObj.servers.genexus18;
1060
1177
  }
1061
- writeClientJson(filePath, cfgObj);
1178
+ writeClientJson(filePath, cfgObj, fileSystem);
1062
1179
  }
1063
1180
 
1064
- function removeVsCodeServersJson(filePath, { serverName = DEFAULT_MCP_SERVER_NAME } = {}) {
1181
+ function removeVsCodeServersJson(filePath, { serverName = DEFAULT_MCP_SERVER_NAME, fs: fileSystem = fs } = {}) {
1065
1182
  const parsed = readJsonFileSafe(filePath);
1066
1183
  if (parsed === null) throw new Error('Invalid JSON');
1067
1184
  const cfgObj = parsed || {};
@@ -1081,7 +1198,7 @@ function removeVsCodeServersJson(filePath, { serverName = DEFAULT_MCP_SERVER_NAM
1081
1198
  }
1082
1199
  }
1083
1200
  if (!removedAny) return false;
1084
- writeClientJson(filePath, cfgObj);
1201
+ writeClientJson(filePath, cfgObj, fileSystem);
1085
1202
  return true;
1086
1203
  }
1087
1204
 
@@ -1102,8 +1219,8 @@ function getOpenCodeMcpContainer(cfgObj) {
1102
1219
  };
1103
1220
  }
1104
1221
 
1105
- function applyOpenCodeJson(filePath, launcher, targetConfigPath, { serverName = DEFAULT_MCP_SERVER_NAME, force = false } = {}) {
1106
- const parsed = fs.existsSync(filePath) ? readJsonFileSafe(filePath) : {};
1222
+ function applyOpenCodeJson(filePath, launcher, targetConfigPath, { serverName = DEFAULT_MCP_SERVER_NAME, force = false, globalConfig = false, fs: fileSystem = fs } = {}) {
1223
+ const parsed = fileSystem.existsSync(filePath) ? readJsonFileSafe(filePath, fileSystem) : {};
1107
1224
  if (parsed === null) throw new Error('Invalid JSON');
1108
1225
  const cfgObj = parsed || {};
1109
1226
  // OpenCode configs carry a top-level $schema for editor validation; set it when
@@ -1116,12 +1233,15 @@ function applyOpenCodeJson(filePath, launcher, targetConfigPath, { serverName =
1116
1233
  err.code = 'MCP_SERVER_COLLISION';
1117
1234
  throw err;
1118
1235
  }
1119
- servers[serverName] = {
1236
+ const serverEntry = {
1120
1237
  type: 'local',
1121
1238
  command: [launcher.command, ...(launcher.args || [])],
1122
- environment: { GX_CONFIG_PATH: targetConfigPath },
1123
1239
  ...(nested ? { disabled: false } : { enabled: true })
1124
1240
  };
1241
+ if (globalConfig && targetConfigPath) {
1242
+ serverEntry.environment = { GX_CONFIG_PATH: targetConfigPath };
1243
+ }
1244
+ servers[serverName] = serverEntry;
1125
1245
  if (serverName === DEFAULT_MCP_SERVER_NAME) {
1126
1246
  if (servers.genexus && !isThirdPartyMcpEntry(servers.genexus)) delete servers.genexus;
1127
1247
  if (servers.genexus18 && !isThirdPartyMcpEntry(servers.genexus18)) delete servers.genexus18;
@@ -1142,10 +1262,10 @@ function applyOpenCodeJson(filePath, launcher, targetConfigPath, { serverName =
1142
1262
  if (mcp[serverName] && !isThirdPartyMcpEntry(mcp[serverName])) delete mcp[serverName];
1143
1263
  }
1144
1264
  }
1145
- writeClientJson(filePath, cfgObj);
1265
+ writeClientJson(filePath, cfgObj, fileSystem);
1146
1266
  }
1147
1267
 
1148
- function removeOpenCodeJson(filePath, { serverName = DEFAULT_MCP_SERVER_NAME } = {}) {
1268
+ function removeOpenCodeJson(filePath, { serverName = DEFAULT_MCP_SERVER_NAME, fs: fileSystem = fs } = {}) {
1149
1269
  const parsed = readJsonFileSafe(filePath);
1150
1270
  if (parsed === null) throw new Error('Invalid JSON');
1151
1271
  const cfgObj = parsed || {};
@@ -1177,7 +1297,7 @@ function removeOpenCodeJson(filePath, { serverName = DEFAULT_MCP_SERVER_NAME } =
1177
1297
  }
1178
1298
  }
1179
1299
  if (!removedAny) return false;
1180
- writeClientJson(filePath, cfgObj);
1300
+ writeClientJson(filePath, cfgObj, fileSystem);
1181
1301
  return true;
1182
1302
  }
1183
1303
 
@@ -1231,7 +1351,7 @@ function stripCodexServerBlocks(content, serverName) {
1231
1351
  // [mcp_servers.genexus*] blocks and append fresh ones. Brittle on hand-edited
1232
1352
  // files that put other keys after our blocks without a blank line, but
1233
1353
  // adequate for the typical machine-managed config.
1234
- function applyCodexToml(filePath, launcher, targetConfigPath, { serverName = DEFAULT_MCP_SERVER_NAME, force = false } = {}) {
1354
+ function applyCodexToml(filePath, launcher, targetConfigPath, { serverName = DEFAULT_MCP_SERVER_NAME, force = false, globalConfig = false } = {}) {
1235
1355
  let existing = '';
1236
1356
  if (fs.existsSync(filePath)) existing = fs.readFileSync(filePath, 'utf8');
1237
1357
  const existingEntry = extractCodexTomlEntry(existing, serverName);
@@ -1263,9 +1383,11 @@ function applyCodexToml(filePath, launcher, targetConfigPath, { serverName = DEF
1263
1383
  lines.push(`[mcp_servers.${serverName}]`);
1264
1384
  lines.push(`command = ${tomlString(launcher.command)}`);
1265
1385
  lines.push(`args = [${args.map(tomlString).join(', ')}]`);
1266
- lines.push('');
1267
- lines.push(`[mcp_servers.${serverName}.env]`);
1268
- lines.push(`GX_CONFIG_PATH = ${tomlString(targetConfigPath)}`);
1386
+ if (globalConfig && targetConfigPath) {
1387
+ lines.push('');
1388
+ lines.push(`[mcp_servers.${serverName}.env]`);
1389
+ lines.push(`GX_CONFIG_PATH = ${tomlString(targetConfigPath)}`);
1390
+ }
1269
1391
  lines.push('');
1270
1392
  writeClientText(filePath, stripped + lines.join('\n'));
1271
1393
  }
@@ -1321,12 +1443,12 @@ function normalizeExePath(p) {
1321
1443
  return s;
1322
1444
  }
1323
1445
 
1324
- function readClientCommandEntry(client, serverName = DEFAULT_MCP_SERVER_NAME) {
1446
+ function readClientCommandEntry(client, serverName = DEFAULT_MCP_SERVER_NAME, { fs: fileSystem = fs } = {}) {
1325
1447
  if (client.writeSupported === false) return null;
1326
- if (!fs.existsSync(client.path)) return null;
1448
+ if (!fileSystem.existsSync(client.path)) return null;
1327
1449
  try {
1328
1450
  if (client.format === 'mcpServers') {
1329
- const parsed = readJsonFileSafe(client.path);
1451
+ const parsed = readJsonFileSafe(client.path, fileSystem);
1330
1452
  if (!parsed || typeof parsed !== 'object') return null;
1331
1453
  let entry = parsed.mcpServers && parsed.mcpServers[serverName];
1332
1454
  if (!entry && serverName === DEFAULT_MCP_SERVER_NAME) {
@@ -1345,7 +1467,7 @@ function readClientCommandEntry(client, serverName = DEFAULT_MCP_SERVER_NAME) {
1345
1467
  };
1346
1468
  }
1347
1469
  if (client.format === 'opencode') {
1348
- const parsed = readJsonFileSafe(client.path);
1470
+ const parsed = readJsonFileSafe(client.path, fileSystem);
1349
1471
  if (!parsed || typeof parsed !== 'object') return null;
1350
1472
  let entry = parsed.mcp?.servers?.[serverName] || parsed.mcp?.[serverName];
1351
1473
  if (!entry && serverName === DEFAULT_MCP_SERVER_NAME) {
@@ -1374,7 +1496,7 @@ function readClientCommandEntry(client, serverName = DEFAULT_MCP_SERVER_NAME) {
1374
1496
  };
1375
1497
  }
1376
1498
  if (client.format === 'vscode-servers') {
1377
- const parsed = readJsonFileSafe(client.path);
1499
+ const parsed = readJsonFileSafe(client.path, fileSystem);
1378
1500
  if (!parsed || typeof parsed !== 'object') return null;
1379
1501
  let entry = parsed.servers && parsed.servers[serverName];
1380
1502
  if (!entry && serverName === DEFAULT_MCP_SERVER_NAME) {
@@ -1729,13 +1851,31 @@ function applyLauncherConfigOrExit({ cwd, stderr, quiet }) {
1729
1851
  return { ok: true };
1730
1852
  }
1731
1853
 
1732
- if (!directoryLooksLikeKnowledgeBase(cwd)) {
1733
- log('[genexus-mcp] ERROR: Zero-config failed because current directory is not a GeneXus KB.');
1734
- log(`[genexus-mcp] CWD: ${cwd}`);
1854
+ const discoveredGxPath = discoverGeneXusInstallation();
1855
+ if (!discoveredGxPath) {
1856
+ log('[genexus-mcp] ERROR: No config.json found and GeneXus installation auto-discovery failed.');
1735
1857
  log('[genexus-mcp] Fix with: npx genexus-mcp init --interactive');
1736
1858
  return { ok: false };
1737
1859
  }
1738
1860
 
1861
+ const userMcpDir = path.join(os.homedir(), '.genexus-mcp');
1862
+ const userMcpConfigPath = path.join(userMcpDir, 'config.json');
1863
+
1864
+
1865
+ if (!directoryLooksLikeKnowledgeBase(cwd)) {
1866
+ if (fs.existsSync(userMcpConfigPath)) {
1867
+ process.env.GX_CONFIG_PATH = userMcpConfigPath;
1868
+ return { ok: true };
1869
+ }
1870
+ log(`[genexus-mcp] Auto-discovered GeneXus at: ${discoveredGxPath}`);
1871
+ log(`[genexus-mcp] Current directory is not a GeneXus KB. Generating neutral user config at: ${userMcpConfigPath}`);
1872
+ fs.mkdirSync(userMcpDir, { recursive: true });
1873
+ const neutralConfig = generateNeutralConfig(discoveredGxPath);
1874
+ writeFileAtomic(userMcpConfigPath, JSON.stringify(neutralConfig, null, 2));
1875
+ process.env.GX_CONFIG_PATH = userMcpConfigPath;
1876
+ return { ok: true };
1877
+ }
1878
+
1739
1879
  const kbIdentity = readGeneXusKbIdentity(cwd);
1740
1880
  if (!kbIdentity.major) {
1741
1881
  log('[genexus-mcp] ERROR: Zero-config could not determine the KB GeneXus major safely.');
@@ -1776,6 +1916,7 @@ function applyLauncherConfigOrExit({ cwd, stderr, quiet }) {
1776
1916
 
1777
1917
  module.exports = {
1778
1918
  generateConfig,
1919
+ generateNeutralConfig,
1779
1920
  getGatewayExePath,
1780
1921
  getToolDefinitionsPath,
1781
1922
  getGeneXusVersionCatalog,
@@ -1787,6 +1928,7 @@ module.exports = {
1787
1928
  directoryLooksLikeKnowledgeBase,
1788
1929
  readJsonFileSafe,
1789
1930
  resolveConfigPathNoMutate,
1931
+ migrateLegacyConfig,
1790
1932
  createConfigFile,
1791
1933
  patchClientConfig,
1792
1934
  unpatchClientConfig,