genexus-mcp 2.0.4 → 2.1.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/cli/lib/config.js CHANGED
@@ -21,6 +21,42 @@ function getToolDefinitionsPath() {
21
21
  return path.join(__dirname, '..', '..', 'src', 'GxMcp.Gateway', 'tool_definitions.json');
22
22
  }
23
23
 
24
+ function discoverGeneXusFromRegistry() {
25
+ if (process.platform !== 'win32') return null;
26
+ try {
27
+ const { execFileSync } = require('child_process');
28
+ const versions = ['GeneXus 18', 'GeneXus 17', 'GeneXus 16'];
29
+ const hives = [
30
+ 'HKLM\\SOFTWARE\\WOW6432Node\\Artech',
31
+ 'HKLM\\SOFTWARE\\Artech',
32
+ 'HKCU\\SOFTWARE\\Artech'
33
+ ];
34
+ for (const hive of hives) {
35
+ for (const ver of versions) {
36
+ const key = `${hive}\\${ver}`;
37
+ try {
38
+ const out = execFileSync('reg.exe', ['query', key, '/v', 'InstallationDirectory'], {
39
+ encoding: 'utf8',
40
+ stdio: ['ignore', 'pipe', 'ignore'],
41
+ windowsHide: true,
42
+ timeout: 3000
43
+ });
44
+ const match = out.match(/InstallationDirectory\s+REG_SZ\s+(.+?)\r?\n/i);
45
+ if (match) {
46
+ const candidate = match[1].trim().replace(/[\\/]+$/, '');
47
+ if (candidate && fs.existsSync(path.join(candidate, 'genexus.exe'))) {
48
+ return candidate;
49
+ }
50
+ }
51
+ } catch {
52
+ }
53
+ }
54
+ }
55
+ } catch {
56
+ }
57
+ return null;
58
+ }
59
+
24
60
  function discoverGeneXusInstallation() {
25
61
  const possible = [
26
62
  'C:\\Program Files (x86)\\GeneXus\\GeneXus18',
@@ -36,6 +72,15 @@ function discoverGeneXusInstallation() {
36
72
  }
37
73
  }
38
74
 
75
+ const fromRegistry = discoverGeneXusFromRegistry();
76
+ if (fromRegistry) return fromRegistry;
77
+
78
+ return null;
79
+ }
80
+
81
+ function discoverKnowledgeBase(cwd) {
82
+ if (!cwd) return null;
83
+ if (directoryLooksLikeKnowledgeBase(cwd)) return cwd;
39
84
  return null;
40
85
  }
41
86
 
