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/README.md +155 -132
- package/cli/commands/axi.js +423 -32
- package/cli/index.js +48 -1
- package/cli/lib/config.js +219 -18
- package/cli/run.test.js +256 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
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
|
|
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
|
|
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": [
|