genexus-mcp 3.2.1 → 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/README.md CHANGED
@@ -96,8 +96,9 @@ Before you start, make sure you have:
96
96
 
97
97
  - ✅ **Windows** (GeneXus is Windows-only)
98
98
  - ✅ **A supported GeneXus SDK** installed locally (see [`docs/generated/supported-versions.md`](docs/generated/supported-versions.md); pass another install path explicitly when needed)
99
+ - ✅ **GeneXus 18** installed locally (the primary supported SDK; other catalogued majors are also supported)
99
100
  - ✅ **A Knowledge Base created with a supported GeneXus major** and opened at least once in the IDE (so it's initialized)
100
- - ✅ **Node.js 18+** — check with `node --version` in a terminal; install from [nodejs.org](https://nodejs.org/) if missing
101
+ - ✅ **Node.js 22+** — check with `node --version` in a terminal; install from [nodejs.org](https://nodejs.org/) if missing
101
102
  - ✅ **An MCP-compatible AI client** — [Claude Desktop](https://claude.ai/download), [Claude Code](https://claude.com/claude-code), Cursor, Antigravity, etc.
102
103
 
103
104
  You do **not** need to clone this repo or install anything globally — `npx` handles it.
@@ -208,7 +209,7 @@ Paths to give to IT for the ASR / Defender exclusion list:
208
209
  <InstallDir>\worker\GxMcp.Worker.exe
209
210
  ```
210
211
 
211
- Re-run the same one-liner later to **upgrade** — it detects the installed version (`version.txt` in the install dir) and downloads only if a newer release is available. Use `-Force` to reinstall the same version, `-Version v2.3.0` to pin a specific tag, `-NoClient` to skip AI client registration. Node.js 18+ must be installed for client registration; without it the script still extracts the binaries but you'll need to edit the client config (`claude_desktop_config.json` etc.) manually.
212
+ Re-run the same one-liner later to **upgrade** — it detects the installed version (`version.txt` in the install dir) and downloads only if a newer release is available. Use `-Force` to reinstall the same version, `-Version v2.3.0` to pin a specific tag, `-NoClient` to skip AI client registration. Node.js 22+ must be installed for client registration; without it the script still extracts the binaries but you'll need to edit the client config (`claude_desktop_config.json` etc.) manually.
212
213
 
213
214
  ---
214
215
 
@@ -243,7 +244,7 @@ Once installed, here's what unlocks. Try these as your first prompts:
243
244
  - *"Read the Documentation part of the transaction Customer and rewrite it in markdown."*
244
245
 
245
246
  **Analysis**
246
- - *"Explain what the procedure ProcessShipment does, step by step."*
247
+ - *"Explain what the procedure ProcessShipment does."* `genexus_analyze mode=explain` is a compatibility-only envelope and returns `NotImplemented`; use `mode=summary`, `mode=context`, or `genexus_read` for supported analysis and source.
247
248
  - *"What SQL does the query in WebPanel CustomerList generate?"*
248
249
  - *"Summarize the structure of the Sales module."*
249
250
 
@@ -362,7 +363,7 @@ produced by `DataSelectorStructurePart.ToString()` on U16.
362
363
  - `genexus_merge` — 2- or 3-way object merge (`IMergeService`)
363
364
 
364
365
  **Analysis, docs & API**
365
- - `genexus_analyze` — cross-object semantic analysis (impact, dependencies, complexity, naming, code_metrics, summary, explain, `kb_stats` = KB activity/freshness, `table_relations` = table↔transaction relations + redundant attrs, …)
366
+ - `genexus_analyze` — cross-object semantic analysis (impact, dependencies, complexity, naming, code_metrics, summary, `kb_stats` = KB activity/freshness, `table_relations` = table↔transaction relations + redundant attrs, …). `mode=explain` is compatibility-only: it preserves the legacy response envelope and returns `NotImplemented`; use `mode=summary`, `mode=context`, or `genexus_read` instead.
366
367
  - `genexus_doc` — generate wiki / dependency graphs / health reports
367
368
  - `genexus_api` — introspect REST endpoints exposed by HTTP procedures
368
369
  - `genexus_security` — audit KB security: `audit_gam` (env/GAM props), `scan_secrets` (regex over Source), `scan_native` (the SDK's own Security Scanner, `ISecurityScannerService`)
@@ -580,8 +581,8 @@ graph LR
580
581
  Want to contribute or run a local dev build?
581
582
 
582
583
  1. Clone this repo on Windows.
583
- 2. Run `.\setup.bat` checks prerequisites, builds the C# components, and auto-registers the local build with detected AI clients.
584
- 3. If GeneXus or your KB aren't auto-detected, follow the prompts.
584
+ 2. Run `.\build.ps1` to restore and build the C# components and package the local artifacts. The script checks for the required .NET SDK and GeneXus 18 installation.
585
+ 3. If GeneXus is installed outside the default path, set `$env:GX_PATH` to its installation folder before running the build. A Knowledge Base is only needed for runtime testing.
585
586
 
586
587
  ### Bundled AI skills (`.gemini/skills/`)
587
588
 
@@ -5,10 +5,12 @@ const fs = require('fs');
5
5
  const {
6
6
  getGatewayExePath,
7
7
  getToolDefinitionsPath,
8
+ generateNeutralConfig,
8
9
  resolveConfigPathNoMutate,
9
10
  readJsonFileSafe,
10
11
  directoryLooksLikeKnowledgeBase,
11
12
  createConfigFile,
13
+ migrateLegacyConfig,
12
14
  patchClientConfig,
13
15
  unpatchClientConfig,
14
16
  getClientConfigTargets,
@@ -946,6 +948,109 @@ async function handleToolsList(options, ctx) {
946
948
  };
947
949
  }
948
950
 
951
+ async function handleConfigCreate(options, ctx) {
952
+ if (options.kb) {
953
+ return { exitCode: ctx.EXIT_CODES.USAGE, envelope: usageEnvelope('--kb is not allowed when creating a neutral runtime config.', ctx.EXIT_CODES.USAGE) };
954
+ }
955
+
956
+ const required = [
957
+ ['--config-scope', options.configScope],
958
+ ['--output', options.output],
959
+ ['--gx', options.gx],
960
+ ['--worker', options.worker],
961
+ ['--gateway-mode', options.gatewayMode],
962
+ ['--resolution-policy', options.resolutionPolicy]
963
+ ];
964
+ const missing = required.filter(([, value]) => !value).map(([flag]) => flag);
965
+ if (missing.length > 0) {
966
+ return {
967
+ exitCode: ctx.EXIT_CODES.USAGE,
968
+ envelope: usageEnvelope(`config create requires: ${missing.join(', ')}.`, ctx.EXIT_CODES.USAGE)
969
+ };
970
+ }
971
+ if (options.configScope !== 'neutral') {
972
+ return {
973
+ exitCode: ctx.EXIT_CODES.USAGE,
974
+ envelope: usageEnvelope('--config-scope must be `neutral` for config create.', ctx.EXIT_CODES.USAGE)
975
+ };
976
+ }
977
+
978
+ const outputPath = path.resolve(options.output);
979
+ try {
980
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
981
+ const config = generateNeutralConfig(options.gx, {
982
+ workerPath: options.worker,
983
+ gatewayMode: options.gatewayMode,
984
+ resolutionPolicy: options.resolutionPolicy
985
+ });
986
+ fs.writeFileSync(outputPath, JSON.stringify(config, null, 2));
987
+ return {
988
+ exitCode: ctx.EXIT_CODES.OK,
989
+ envelope: {
990
+ ok: {
991
+ action: 'config.create',
992
+ configScope: 'neutral',
993
+ configPath: outputPath,
994
+ clientsPatchedCount: 0,
995
+ config
996
+ },
997
+ help: [],
998
+ meta: { clientRegistration: 'not_attempted', kbCatalog: 'not_created' }
999
+ }
1000
+ };
1001
+ } catch (err) {
1002
+ return {
1003
+ exitCode: ctx.EXIT_CODES.ERROR,
1004
+ envelope: operationalErrorEnvelope(`Could not write neutral config: ${err.message}`, ctx.EXIT_CODES.ERROR)
1005
+ };
1006
+ }
1007
+ }
1008
+
1009
+ async function handleConfigMigrate(options, ctx) {
1010
+ const sourcePath = options.fromPath || resolveConfigPathNoMutate(ctx.cwd);
1011
+ if (!sourcePath) {
1012
+ return { exitCode: ctx.EXIT_CODES.USAGE, envelope: usageEnvelope('config migrate requires --from <legacy-config> (or a config.json in the current directory).', ctx.EXIT_CODES.USAGE) };
1013
+ }
1014
+ const targetPath = options.output || `${sourcePath}.neutral.json`;
1015
+ if (path.resolve(sourcePath) === path.resolve(targetPath)) {
1016
+ return { exitCode: ctx.EXIT_CODES.USAGE, envelope: usageEnvelope('config migrate requires --output to differ from --from; the legacy source is never overwritten.', ctx.EXIT_CODES.USAGE) };
1017
+ }
1018
+ try {
1019
+ const receipt = migrateLegacyConfig(sourcePath, targetPath, { rejectNonMigratable: Boolean(options.rejectNonMigratable) });
1020
+ return {
1021
+ exitCode: ctx.EXIT_CODES.OK,
1022
+ envelope: {
1023
+ ok: { action: 'config.migrate', ...receipt },
1024
+ help: receipt.notMigrated.length > 0
1025
+ ? ['KB fields were not migrated. Keep using `kb add`, `kb remove`, and `kb switch` against the legacy config, or select a KB explicitly through MCP.']
1026
+ : []
1027
+ }
1028
+ };
1029
+ } catch (err) {
1030
+ if (err.code === 'NON_MIGRATABLE_FIELDS') {
1031
+ return {
1032
+ exitCode: ctx.EXIT_CODES.USAGE,
1033
+ envelope: {
1034
+ ...usageEnvelope(err.message, ctx.EXIT_CODES.USAGE),
1035
+ meta: { exitCode: ctx.EXIT_CODES.USAGE, notMigrated: err.notMigrated || [] }
1036
+ }
1037
+ };
1038
+ }
1039
+ return {
1040
+ exitCode: ctx.EXIT_CODES.ERROR,
1041
+ envelope: {
1042
+ error: {
1043
+ code: 'operation_error',
1044
+ message: sanitizeOperationalMessage(`Configuration migration failed: ${err.message}`),
1045
+ rollback: err.rollback || { rolledBack: false }
1046
+ },
1047
+ help: err.backupPath ? [`The source backup was written to ${err.backupPath}.`] : [],
1048
+ meta: { exitCode: ctx.EXIT_CODES.ERROR, notMigrated: err.notMigrated || [] }
1049
+ }
1050
+ };
1051
+ }
1052
+ }
1053
+
949
1054
  async function handleConfigShow(options, ctx) {
950
1055
  const configPath = resolveConfigPathNoMutate(ctx.cwd);
951
1056
  if (!configPath) {
@@ -1631,7 +1736,8 @@ async function handleInit(options, ctx) {
1631
1736
  ids,
1632
1737
  onlyExisting: !options.allClients,
1633
1738
  serverName: options.serverName || DEFAULT_MCP_SERVER_NAME,
1634
- force: options.force
1739
+ force: options.force,
1740
+ globalConfig: Boolean(options.globalConfig)
1635
1741
  });
1636
1742
  }
1637
1743
 
@@ -2026,11 +2132,11 @@ async function handleClients(subcommand, options, ctx) {
2026
2132
  }
2027
2133
 
2028
2134
  if (sub === 'add' || sub === 'remove') {
2029
- const ids = resolveClientIds(options);
2135
+ const ids = options.allClients ? listSupportedClientIds() : resolveClientIds(options);
2030
2136
  if (sub === 'add' && (!ids || ids.length === 0)) {
2031
2137
  return {
2032
2138
  exitCode: ctx.EXIT_CODES.USAGE,
2033
- envelope: usageEnvelope('`clients add` requires --clients <csv> (e.g. --clients antigravity,vscode).', ctx.EXIT_CODES.USAGE)
2139
+ envelope: usageEnvelope('`clients add` requires --clients <csv> or --all-clients.', ctx.EXIT_CODES.USAGE)
2034
2140
  };
2035
2141
  }
2036
2142
  const validation = validateClientIds(ids);
@@ -2056,7 +2162,8 @@ async function handleClients(subcommand, options, ctx) {
2056
2162
  ids,
2057
2163
  onlyExisting: false,
2058
2164
  serverName,
2059
- force: options.force
2165
+ force: options.force,
2166
+ globalConfig: Boolean(options.globalConfig)
2060
2167
  });
2061
2168
  } catch (err) {
2062
2169
  return {
@@ -2265,8 +2372,8 @@ function commandHelpMap() {
2265
2372
  examples: ['genexus-mcp tools list', 'genexus-mcp tools list --query read --fields name,category --format json']
2266
2373
  },
2267
2374
  config: {
2268
- usage: 'genexus-mcp config show [--full] [--fields f1,f2] [--format ...]',
2269
- examples: ['genexus-mcp config show', 'genexus-mcp config show --full --format json']
2375
+ usage: 'genexus-mcp config show [--full] [--fields f1,f2] [--format ...] OR genexus-mcp config create --config-scope neutral --output <path> --gx <path> --worker <path> --gateway-mode <mode> --resolution-policy <policy> OR genexus-mcp config migrate --from <legacy.json> --output <neutral.json> [--reject-non-migratable]',
2376
+ examples: ['genexus-mcp config show', 'genexus-mcp config create --config-scope neutral --output ./config.json --gx <path> --worker <path> --gateway-mode stdio-isolated --resolution-policy strict', 'genexus-mcp config migrate --from ./config.json --output ./.genexus-mcp/config.json']
2270
2377
  },
2271
2378
  init: {
2272
2379
  usage: 'genexus-mcp init [--kb <path>] [--gx <path>] [--server-name <name>] [--force] [--no-write-clients] [--clients <csv>] [--all-clients] [--no-smoke] [--warm] [--format ...] OR genexus-mcp init --interactive',
@@ -2386,7 +2493,7 @@ async function handleHelp(targetCommand, ctx) {
2386
2493
  bin: binPath,
2387
2494
  command: 'genexus-mcp',
2388
2495
  description: 'GeneXus MCP launcher and AXI-oriented utility CLI',
2389
- commands: ['home', 'axi home', 'status', 'doctor', 'tools list', 'config show', 'layout status', 'layout run', 'layout inspect', 'init', 'whoami', 'uninstall', 'kb list', 'kb add', 'kb remove', 'kb switch', 'llm help', 'update', 'help'],
2496
+ commands: ['home', 'axi home', 'status', 'doctor', 'tools list', 'config show', 'config create', 'layout status', 'layout run', 'layout inspect', 'init', 'whoami', 'uninstall', 'kb list', 'kb add', 'kb remove', 'kb switch', 'llm help', 'update', 'help'],
2390
2497
  defaults: { format: 'toon', limit: 100 }
2391
2498
  },
2392
2499
  help: [
@@ -2488,6 +2595,8 @@ module.exports = {
2488
2595
  handleDoctor,
2489
2596
  handleToolsList,
2490
2597
  handleConfigShow,
2598
+ handleConfigCreate,
2599
+ handleConfigMigrate,
2491
2600
  handleInit,
2492
2601
  handleWhoami,
2493
2602
  handleUninstall,
@@ -0,0 +1,29 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const repoRoot = path.join(__dirname, '..');
7
+ const read = (file) => fs.readFileSync(path.join(repoRoot, file), 'utf8');
8
+
9
+ function requiredNodeMajor() {
10
+ const range = JSON.parse(read('package.json')).engines.node;
11
+ const match = range.match(/>=([0-9]+)/);
12
+ assert.ok(match, `Unable to determine Node.js minimum from engines.node: ${range}`);
13
+ return match[1];
14
+ }
15
+
16
+ test('onboarding docs stay aligned with package prerequisites', () => {
17
+ const nodeMajor = requiredNodeMajor();
18
+ const readme = read('README.md');
19
+ const spanish = read('docs/GETTING_STARTED.es.md');
20
+ const portuguese = read('docs/GETTING_STARTED.pt-br.md');
21
+
22
+ assert.match(readme, new RegExp(`Node\\.js ${nodeMajor}\\+`));
23
+ assert.match(spanish, new RegExp(`Node\\.js ${nodeMajor} o superior`));
24
+ assert.match(portuguese, new RegExp(`Node\\.js ${nodeMajor} ou superior`));
25
+ assert.doesNotMatch(readme, /setup\.bat/);
26
+ assert.match(readme, /`\.\\build\.ps1`/);
27
+ assert.match(readme, /\*\*Windows\*\* \(GeneXus is Windows-only\)/);
28
+ assert.match(readme, /GeneXus 18.*installed locally/);
29
+ });
package/cli/index.js CHANGED
@@ -15,6 +15,8 @@ const {
15
15
  handleDoctor,
16
16
  handleToolsList,
17
17
  handleConfigShow,
18
+ handleConfigCreate,
19
+ handleConfigMigrate,
18
20
  handleInit,
19
21
  handleWhoami,
20
22
  handleUninstall,
@@ -97,8 +99,8 @@ function parseArgs(argv) {
97
99
  tokens.shift();
98
100
  }
99
101
 
100
- if (result.command === 'config' && tokens[0] === 'show') {
101
- result.subcommand = 'show';
102
+ if (result.command === 'config' && ['show', 'create', 'migrate'].includes(tokens[0])) {
103
+ result.subcommand = tokens[0];
102
104
  tokens.shift();
103
105
  }
104
106
 
@@ -175,6 +177,45 @@ function parseArgs(argv) {
175
177
  else result.unknownFlags.push('--gx requires a value');
176
178
  break;
177
179
  }
180
+ case 'from': {
181
+ const val = takeValue();
182
+ if (val) result.options.fromPath = val;
183
+ else result.unknownFlags.push('--from requires a value');
184
+ break;
185
+ }
186
+ case 'reject-non-migratable':
187
+ result.options.rejectNonMigratable = true;
188
+ break;
189
+ case 'output': {
190
+ const val = takeValue();
191
+ if (val) result.options.output = val;
192
+ else result.unknownFlags.push('--output requires a value');
193
+ break;
194
+ }
195
+ case 'worker': {
196
+ const val = takeValue();
197
+ if (val) result.options.worker = val;
198
+ else result.unknownFlags.push('--worker requires a value');
199
+ break;
200
+ }
201
+ case 'config-scope': {
202
+ const val = takeValue();
203
+ if (val) result.options.configScope = val;
204
+ else result.unknownFlags.push('--config-scope requires a value');
205
+ break;
206
+ }
207
+ case 'gateway-mode': {
208
+ const val = takeValue();
209
+ if (val) result.options.gatewayMode = val;
210
+ else result.unknownFlags.push('--gateway-mode requires a value');
211
+ break;
212
+ }
213
+ case 'resolution-policy': {
214
+ const val = takeValue();
215
+ if (val) result.options.resolutionPolicy = val;
216
+ else result.unknownFlags.push('--resolution-policy requires a value');
217
+ break;
218
+ }
178
219
  case 'name': {
179
220
  const val = takeValue();
180
221
  if (val) result.options.name = val;
@@ -290,6 +331,12 @@ function parseArgs(argv) {
290
331
  case 'interactive':
291
332
  result.options.interactive = true;
292
333
  break;
334
+ case 'global-config':
335
+ result.options.globalConfig = true;
336
+ break;
337
+ case 'neutral':
338
+ result.options.neutral = true;
339
+ break;
293
340
  case 'write-clients':
294
341
  result.options.writeClients = true;
295
342
  break;
@@ -466,7 +513,7 @@ function withCommandMeta(envelope, commandName) {
466
513
  function resolveMetaCommand(parsed, targetHelp) {
467
514
  if (targetHelp || parsed.command === 'help') return 'help';
468
515
  if (parsed.command === 'tools') return 'tools.list';
469
- if (parsed.command === 'config') return 'config.show';
516
+ if (parsed.command === 'config') return parsed.subcommand ? `config.${parsed.subcommand}` : 'config';
470
517
  if (parsed.command === 'axi' || parsed.command === 'home') return 'home';
471
518
  if (parsed.command === 'llm') return 'llm.help';
472
519
  if (parsed.command === 'layout') {
@@ -556,15 +603,19 @@ async function main(argv) {
556
603
  result = await handleToolsList(parsed.options, ctx);
557
604
  break;
558
605
  case 'config':
559
- if (parsed.subcommand !== 'show') {
606
+ if (!['show', 'create', 'migrate'].includes(parsed.subcommand)) {
560
607
  writeStructured(
561
608
  process.stdout,
562
- withCommandMeta(usageEnvelope('config requires subcommand `show`.', EXIT_CODES.USAGE), resolveMetaCommand(parsed)),
609
+ withCommandMeta(usageEnvelope('config requires subcommand `show`, `create`, or `migrate`.', EXIT_CODES.USAGE), resolveMetaCommand(parsed)),
563
610
  parsed.options.format
564
611
  );
565
612
  return EXIT_CODES.USAGE;
566
613
  }
567
- result = await handleConfigShow(parsed.options, ctx);
614
+ result = parsed.subcommand === 'create'
615
+ ? await handleConfigCreate(parsed.options, ctx)
616
+ : parsed.subcommand === 'migrate'
617
+ ? await handleConfigMigrate(parsed.options, ctx)
618
+ : await handleConfigShow(parsed.options, ctx);
568
619
  break;
569
620
  case 'llm':
570
621
  if (parsed.subcommand && parsed.subcommand !== 'help') {
@@ -11,6 +11,10 @@ const {
11
11
  CodexTomlAdapter,
12
12
  ClientConfigManager
13
13
  } = require('./client-adapters');
14
+ const {
15
+ generateNeutralConfig,
16
+ applyLauncherConfigOrExit
17
+ } = require('./config');
14
18
 
15
19
  test('getClientAdapter returns correct strategy for each format', () => {
16
20
  assert.ok(getClientAdapter('mcpServers') instanceof McpServersJsonAdapter);
@@ -19,7 +23,7 @@ test('getClientAdapter returns correct strategy for each format', () => {
19
23
  assert.ok(getClientAdapter('codex-toml') instanceof CodexTomlAdapter);
20
24
  });
21
25
 
22
- test('ClientConfigManager applies and reads mcpServers format cleanly', () => {
26
+ test('ClientConfigManager applies and reads mcpServers format cleanly without GX_CONFIG_PATH by default', () => {
23
27
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-cli-test-'));
24
28
  const configPath = path.join(tmpDir, 'mcp.json');
25
29
  const client = { id: 'test-cursor', name: 'Test Cursor', format: 'mcpServers', path: configPath };
@@ -27,13 +31,22 @@ test('ClientConfigManager applies and reads mcpServers format cleanly', () => {
27
31
 
28
32
  try {
29
33
  const manager = new ClientConfigManager();
34
+ // Decoupled default
30
35
  manager.apply(client, launcher, 'C:\\MyKb\\config.json', { serverName: 'genexus18mcp' });
31
36
 
32
- const raw = fs.readFileSync(configPath, 'utf8');
33
- const parsed = JSON.parse(raw);
37
+ let raw = fs.readFileSync(configPath, 'utf8');
38
+ let parsed = JSON.parse(raw);
34
39
  assert.ok(parsed.mcpServers);
35
40
  assert.ok(parsed.mcpServers.genexus18mcp);
36
41
  assert.equal(parsed.mcpServers.genexus18mcp.command, 'node');
42
+ assert.equal(parsed.mcpServers.genexus18mcp.env, undefined);
43
+
44
+ // With globalConfig: true
45
+ manager.apply(client, launcher, 'C:\\MyKb\\config.json', { serverName: 'genexus18mcp', globalConfig: true, force: true });
46
+ raw = fs.readFileSync(configPath, 'utf8');
47
+ parsed = JSON.parse(raw);
48
+ assert.ok(parsed.mcpServers.genexus18mcp.env);
49
+ assert.equal(parsed.mcpServers.genexus18mcp.env.GX_CONFIG_PATH, 'C:\\MyKb\\config.json');
37
50
 
38
51
  const readEntry = manager.read(client, 'genexus18mcp');
39
52
  assert.ok(readEntry);
@@ -48,3 +61,170 @@ test('ClientConfigManager applies and reads mcpServers format cleanly', () => {
48
61
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { }
49
62
  }
50
63
  });
64
+
65
+ test('ClientConfigManager applies vscode-servers format cleanly without GX_CONFIG_PATH by default', () => {
66
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-cli-test-'));
67
+ const configPath = path.join(tmpDir, 'mcp.json');
68
+ const client = { id: 'test-vscode', name: 'Test VSCode', format: 'vscode-servers', path: configPath };
69
+ const launcher = { command: 'node', args: ['run.js'] };
70
+
71
+ try {
72
+ const manager = new ClientConfigManager();
73
+ // Decoupled default
74
+ manager.apply(client, launcher, 'C:\\MyKb\\config.json', { serverName: 'genexus18mcp' });
75
+
76
+ let raw = fs.readFileSync(configPath, 'utf8');
77
+ let parsed = JSON.parse(raw);
78
+ assert.ok(parsed.servers);
79
+ assert.ok(parsed.servers.genexus18mcp);
80
+ assert.equal(parsed.servers.genexus18mcp.command, 'node');
81
+ assert.equal(parsed.servers.genexus18mcp.env, undefined);
82
+
83
+ // With globalConfig: true
84
+ manager.apply(client, launcher, 'C:\\MyKb\\config.json', { serverName: 'genexus18mcp', globalConfig: true, force: true });
85
+ raw = fs.readFileSync(configPath, 'utf8');
86
+ parsed = JSON.parse(raw);
87
+ assert.ok(parsed.servers.genexus18mcp.env);
88
+ assert.equal(parsed.servers.genexus18mcp.env.GX_CONFIG_PATH, 'C:\\MyKb\\config.json');
89
+ } finally {
90
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { }
91
+ }
92
+ });
93
+
94
+ test('ClientConfigManager applies opencode format cleanly without GX_CONFIG_PATH by default', () => {
95
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-cli-test-'));
96
+ const configPath = path.join(tmpDir, 'opencode.json');
97
+ const client = { id: 'test-opencode', name: 'Test OpenCode', format: 'opencode', path: configPath };
98
+ const launcher = { command: 'node', args: ['run.js'] };
99
+
100
+ try {
101
+ const manager = new ClientConfigManager();
102
+ // Decoupled default
103
+ manager.apply(client, launcher, 'C:\\MyKb\\config.json', { serverName: 'genexus18mcp' });
104
+
105
+ let raw = fs.readFileSync(configPath, 'utf8');
106
+ let parsed = JSON.parse(raw);
107
+ assert.ok(parsed.mcp);
108
+ assert.ok(parsed.mcp.genexus18mcp);
109
+ assert.equal(parsed.mcp.genexus18mcp.environment, undefined);
110
+
111
+ // With globalConfig: true
112
+ manager.apply(client, launcher, 'C:\\MyKb\\config.json', { serverName: 'genexus18mcp', globalConfig: true, force: true });
113
+ raw = fs.readFileSync(configPath, 'utf8');
114
+ parsed = JSON.parse(raw);
115
+ assert.ok(parsed.mcp.genexus18mcp.environment);
116
+ assert.equal(parsed.mcp.genexus18mcp.environment.GX_CONFIG_PATH, 'C:\\MyKb\\config.json');
117
+ } finally {
118
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { }
119
+ }
120
+ });
121
+
122
+ test('ClientConfigManager applies codex-toml format cleanly without GX_CONFIG_PATH by default', () => {
123
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-cli-test-'));
124
+ const configPath = path.join(tmpDir, 'config.toml');
125
+ const client = { id: 'test-codex', name: 'Test Codex', format: 'codex-toml', path: configPath };
126
+ const launcher = { command: 'node', args: ['run.js'] };
127
+
128
+ try {
129
+ const manager = new ClientConfigManager();
130
+ // Decoupled default
131
+ manager.apply(client, launcher, 'C:\\MyKb\\config.json', { serverName: 'genexus18mcp' });
132
+
133
+ let raw = fs.readFileSync(configPath, 'utf8');
134
+ assert.ok(raw.includes('[mcp_servers.genexus18mcp]'));
135
+ assert.ok(!raw.includes('GX_CONFIG_PATH'));
136
+
137
+ // With globalConfig: true
138
+ manager.apply(client, launcher, 'C:\\MyKb\\config.json', { serverName: 'genexus18mcp', globalConfig: true, force: true });
139
+ raw = fs.readFileSync(configPath, 'utf8');
140
+ assert.ok(raw.includes('[mcp_servers.genexus18mcp.env]'));
141
+ assert.ok(raw.includes('GX_CONFIG_PATH'));
142
+ } finally {
143
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { }
144
+ }
145
+ });
146
+
147
+ test('generateNeutralConfig produces valid shape without KB fields', () => {
148
+ const cfg = generateNeutralConfig('C:\\GeneXus18');
149
+ assert.equal(cfg.ConfigSchemaVersion, 2);
150
+ assert.equal(cfg.GatewayMode, 'stdio-isolated');
151
+ assert.equal(cfg.GeneXus.InstallationPath, 'C:\\GeneXus18');
152
+ assert.ok(cfg.GeneXus.WorkerExecutable.endsWith(path.join('worker', 'GxMcp.Worker.exe')));
153
+ assert.equal(cfg.Server.McpStdio, true);
154
+ assert.equal(cfg.Server.TransportMode, undefined);
155
+ assert.equal(cfg.Server.HttpPort, 0);
156
+ assert.equal(cfg.Environment.ResolutionPolicy, 'strict');
157
+ assert.equal(cfg.Environment.KBs, undefined);
158
+ assert.equal(cfg.Environment.KBPath, undefined);
159
+ });
160
+
161
+ test('all client adapters preserve unrelated servers and emit no structural KB overrides', () => {
162
+ const cases = [
163
+ { format: 'mcpServers', file: 'claude.json', root: { mcpServers: { unrelated: { command: 'other' } } } },
164
+ { format: 'mcpServers', file: 'gemini.json', root: { mcpServers: { unrelated: { command: 'other' } } } },
165
+ { format: 'mcpServers', file: 'antigravity.json', root: { mcpServers: { unrelated: { command: 'other' } } } },
166
+ { format: 'vscode-servers', file: 'vscode.json', root: { servers: { unrelated: { type: 'stdio', command: 'other' } } } },
167
+ { format: 'opencode', file: 'opencode.json', root: { $schema: 'custom', mcp: { unrelated: { type: 'local', command: ['other'] } } } },
168
+ { format: 'codex-toml', file: 'config.toml', root: '[mcp_servers.unrelated]\ncommand = "other"\n' }
169
+ ];
170
+ const launcher = { command: 'gateway.exe', args: [] };
171
+ for (const entry of cases) {
172
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-adapter-matrix-'));
173
+ try {
174
+ const configPath = path.join(tmpDir, entry.file);
175
+ fs.writeFileSync(configPath, typeof entry.root === 'string' ? entry.root : JSON.stringify(entry.root, null, 2));
176
+ new ClientConfigManager().apply({ format: entry.format, path: configPath }, launcher, path.join(tmpDir, 'neutral.json'), { serverName: 'genexus18mcp' });
177
+ const raw = fs.readFileSync(configPath, 'utf8');
178
+ assert.equal(/DefaultKb|ActiveKb|KBs/.test(raw), false, entry.format);
179
+ if (entry.format === 'codex-toml') {
180
+ assert.match(raw, /\[mcp_servers\.unrelated\]/);
181
+ assert.match(raw, /\[mcp_servers\.genexus18mcp\]/);
182
+ } else {
183
+ const parsed = JSON.parse(raw);
184
+ const servers = entry.format === 'vscode-servers' ? parsed.servers : entry.format === 'opencode' ? parsed.mcp : parsed.mcpServers;
185
+ const original = entry.format === 'vscode-servers' ? entry.root.servers : entry.format === 'opencode' ? entry.root.mcp : entry.root.mcpServers;
186
+ assert.deepEqual(servers.unrelated, original.unrelated);
187
+ assert.ok(servers.genexus18mcp);
188
+ }
189
+ } finally {
190
+ fs.rmSync(tmpDir, { recursive: true, force: true });
191
+ }
192
+ }
193
+ });
194
+
195
+ test('applyLauncherConfigOrExit creates neutral config outside a KB', () => {
196
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-outside-kb-'));
197
+ const origEnv = process.env.GX_CONFIG_PATH;
198
+ const origGeneXusHome = process.env.GENEXUS_HOME;
199
+ delete process.env.GX_CONFIG_PATH;
200
+ const fakeGeneXusHome = path.join(tmpDir, 'GeneXus18');
201
+ fs.mkdirSync(fakeGeneXusHome, { recursive: true });
202
+ fs.writeFileSync(path.join(fakeGeneXusHome, 'genexus.exe'), 'test fixture');
203
+ process.env.GENEXUS_HOME = fakeGeneXusHome;
204
+
205
+ try {
206
+ const stderr = { write: () => {} };
207
+ const result = applyLauncherConfigOrExit({ cwd: tmpDir, stderr, quiet: true });
208
+
209
+ assert.equal(result.ok, true);
210
+ assert.ok(process.env.GX_CONFIG_PATH);
211
+ assert.ok(fs.existsSync(process.env.GX_CONFIG_PATH));
212
+ const content = JSON.parse(fs.readFileSync(process.env.GX_CONFIG_PATH, 'utf8'));
213
+ assert.equal(content.Environment?.KBs, undefined);
214
+ assert.equal(content.Environment?.DefaultKb, undefined);
215
+ assert.equal(content.Environment?.ActiveKb, undefined);
216
+ assert.equal(content.Environment?.KBPath, undefined);
217
+ } finally {
218
+ if (origEnv !== undefined) {
219
+ process.env.GX_CONFIG_PATH = origEnv;
220
+ } else {
221
+ delete process.env.GX_CONFIG_PATH;
222
+ }
223
+ if (origGeneXusHome !== undefined) {
224
+ process.env.GENEXUS_HOME = origGeneXusHome;
225
+ } else {
226
+ delete process.env.GENEXUS_HOME;
227
+ }
228
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { }
229
+ }
230
+ });