@@ -71,20 +116,24 @@ function resolveConfigPathNoMutate(cwd) {
71
116
 
72
117
  function createConfigFile(kbPath, gxPath) {
73
118
  const targetConfigPath = path.join(kbPath, 'config.json');
74
- const nextConfig = generateConfig(gxPath, kbPath);
119
+ const baseConfig = generateConfig(gxPath, kbPath);
75
120
 
76
121
  if (!fs.existsSync(kbPath)) {
77
122
  fs.mkdirSync(kbPath, { recursive: true });
78
123
  }
79
124
 
80
- let changed = true;
81
- if (fs.existsSync(targetConfigPath)) {
82
- const current = readJsonFileSafe(targetConfigPath);
83
- if (current && JSON.stringify(current) === JSON.stringify(nextConfig)) {
84
- changed = false;
85
- }
125
+ const existing = fs.existsSync(targetConfigPath) ? readJsonFileSafe(targetConfigPath) : null;
126
+ const preservedEnv = {};
127
+ if (existing && existing.Environment) {
128
+ if (existing.Environment.KBs) preservedEnv.KBs = existing.Environment.KBs;
129
+ if (existing.Environment.ActiveKb) preservedEnv.ActiveKb = existing.Environment.ActiveKb;
86
130
  }
131
+ const nextConfig = {
132
+ ...baseConfig,
133
+ Environment: { ...baseConfig.Environment, ...preservedEnv }
134
+ };
87
135
 
136
+ const changed = !existing || JSON.stringify(existing) !== JSON.stringify(nextConfig);
88
137
  if (changed) {
89
138
  fs.writeFileSync(targetConfigPath, JSON.stringify(nextConfig, null, 2));
90
139
  }
@@ -97,17 +146,7 @@ function createConfigFile(kbPath, gxPath) {
97
146
  }
98
147
 
99
148
  function patchClientConfig(targetConfigPath) {
100
- const claudeWin = path.join(os.homedir(), 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json');
101
- const claudeMac = path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
102
- const antigravityCfg = path.join(os.homedir(), '.gemini', 'antigravity', 'mcp_config.json');
103
- const claudeCodeCfg = path.join(os.homedir(), '.claude.json');
104
-
105
- const clients = [
106
- { path: claudeWin, name: 'Claude Desktop (Windows)' },
107
- { path: claudeMac, name: 'Claude Desktop (macOS)' },
108
- { path: antigravityCfg, name: 'Antigravity' },
109
- { path: claudeCodeCfg, name: 'Claude Code' }
110
- ];
149
+ const clients = getClientConfigTargets();
111
150
 
112
151
  const patched = [];
113
152
  const failed = [];
@@ -140,6 +179,158 @@ function patchClientConfig(targetConfigPath) {
140
179
  return { patched, failed };
141
180
  }
142
181
 
182
+ function getClientConfigTargets() {
183
+ return [
184
+ { path: path.join(os.homedir(), 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json'), name: 'Claude Desktop (Windows)' },
185
+ { path: path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), name: 'Claude Desktop (macOS)' },
186
+ { path: path.join(os.homedir(), '.gemini', 'antigravity', 'mcp_config.json'), name: 'Antigravity' },
187
+ { path: path.join(os.homedir(), '.claude.json'), name: 'Claude Code' }
188
+ ];
189
+ }
190
+
191
+ function unpatchClientConfig() {
192
+ const clients = getClientConfigTargets();
193
+ const removed = [];
194
+ const skipped = [];
195
+ const failed = [];
196
+
197
+ for (const client of clients) {
198
+ if (!fs.existsSync(client.path)) continue;
199
+
200
+ try {
201
+ const parsed = readJsonFileSafe(client.path);
202
+ if (parsed === null) {
203
+ failed.push({ client: client.name, reason: 'Invalid JSON' });
204
+ continue;
205
+ }
206
+
207
+ const cfgObj = parsed || {};
208
+ if (!cfgObj.mcpServers || !cfgObj.mcpServers.genexus) {
209
+ skipped.push({ client: client.name, reason: 'no genexus entry' });
210
+ continue;
211
+ }
212
+
213
+ delete cfgObj.mcpServers.genexus;
214
+ fs.writeFileSync(client.path, JSON.stringify(cfgObj, null, 2));
215
+ removed.push(client.name);
216
+ } catch (err) {
217
+ failed.push({ client: client.name, reason: err && err.message ? err.message : 'Unknown error' });
218
+ }
219
+ }
220
+
221
+ return { removed, skipped, failed };
222
+ }
223
+
224
+ function getLocalAppDataCacheDir() {
225
+ if (process.platform !== 'win32') return null;
226
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
227
+ return path.join(base, 'GenexusMCP');
228
+ }
229
+
230
+ function readGeneXusVersionFromInstall(gxPath) {
231
+ if (!gxPath) return null;
232
+ const candidates = [
233
+ path.join(gxPath, 'version.txt'),
234
+ path.join(gxPath, 'Version.txt'),
235
+ path.join(gxPath, 'GeneXus.version')
236
+ ];
237
+ for (const candidate of candidates) {
238
+ try {
239
+ const raw = fs.readFileSync(candidate, 'utf8').trim();
240
+ if (raw) return raw.split(/\r?\n/)[0].trim();
241
+ } catch {
242
+ }
243
+ }
244
+ return null;
245
+ }
246
+
247
+ function readKbCatalog(configPath) {
248
+ if (!configPath) return { kbs: {}, activeKb: null, kbPath: null };
249
+ const cfg = readJsonFileSafe(configPath);
250
+ if (!cfg) return { kbs: {}, activeKb: null, kbPath: null };
251
+ const env = cfg.Environment || {};
252
+ return {
253
+ kbs: (env.KBs && typeof env.KBs === 'object') ? env.KBs : {},
254
+ activeKb: typeof env.ActiveKb === 'string' ? env.ActiveKb : null,
255
+ kbPath: typeof env.KBPath === 'string' ? env.KBPath : null
256
+ };
257
+ }
258
+
259
+ function writeKbCatalog(configPath, { kbs, activeKb, kbPath }) {
260
+ const cfg = readJsonFileSafe(configPath) || {};
261
+ cfg.Environment = cfg.Environment || {};
262
+ cfg.Environment.KBs = kbs;
263
+ if (activeKb) cfg.Environment.ActiveKb = activeKb;
264
+ else delete cfg.Environment.ActiveKb;
265
+ if (kbPath) cfg.Environment.KBPath = kbPath;
266
+ else delete cfg.Environment.KBPath;
267
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
268
+ }
269
+
270
+ function addKbToConfig(configPath, name, kbPath) {
271
+ const catalog = readKbCatalog(configPath);
272
+ const alreadyRegistered = catalog.kbs[name] === kbPath;
273
+ const willBecomeActive = !catalog.activeKb;
274
+ if (alreadyRegistered && !willBecomeActive) {
275
+ return catalog;
276
+ }
277
+ catalog.kbs[name] = kbPath;
278
+ if (willBecomeActive) {
279
+ catalog.activeKb = name;
280
+ catalog.kbPath = kbPath;
281
+ }
282
+ writeKbCatalog(configPath, catalog);
283
+ return catalog;
284
+ }
285
+
286
+ function removeKbFromConfig(configPath, name) {
287
+ const catalog = readKbCatalog(configPath);
288
+ if (!(name in catalog.kbs)) return { catalog, removed: false };
289
+ delete catalog.kbs[name];
290
+ if (catalog.activeKb === name) {
291
+ const remainingNames = Object.keys(catalog.kbs);
292
+ catalog.activeKb = remainingNames[0] || null;
293
+ catalog.kbPath = catalog.activeKb ? catalog.kbs[catalog.activeKb] : null;
294
+ }
295
+ writeKbCatalog(configPath, catalog);
296
+ return { catalog, removed: true };
297
+ }
298
+
299
+ function switchActiveKb(configPath, { name, path: explicitPath }) {
300
+ const catalog = readKbCatalog(configPath);
301
+
302
+ let targetName = name;
303
+ let targetPath = explicitPath;
304
+
305
+ if (explicitPath && !name) {
306
+ const existing = Object.entries(catalog.kbs).find(([, p]) => p === explicitPath);
307
+ if (existing) {
308
+ targetName = existing[0];
309
+ } else {
310
+ targetName = path.basename(explicitPath);
311
+ if (catalog.kbs[targetName] && catalog.kbs[targetName] !== explicitPath) {
312
+ return {
313
+ ok: false,
314
+ reason: `Name '${targetName}' is already registered to a different path (${catalog.kbs[targetName]}). Pass --name explicitly to disambiguate.`
315
+ };
316
+ }
317
+ catalog.kbs[targetName] = explicitPath;
318
+ }
319
+ } else if (name) {
320
+ if (!(name in catalog.kbs)) {
321
+ return { ok: false, reason: `KB '${name}' is not registered. Use \`genexus-mcp kb add\` first or pass --path.` };
322
+ }
323
+ targetPath = catalog.kbs[name];
324
+ } else {
325
+ return { ok: false, reason: 'Either --name or --path is required.' };
326
+ }
327
+
328
+ catalog.activeKb = targetName;
329
+ catalog.kbPath = targetPath;
330
+ writeKbCatalog(configPath, catalog);
331
+ return { ok: true, catalog, switchedTo: { name: targetName, path: targetPath } };
332
+ }
333
+
143
334
  function applyLauncherConfigOrExit({ cwd, stderr, quiet }) {
144
335
  const log = (msg) => {
145
336
  if (!quiet) stderr.write(`${msg}\n`);
@@ -185,10 +376,20 @@ module.exports = {
185
376
  getGatewayExePath,
186
377
  getToolDefinitionsPath,
187
378
  discoverGeneXusInstallation,
379
+ discoverGeneXusFromRegistry,
380
+ discoverKnowledgeBase,
188
381
  directoryLooksLikeKnowledgeBase,
189
382
  readJsonFileSafe,
190
383
  resolveConfigPathNoMutate,
191
384
  createConfigFile,
192
385
  patchClientConfig,
386
+ unpatchClientConfig,
387
+ getClientConfigTargets,
388
+ getLocalAppDataCacheDir,
389
+ readGeneXusVersionFromInstall,
390
+ readKbCatalog,
391
+ addKbToConfig,
392
+ removeKbFromConfig,
393
+ switchActiveKb,
193
394
  applyLauncherConfigOrExit
194
395
  };
package/cli/run.test.js CHANGED
@@ -135,6 +135,7 @@ test('non-interactive init supports idempotent no-op', () => {
135
135
  kbDir,
136
136
  '--gx',
137
137
  'C:\\Program Files (x86)\\GeneXus\\GeneXus18',
138
+ '--no-smoke',
138
139
  '--format',
139
140
  'json'
140
141
  ];
@@ -143,6 +144,10 @@ test('non-interactive init supports idempotent no-op', () => {
143
144
  assert.equal(first.status, 0);
144
145
  const firstParsed = JSON.parse(first.stdout);
145
146
  assert.equal(firstParsed.ok.noOp, false);
147
+ assert.ok(firstParsed.ok.verification, 'init should include verification block');
148
+ assert.ok(firstParsed.ok.verification.summary, 'verification should have summary');
149
+ assert.ok(Array.isArray(firstParsed.ok.verification.checks), 'verification should have checks array');
150
+ assert.equal(firstParsed.meta.smokeSkipped, true, '--no-smoke should be reflected in meta');
146
151
 
147
152
  const second = runCli(args);
148
153
  assert.equal(second.status, 0);
@@ -155,6 +160,257 @@ test('non-interactive init supports idempotent no-op', () => {
155
160
  fs.rmSync(tempRoot, { recursive: true, force: true });
156
161
  });
157
162
 
163
+ test('whoami without config returns disconnected state', () => {
164
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-test-'));
165
+ const res = runCli(['whoami', '--format', 'json'], { cwd: tempRoot });
166
+ assert.equal(res.status, 0);
167
+ const parsed = JSON.parse(res.stdout);
168
+ assert.equal(parsed.ok.connected, false);
169
+ assert.ok(parsed.ok.reason, 'should explain why not connected');
170
+ fs.rmSync(tempRoot, { recursive: true, force: true });
171
+ });
172
+
173
+ test('whoami with config returns kb and geneXus details', () => {
174
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-test-'));
175
+ const kbDir = path.join(tempRoot, 'kb-w');
176
+ fs.mkdirSync(kbDir, { recursive: true });
177
+
178
+ runCli(['init', '--kb', kbDir, '--gx', 'C:\\Program Files (x86)\\GeneXus\\GeneXus18', '--no-smoke', '--format', 'json']);
179
+
180
+ const res = runCli(['whoami', '--format', 'json'], { cwd: kbDir });
181
+ assert.equal(res.status, 0);
182
+ const parsed = JSON.parse(res.stdout);
183
+ assert.equal(parsed.ok.connected, true);
184
+ assert.equal(parsed.ok.kb.path, kbDir);
185
+ assert.equal(parsed.ok.kb.name, path.basename(kbDir));
186
+ assert.equal(parsed.ok.geneXus.installationPath, 'C:\\Program Files (x86)\\GeneXus\\GeneXus18');
187
+ assert.equal(parsed.meta.command, 'whoami');
188
+
189
+ fs.rmSync(tempRoot, { recursive: true, force: true });
190
+ });
191
+
192
+ test('uninstall --yes removes local config and reports plan', () => {
193
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-test-'));
194
+ const kbDir = path.join(tempRoot, 'kb-u');
195
+ fs.mkdirSync(kbDir, { recursive: true });
196
+
197
+ runCli(['init', '--kb', kbDir, '--gx', 'C:\\Program Files (x86)\\GeneXus\\GeneXus18', '--no-smoke', '--format', 'json']);
198
+
199
+ const cfgPath = path.join(kbDir, 'config.json');
200
+ assert.equal(fs.existsSync(cfgPath), true, 'precondition: config.json exists');
201
+
202
+ const res = runCli(['uninstall', '--yes', '--format', 'json'], { cwd: kbDir });
203
+ assert.equal(res.status, 0);
204
+ const parsed = JSON.parse(res.stdout);
205
+ assert.equal(parsed.ok.action, 'uninstall');
206
+ assert.equal(parsed.ok.cancelled, false);
207
+ assert.equal(parsed.ok.configRemoved, true);
208
+ assert.equal(fs.existsSync(cfgPath), false, 'config.json should be deleted');
209
+
210
+ fs.rmSync(tempRoot, { recursive: true, force: true });
211
+ });
212
+
213
+ test('uninstall --help returns usage entry', () => {
214
+ const res = runCli(['uninstall', '--help', '--format', 'json']);
215
+ assert.equal(res.status, 0);
216
+ const parsed = JSON.parse(res.stdout);
217
+ assert.equal(parsed.ok.command, 'uninstall');
218
+ assert.ok(parsed.ok.usage.includes('--yes'), 'usage should mention --yes flag');
219
+ });
220
+
221
+ test('whoami --help returns usage entry', () => {
222
+ const res = runCli(['whoami', '--help', '--format', 'json']);
223
+ assert.equal(res.status, 0);
224
+ const parsed = JSON.parse(res.stdout);
225
+ assert.equal(parsed.ok.command, 'whoami');
226
+ });
227
+
228
+ test('init auto-discovers KB from cwd when --kb is omitted', () => {
229
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-test-'));
230
+ const kbDir = path.join(tempRoot, 'kb-disco');
231
+ fs.mkdirSync(kbDir, { recursive: true });
232
+ fs.writeFileSync(path.join(kbDir, 'KnowledgeBase.Connection'), '');
233
+
234
+ const res = runCli(
235
+ ['init', '--gx', 'C:\\Program Files (x86)\\GeneXus\\GeneXus18', '--no-smoke', '--format', 'json'],
236
+ { cwd: kbDir }
237
+ );
238
+
239
+ assert.equal(res.status, 0);
240
+ const parsed = JSON.parse(res.stdout);
241
+ assert.equal(parsed.ok.resolved.kb.path, kbDir);
242
+ assert.equal(parsed.ok.resolved.kb.source, 'cwd');
243
+ assert.equal(parsed.ok.resolved.gx.source, 'flag');
244
+
245
+ fs.rmSync(tempRoot, { recursive: true, force: true });
246
+ });
247
+
248
+ test('init fails clearly when paths cannot be auto-discovered', () => {
249
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-test-'));
250
+
251
+ const res = runCli(
252
+ ['init', '--gx', 'C:\\Program Files (x86)\\GeneXus\\GeneXus18', '--no-smoke', '--format', 'json'],
253
+ { cwd: tempRoot }
254
+ );
255
+
256
+ assert.equal(res.status, 2);
257
+ const parsed = JSON.parse(res.stdout);
258
+ assert.equal(parsed.error.code, 'usage_error');
259
+ assert.ok(parsed.error.message.includes('--kb'), 'error should mention --kb');
260
+
261
+ fs.rmSync(tempRoot, { recursive: true, force: true });
262
+ });
263
+
264
+ test('kb list shows the KB auto-registered by init', () => {
265
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-test-'));
266
+ const kbDir = path.join(tempRoot, 'kb-list');
267
+ fs.mkdirSync(kbDir, { recursive: true });
268
+
269
+ runCli(['init', '--kb', kbDir, '--gx', 'C:\\Program Files (x86)\\GeneXus\\GeneXus18', '--no-smoke', '--format', 'json']);
270
+
271
+ const res = runCli(['kb', 'list', '--format', 'json'], { cwd: kbDir });
272
+ assert.equal(res.status, 0);
273
+ const parsed = JSON.parse(res.stdout);
274
+ assert.equal(parsed.meta.command, 'kb.list');
275
+ assert.equal(parsed.ok.activeKb, path.basename(kbDir));
276
+ assert.equal(parsed.ok.kbs.length, 1);
277
+ assert.equal(parsed.ok.kbs[0].active, true);
278
+ assert.equal(parsed.ok.kbs[0].path, kbDir);
279
+
280
+ fs.rmSync(tempRoot, { recursive: true, force: true });
281
+ });
282
+
283
+ test('kb add and switch update active KB', () => {
284
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-test-'));
285
+ const kbA = path.join(tempRoot, 'kb-a');
286
+ const kbB = path.join(tempRoot, 'kb-b');
287
+ fs.mkdirSync(kbA, { recursive: true });
288
+ fs.mkdirSync(kbB, { recursive: true });
289
+
290
+ runCli(['init', '--kb', kbA, '--gx', 'C:\\Program Files (x86)\\GeneXus\\GeneXus18', '--no-smoke', '--format', 'json']);
291
+
292
+ const addRes = runCli(['kb', 'add', '--name', 'bravo', '--kb', kbB, '--format', 'json'], { cwd: kbA });
293
+ assert.equal(addRes.status, 0);
294
+ const addParsed = JSON.parse(addRes.stdout);
295
+ assert.equal(addParsed.ok.registeredCount, 2);
296
+ assert.equal(addParsed.ok.activeKb, path.basename(kbA), 'active KB should remain the first one');
297
+
298
+ const switchRes = runCli(['kb', 'switch', '--name', 'bravo', '--format', 'json'], { cwd: kbA });
299
+ assert.equal(switchRes.status, 0);
300
+ const switchParsed = JSON.parse(switchRes.stdout);
301
+ assert.equal(switchParsed.ok.activeKb, 'bravo');
302
+ assert.equal(switchParsed.ok.kbPath, kbB);
303
+
304
+ const cfg = JSON.parse(fs.readFileSync(path.join(kbA, 'config.json'), 'utf8'));
305
+ assert.equal(cfg.Environment.KBPath, kbB, 'legacy KBPath should be updated');
306
+ assert.equal(cfg.Environment.ActiveKb, 'bravo');
307
+
308
+ fs.rmSync(tempRoot, { recursive: true, force: true });
309
+ });
310
+
311
+ test('kb switch rejects unknown name', () => {
312
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-test-'));
313
+ const kbDir = path.join(tempRoot, 'kb-x');
314
+ fs.mkdirSync(kbDir, { recursive: true });
315
+
316
+ runCli(['init', '--kb', kbDir, '--gx', 'C:\\Program Files (x86)\\GeneXus\\GeneXus18', '--no-smoke', '--format', 'json']);
317
+
318
+ const res = runCli(['kb', 'switch', '--name', 'nonexistent', '--format', 'json'], { cwd: kbDir });
319
+ assert.equal(res.status, 2);
320
+ const parsed = JSON.parse(res.stdout);
321
+ assert.equal(parsed.error.code, 'usage_error');
322
+ assert.ok(parsed.error.message.includes('nonexistent'));
323
+
324
+ fs.rmSync(tempRoot, { recursive: true, force: true });
325
+ });
326
+
327
+ test('kb remove deletes entry and reassigns active when applicable', () => {
328
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-test-'));
329
+ const kbA = path.join(tempRoot, 'kb-r-a');
330
+ const kbB = path.join(tempRoot, 'kb-r-b');
331
+ fs.mkdirSync(kbA, { recursive: true });
332
+ fs.mkdirSync(kbB, { recursive: true });
333
+
334
+ runCli(['init', '--kb', kbA, '--gx', 'C:\\Program Files (x86)\\GeneXus\\GeneXus18', '--no-smoke', '--format', 'json']);
335
+ runCli(['kb', 'add', '--name', 'second', '--kb', kbB, '--format', 'json'], { cwd: kbA });
336
+
337
+ const removeRes = runCli(['kb', 'remove', '--name', path.basename(kbA), '--format', 'json'], { cwd: kbA });
338
+ assert.equal(removeRes.status, 0);
339
+ const parsed = JSON.parse(removeRes.stdout);
340
+ assert.equal(parsed.ok.removed, true);
341
+ assert.equal(parsed.ok.activeKb, 'second', 'active should fall back to remaining KB');
342
+
343
+ fs.rmSync(tempRoot, { recursive: true, force: true });
344
+ });
345
+
346
+ test('kb switch --kb refuses to overwrite existing entry with different path', () => {
347
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-test-'));
348
+ const kbA = path.join(tempRoot, 'a', 'Sales');
349
+ const kbB = path.join(tempRoot, 'b', 'Sales');
350
+ fs.mkdirSync(kbA, { recursive: true });
351
+ fs.mkdirSync(kbB, { recursive: true });
352
+
353
+ runCli(['init', '--kb', kbA, '--gx', 'C:\\Program Files (x86)\\GeneXus\\GeneXus18', '--no-smoke', '--format', 'json']);
354
+
355
+ const res = runCli(['kb', 'switch', '--kb', kbB, '--format', 'json'], { cwd: kbA });
356
+ assert.equal(res.status, 2);
357
+ const parsed = JSON.parse(res.stdout);
358
+ assert.ok(/already registered/i.test(parsed.error.message), 'should warn about basename collision');
359
+
360
+ const cfg = JSON.parse(fs.readFileSync(path.join(kbA, 'config.json'), 'utf8'));
361
+ assert.equal(cfg.Environment.KBs.Sales, kbA, 'original entry must be preserved');
362
+
363
+ fs.rmSync(tempRoot, { recursive: true, force: true });
364
+ });
365
+
366
+ test('kb remove of last KB clears legacy KBPath', () => {
367
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-test-'));
368
+ const kbDir = path.join(tempRoot, 'kb-last');
369
+ fs.mkdirSync(kbDir, { recursive: true });
370
+
371
+ runCli(['init', '--kb', kbDir, '--gx', 'C:\\Program Files (x86)\\GeneXus\\GeneXus18', '--no-smoke', '--format', 'json']);
372
+
373
+ runCli(['kb', 'remove', '--name', path.basename(kbDir), '--format', 'json'], { cwd: kbDir });
374
+
375
+ const cfg = JSON.parse(fs.readFileSync(path.join(kbDir, 'config.json'), 'utf8'));
376
+ assert.equal(cfg.Environment.KBPath, undefined, 'KBPath should be cleared after removing last KB');
377
+ assert.equal(cfg.Environment.ActiveKb, undefined, 'ActiveKb should be cleared');
378
+
379
+ fs.rmSync(tempRoot, { recursive: true, force: true });
380
+ });
381
+
382
+ test('kb subcommand validation: missing subcommand returns usage error', () => {
383
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-test-'));
384
+ const kbDir = path.join(tempRoot, 'kb-v');
385
+ fs.mkdirSync(kbDir, { recursive: true });
386
+
387
+ runCli(['init', '--kb', kbDir, '--gx', 'C:\\Program Files (x86)\\GeneXus\\GeneXus18', '--no-smoke', '--format', 'json']);
388
+
389
+ const res = runCli(['kb', '--format', 'json'], { cwd: kbDir });
390
+ assert.equal(res.status, 2);
391
+ const parsed = JSON.parse(res.stdout);
392
+ assert.equal(parsed.error.code, 'usage_error');
393
+
394
+ fs.rmSync(tempRoot, { recursive: true, force: true });
395
+ });
396
+
397
+ test('tool_definitions.json is valid and disambiguation tools have use-when guidance', () => {
398
+ const defsPath = path.join(__dirname, '..', 'src', 'GxMcp.Gateway', 'tool_definitions.json');
399
+ const defs = JSON.parse(fs.readFileSync(defsPath, 'utf8'));
400
+ assert.ok(Array.isArray(defs) && defs.length > 0, 'tool defs should be a non-empty array');
401
+
402
+ const byName = Object.fromEntries(defs.map((t) => [t.name, t]));
403
+ const disambiguationTools = ['genexus_inspect', 'genexus_analyze', 'genexus_summarize', 'genexus_doc'];
404
+ for (const name of disambiguationTools) {
405
+ assert.ok(byName[name], `${name} should exist`);
406
+ const desc = byName[name].description || '';
407
+ assert.ok(
408
+ /use when|don't use|use this|use to/i.test(desc),
409
+ `${name} description should include use-when/don't-use guidance`
410
+ );
411
+ }
412
+ });
413
+
158
414
  test('tools list supports query and category aggregate', () => {
159
415
  const result = runCli([
160
416
  'tools',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genexus-mcp",
3
- "version": "2.0.4",
3
+ "version": "2.1.0",
4
4
  "mcpName": "io.github.lennix1337/genexus",
5
5
  "description": "GeneXus 18 MCP server — read, edit, and analyze GeneXus knowledge base objects (transactions, web panels, procedures, SDTs) directly from Claude, Cursor, and other AI agents over the Model Context Protocol.",
6
6
  "keywords": [