genexus-mcp 2.8.2 → 2.8.3

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
@@ -175,13 +175,22 @@ Auto-detected and auto-configured by the installer:
175
175
  | Claude Desktop | ✅ | Restart required after install |
176
176
  | Claude Code (CLI) | ✅ | Reload session |
177
177
  | Cursor | ✅ | Restart required |
178
- | Antigravity | ✅ | Restart required |
178
+ | Antigravity | ✅ | Restart required; detected even before its MCP config exists |
179
+ | Gemini CLI | ✅ | — |
180
+ | OpenCode (CLI) | ✅ | Reads `opencode.json` / `opencode.jsonc` |
181
+ | Codex CLI | ✅ | Writes `~/.codex/config.toml` |
182
+ | VS Code / VS Code Insiders | ✅ | Native MCP (`User/mcp.json`); restart required |
183
+ | OpenCode Desktop | Detect-only | Reported as installed; add the server from the app's settings |
179
184
  | Any MCP client | Manual | Use the JSON snippet printed by `init` |
180
185
 
186
+ Run **`npx genexus-mcp clients`** at any time to see which agents are installed, which have `genexus` registered, and whether any point at a stale gateway exe. To (re)register specific ones: `npx genexus-mcp clients add --clients antigravity,vscode`.
187
+
181
188
  ---
182
189
 
183
190
  ## Troubleshooting
184
191
 
192
+ First stop for any "the agent doesn't see GeneXus" problem: **`npx genexus-mcp clients`** (is it registered? does it point at a gateway exe that still exists?) and **`npx genexus-mcp doctor --mcp-smoke`**.
193
+
185
194
  Most install issues fall into a handful of buckets — see **[TROUBLESHOOTING.md](TROUBLESHOOTING.md)** for fixes:
186
195
 
187
196
  - Installer can't find GeneXus or the KB
@@ -375,12 +384,17 @@ This repo ships a set of **agent skills** under `.gemini/skills/` that any MCP-c
375
384
 
376
385
  Third-party skills are Apache 2.0 (see [`.gemini/skills/NOTICE.md`](.gemini/skills/NOTICE.md)). To refresh against upstream, follow the steps in `NOTICE.md`.
377
386
 
378
- ### Nexus-IDE (VS Code extension)
387
+ ### Nexus-IDE (VS Code extension — optional, not auto-installed)
388
+
389
+ `src/nexus-ide` is a lightweight, experimental VS Code extension in the repo. The installer **no longer packages or installs it** — VS Code is wired up as a native MCP client instead (see [Supported AI clients](#supported-ai-clients)). If you want the extension, build and install it manually:
390
+
391
+ ```powershell
392
+ cd src/nexus-ide; npm ci; npm run compile
393
+ npx --yes @vscode/vsce package --out nexus-ide.vsix
394
+ code --install-extension nexus-ide.vsix --force
395
+ ```
379
396
 
380
- `src/nexus-ide` is a lightweight VS Code extension that ships with the repo:
381
- - Virtual filesystem using the `genexus://` scheme
382
- - Dynamic KB explorer with multi-part editing (Source, Rules, Events, Variables)
383
- - Built-in MCP discovery commands (tools, resources, prompts)
397
+ It provides a virtual filesystem (`genexus://` scheme), a KB explorer with multi-part editing, and MCP discovery commands.
384
398
 
385
399
  ### Automated release
386
400
 
@@ -12,6 +12,8 @@ const {
12
12
  patchClientConfig,
13
13
  unpatchClientConfig,
14
14
  getClientConfigTargets,
15
+ detectClientInstalled,
16
+ clientsStatus,
15
17
  filterClientTargets,
16
18
  listSupportedClientIds,
17
19
  getLocalAppDataCacheDir,
@@ -701,6 +703,25 @@ async function handleDoctor(options, ctx) {
701
703
  const inProcessLoad = buildInProcessBuildAssemblyLoadCheck(gxPath);
702
704
  checks.push({ id: 'in_process_build_assembly_load', status: inProcessLoad.status, detail: inProcessLoad.detail });
703
705
 
706
+ // Client registration summary — one line answering "are my AI agents wired up?".
707
+ const clientRows = clientsStatus();
708
+ const installedRows = clientRows.filter((r) => r.installed);
709
+ const staleRows = clientRows.filter((r) => r.commandStale);
710
+ const installedUnregistered = installedRows.filter((r) => !r.registered && r.writeSupported);
711
+ let clientsStatusLevel = 'pass';
712
+ let clientsDetail;
713
+ if (staleRows.length > 0) {
714
+ clientsStatusLevel = 'warn';
715
+ clientsDetail = `${staleRows.map((r) => r.name).join(', ')} point at a missing gateway exe. Re-register: genexus-mcp clients add --clients ${staleRows.map((r) => r.id).join(',')}.`;
716
+ } else if (installedUnregistered.length > 0) {
717
+ clientsStatusLevel = 'warn';
718
+ clientsDetail = `${installedUnregistered.length} installed agent(s) not registered (${installedUnregistered.map((r) => r.name).join(', ')}). Run: genexus-mcp clients add --clients ${installedUnregistered.map((r) => r.id).join(',')}.`;
719
+ } else {
720
+ const reg = clientRows.filter((r) => r.registered).length;
721
+ clientsDetail = `${reg} agent(s) registered; ${installedRows.length} installed. Run \`genexus-mcp clients\` for the full table.`;
722
+ }
723
+ checks.push({ id: 'clients_registered', status: clientsStatusLevel, detail: clientsDetail });
724
+
704
725
  if (data.gatewayExeFound) {
705
726
  const probe = await probeGatewaySpawn();
706
727
  checks.push({ id: 'gateway_spawn_probe', status: probe.status, detail: probe.detail });
@@ -1191,10 +1212,16 @@ async function runInteractiveInit(ctx) {
1191
1212
  ctx.stderr.write('\n3) Select AI agents to register (y/N per agent; Enter accepts default):\n');
1192
1213
  const selectedIds = [];
1193
1214
  for (const target of platformTargets) {
1194
- const installed = fs.existsSync(target.path);
1195
- const defaultYes = installed;
1196
- const tag = installed ? 'detected' : 'not detected';
1197
- const prompt = ` - ${target.name} [${tag}] (${defaultYes ? 'Y/n' : 'y/N'}): `;
1215
+ const detection = detectClientInstalled(target);
1216
+ const defaultYes = detection.installed;
1217
+ const tag = detection.installed ? 'detected' : 'not detected';
1218
+ // When not detected, show where we looked so the user understands why
1219
+ // (and can still type `y` to register a freshly-installed agent).
1220
+ let hint = '';
1221
+ if (!detection.installed && detection.markersChecked.length) {
1222
+ hint = ` — looked in ${detection.markersChecked[0]}`;
1223
+ }
1224
+ const prompt = ` - ${target.name} [${tag}${hint}] (${defaultYes ? 'Y/n' : 'y/N'}): `;
1198
1225
  const ans = (await question(prompt)).trim().toLowerCase();
1199
1226
  const yes = ans === '' ? defaultYes : (ans === 'y' || ans === 'yes');
1200
1227
  if (yes) selectedIds.push(target.id);
@@ -1715,6 +1742,107 @@ async function handleUninstall(options, ctx) {
1715
1742
  };
1716
1743
  }
1717
1744
 
1745
+ async function handleClients(subcommand, options, ctx) {
1746
+ const sub = subcommand || 'list';
1747
+
1748
+ if (sub === 'list') {
1749
+ const rows = clientsStatus();
1750
+ const installedCount = rows.filter((r) => r.installed).length;
1751
+ const registeredCount = rows.filter((r) => r.registered).length;
1752
+ const help = [];
1753
+ const installedUnregistered = rows.filter((r) => r.installed && !r.registered && r.writeSupported);
1754
+ if (installedUnregistered.length > 0) {
1755
+ help.push(`Register installed-but-unregistered agents: genexus-mcp clients add --clients ${installedUnregistered.map((r) => r.id).join(',')}`);
1756
+ }
1757
+ const stale = rows.filter((r) => r.commandStale);
1758
+ if (stale.length > 0) {
1759
+ help.push(`These clients point at a missing gateway exe (will fail to connect) — re-register: genexus-mcp clients add --clients ${stale.map((r) => r.id).join(',')}`);
1760
+ }
1761
+ for (const r of rows) {
1762
+ if (r.installed && !r.writeSupported && r.note) help.push(`${r.name}: ${r.note}`);
1763
+ }
1764
+ return {
1765
+ exitCode: ctx.EXIT_CODES.OK,
1766
+ envelope: {
1767
+ ok: {
1768
+ clients: rows,
1769
+ summary: { total: rows.length, installed: installedCount, registered: registeredCount }
1770
+ },
1771
+ help
1772
+ }
1773
+ };
1774
+ }
1775
+
1776
+ if (sub === 'add' || sub === 'remove') {
1777
+ const ids = resolveClientIds(options);
1778
+ if (sub === 'add' && (!ids || ids.length === 0)) {
1779
+ return {
1780
+ exitCode: ctx.EXIT_CODES.USAGE,
1781
+ envelope: usageEnvelope('`clients add` requires --clients <csv> (e.g. --clients antigravity,vscode).', ctx.EXIT_CODES.USAGE)
1782
+ };
1783
+ }
1784
+ const validation = validateClientIds(ids);
1785
+ if (!validation.ok) {
1786
+ return { exitCode: ctx.EXIT_CODES.USAGE, envelope: usageEnvelope(validation.message, ctx.EXIT_CODES.USAGE) };
1787
+ }
1788
+
1789
+ if (sub === 'add') {
1790
+ const configPath = resolveConfigPathNoMutate(ctx.cwd);
1791
+ if (!configPath) {
1792
+ return {
1793
+ exitCode: ctx.EXIT_CODES.ERROR,
1794
+ envelope: operationalErrorEnvelope(
1795
+ 'No config.json found to point the clients at. Run `genexus-mcp init` first (or run from a KB folder).',
1796
+ ctx.EXIT_CODES.ERROR
1797
+ )
1798
+ };
1799
+ }
1800
+ let patch;
1801
+ try {
1802
+ // Explicit add: write even if install markers are absent (the user asked for it).
1803
+ patch = patchClientConfig(configPath, { ids, onlyExisting: false });
1804
+ } catch (err) {
1805
+ return {
1806
+ exitCode: ctx.EXIT_CODES.ERROR,
1807
+ envelope: operationalErrorEnvelope(
1808
+ sanitizeOperationalMessage(`Client registration failed: ${err && err.message ? err.message : 'unknown error'}`),
1809
+ ctx.EXIT_CODES.ERROR
1810
+ )
1811
+ };
1812
+ }
1813
+ const help = [];
1814
+ if (patch.patched.length > 0) help.push('Restart the affected AI client(s) to load the new MCP config.');
1815
+ if (patch.failed.length > 0) help.push('Some clients failed (see meta.failedClients).');
1816
+ return {
1817
+ exitCode: ctx.EXIT_CODES.OK,
1818
+ envelope: {
1819
+ ok: { action: 'clients.add', configPath, patchedClients: patch.patched, patchedCount: patch.patched.length },
1820
+ help,
1821
+ meta: { failedClients: patch.failed, skippedClients: patch.skipped }
1822
+ }
1823
+ };
1824
+ }
1825
+
1826
+ // remove
1827
+ const unpatch = unpatchClientConfig(ids ? { ids } : {});
1828
+ const help = [];
1829
+ if (unpatch.removed.length > 0) help.push('Restart the affected AI client(s) to drop the stale MCP connection.');
1830
+ return {
1831
+ exitCode: ctx.EXIT_CODES.OK,
1832
+ envelope: {
1833
+ ok: { action: 'clients.remove', removedClients: unpatch.removed, removedCount: unpatch.removed.length },
1834
+ help,
1835
+ meta: { skippedClients: unpatch.skipped, failedClients: unpatch.failed }
1836
+ }
1837
+ };
1838
+ }
1839
+
1840
+ return {
1841
+ exitCode: ctx.EXIT_CODES.USAGE,
1842
+ envelope: usageEnvelope('clients supports subcommands `list`, `add`, `remove`.', ctx.EXIT_CODES.USAGE)
1843
+ };
1844
+ }
1845
+
1718
1846
  async function handleKb(subcommand, options, ctx) {
1719
1847
  const data = buildStatusData(ctx.cwd);
1720
1848
  if (!data.configPath) {
@@ -1900,6 +2028,15 @@ function commandHelpMap() {
1900
2028
  'genexus-mcp kb remove --name sales'
1901
2029
  ]
1902
2030
  },
2031
+ clients: {
2032
+ usage: 'genexus-mcp clients [list] [--format ...] OR genexus-mcp clients add --clients <csv> OR genexus-mcp clients remove [--clients <csv>]',
2033
+ examples: [
2034
+ 'genexus-mcp clients # show every AI agent: installed? registered? where?',
2035
+ 'genexus-mcp clients --format json',
2036
+ 'genexus-mcp clients add --clients antigravity,vscode',
2037
+ 'genexus-mcp clients remove --clients cursor'
2038
+ ]
2039
+ },
1903
2040
  llm: {
1904
2041
  usage: 'genexus-mcp llm help [--full] [--fields f1,f2] [--format toon|json|text]',
1905
2042
  examples: ['genexus-mcp llm help --format json', 'genexus-mcp llm help --full --format json']
@@ -1935,8 +2072,8 @@ async function handleHome(_options, ctx) {
1935
2072
  description: 'GeneXus MCP launcher and AXI-oriented utility CLI',
1936
2073
  ready: data.ready,
1937
2074
  next: data.ready
1938
- ? ['genexus-mcp status', 'genexus-mcp doctor --mcp-smoke', 'genexus-mcp tools list --limit 10', 'genexus-mcp layout status', 'genexus-mcp layout inspect --tab Layout']
1939
- : ['genexus-mcp status', 'genexus-mcp doctor --full', 'genexus-mcp init --kb "<kbPath>" --gx "<geneXusPath>"']
2075
+ ? ['genexus-mcp status', 'genexus-mcp clients', 'genexus-mcp doctor --mcp-smoke', 'genexus-mcp tools list --limit 10', 'genexus-mcp layout status']
2076
+ : ['genexus-mcp status', 'genexus-mcp clients', 'genexus-mcp doctor --full', 'genexus-mcp init --kb "<kbPath>" --gx "<geneXusPath>"']
1940
2077
  },
1941
2078
  help: []
1942
2079
  }
@@ -2078,6 +2215,7 @@ module.exports = {
2078
2215
  handleWhoami,
2079
2216
  handleUninstall,
2080
2217
  handleKb,
2218
+ handleClients,
2081
2219
  handleHome,
2082
2220
  handleLlmHelp,
2083
2221
  handleLayout,
package/cli/index.js CHANGED
@@ -19,6 +19,7 @@ const {
19
19
  handleWhoami,
20
20
  handleUninstall,
21
21
  handleKb,
22
+ handleClients,
22
23
  handleHome,
23
24
  handleLlmHelp,
24
25
  handleLayout,
@@ -55,7 +56,7 @@ const GLOBAL_DEFAULTS = {
55
56
  help: false
56
57
  };
57
58
 
58
- const KNOWN_COMMANDS = new Set(['status', 'doctor', 'tools', 'config', 'init', 'setup', 'whoami', 'uninstall', 'kb', 'help', 'home', 'axi', 'llm', 'layout', 'update']);
59
+ const KNOWN_COMMANDS = new Set(['status', 'doctor', 'tools', 'config', 'init', 'setup', 'whoami', 'uninstall', 'kb', 'clients', 'help', 'home', 'axi', 'llm', 'layout', 'update']);
59
60
 
60
61
  function parseArgs(argv) {
61
62
  const result = {
@@ -115,6 +116,11 @@ function parseArgs(argv) {
115
116
  tokens.shift();
116
117
  }
117
118
 
119
+ if (result.command === 'clients' && ['list', 'add', 'remove'].includes(tokens[0])) {
120
+ result.subcommand = tokens[0];
121
+ tokens.shift();
122
+ }
123
+
118
124
  if (result.command === 'layout' && (tokens[0] === 'status' || tokens[0] === 'run' || tokens[0] === 'inspect')) {
119
125
  result.subcommand = tokens[0];
120
126
  tokens.shift();
@@ -395,6 +401,9 @@ function resolveMetaCommand(parsed, targetHelp) {
395
401
  if (parsed.command === 'kb') {
396
402
  return parsed.subcommand ? `kb.${parsed.subcommand}` : 'kb';
397
403
  }
404
+ if (parsed.command === 'clients') {
405
+ return parsed.subcommand ? `clients.${parsed.subcommand}` : 'clients.list';
406
+ }
398
407
  if (parsed.command === 'update') return 'update';
399
408
  return parsed.command || 'unknown';
400
409
  }
@@ -523,6 +532,9 @@ async function main(argv) {
523
532
  }
524
533
  result = await handleKb(parsed.subcommand, parsed.options, ctx);
525
534
  break;
535
+ case 'clients':
536
+ result = await handleClients(parsed.subcommand, parsed.options, ctx);
537
+ break;
526
538
  case 'update':
527
539
  result = await handleUpdate(parsed.options, ctx);
528
540
  break;
package/cli/lib/config.js CHANGED
@@ -237,16 +237,132 @@ function directoryLooksLikeKnowledgeBase(dir) {
237
237
  }
238
238
  }
239
239
 
240
+ // Strip // and /* */ comments and trailing commas while respecting string
241
+ // literals, so we can parse JSONC configs (VS Code's mcp.json/settings.json and
242
+ // OpenCode's opencode.jsonc are JSONC). Comments are NOT preserved on rewrite.
243
+ //
244
+ // Trailing-comma removal is done INSIDE the scanner (a comma is deferred and only
245
+ // emitted once we know the next significant char isn't a closing brace/bracket),
246
+ // not by a post-hoc regex — a regex over the whole text would also strip commas
247
+ // that live inside string values (e.g. "see foo, ]" -> "see foo ]"). Only `"`
248
+ // opens a string: JSON/JSONC has no single-quoted strings.
249
+ function stripJsonComments(text) {
250
+ let out = '';
251
+ let inString = false;
252
+ let inLine = false;
253
+ let inBlock = false;
254
+ let pendingComma = false;
255
+ // Resolve a deferred comma: keep it unless the next significant char closes a
256
+ // container (then it was a trailing comma and gets dropped).
257
+ const flushComma = (nextSignificant) => {
258
+ if (pendingComma) {
259
+ if (nextSignificant !== '}' && nextSignificant !== ']') out += ',';
260
+ pendingComma = false;
261
+ }
262
+ };
263
+ for (let i = 0; i < text.length; i += 1) {
264
+ const ch = text[i];
265
+ const next = text[i + 1];
266
+ if (inLine) {
267
+ if (ch === '\n') inLine = false;
268
+ continue;
269
+ }
270
+ if (inBlock) {
271
+ if (ch === '*' && next === '/') { inBlock = false; i += 1; }
272
+ continue;
273
+ }
274
+ if (inString) {
275
+ out += ch;
276
+ if (ch === '\\') { out += next; i += 1; continue; }
277
+ if (ch === '"') inString = false;
278
+ continue;
279
+ }
280
+ // Outside any string/comment.
281
+ if (ch === '/' && next === '/') { inLine = true; i += 1; continue; }
282
+ if (ch === '/' && next === '*') { inBlock = true; i += 1; continue; }
283
+ if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
284
+ // Whitespace between a deferred comma and the next token is collapsed
285
+ // (JSON.parse ignores it); otherwise emit it verbatim.
286
+ if (!pendingComma) out += ch;
287
+ continue;
288
+ }
289
+ if (ch === ',') {
290
+ flushComma(','); // a prior comma followed by another comma is kept as-is
291
+ pendingComma = true; // defer this one until we see what follows
292
+ continue;
293
+ }
294
+ flushComma(ch);
295
+ out += ch;
296
+ if (ch === '"') inString = true;
297
+ }
298
+ flushComma('');
299
+ return out;
300
+ }
301
+
240
302
  function readJsonFileSafe(filePath) {
241
303
  try {
242
304
  const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
243
305
  if (!raw.trim()) return {};
244
- return JSON.parse(raw);
306
+ try {
307
+ return JSON.parse(raw);
308
+ } catch {
309
+ // Fall back to a JSONC-tolerant parse before giving up, so a commented
310
+ // VS Code / OpenCode config isn't treated as corrupt.
311
+ return JSON.parse(stripJsonComments(raw));
312
+ }
245
313
  } catch {
246
314
  return null;
247
315
  }
248
316
  }
249
317
 
318
+ // Atomic write: stage to a temp file then rename over the target, so a crash
319
+ // mid-write can never leave a client's config truncated.
320
+ function writeFileAtomic(filePath, content) {
321
+ const tmp = `${filePath}.tmp-${process.pid}`;
322
+ fs.writeFileSync(tmp, content);
323
+ try {
324
+ fs.renameSync(tmp, filePath);
325
+ } catch (err) {
326
+ try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
327
+ throw err;
328
+ }
329
+ }
330
+
331
+ // Back up a client config once per process run before the first mutation, so the
332
+ // user has a restore point (the old build-from-source install.ps1 did this; the
333
+ // CLI now owns it). Best-effort \u2014 a failed backup never blocks the write.
334
+ const _backedUpThisRun = new Set();
335
+ function backupClientConfigOnce(filePath) {
336
+ if (!fs.existsSync(filePath)) return null;
337
+ // Case-fold the dedupe key only on Windows; lowercasing on a case-sensitive
338
+ // filesystem could merge two genuinely distinct paths.
339
+ const resolved = path.resolve(filePath);
340
+ const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
341
+ if (_backedUpThisRun.has(key)) return null;
342
+ try {
343
+ const d = new Date();
344
+ const stamp = d.toISOString().replace(/[-:T]/g, '').slice(0, 14);
345
+ const bak = `${filePath}.${stamp}.bak`;
346
+ fs.copyFileSync(filePath, bak);
347
+ _backedUpThisRun.add(key);
348
+ return bak;
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+
354
+ // Write JSON to a client config: back up, serialize, write atomically.
355
+ function writeClientJson(filePath, obj) {
356
+ backupClientConfigOnce(filePath);
357
+ writeFileAtomic(filePath, JSON.stringify(obj, null, 2));
358
+ }
359
+
360
+ // Write raw text to a client config (e.g. Codex TOML): back up + write atomically.
361
+ function writeClientText(filePath, content) {
362
+ backupClientConfigOnce(filePath);
363
+ writeFileAtomic(filePath, content);
364
+ }
365
+
250
366
  function resolveConfigPathNoMutate(cwd) {
251
367
  const cwdConfigPath = path.join(cwd, 'config.json');
252
368
  if (process.env.GX_CONFIG_PATH && fs.existsSync(process.env.GX_CONFIG_PATH)) {
@@ -298,67 +414,241 @@ function getLauncher() {
298
414
  : { command: process.platform === 'win32' ? 'npx.cmd' : 'npx', args: ['-y', 'genexus-mcp@latest'] };
299
415
  }
300
416
 
417
+ // Antigravity (Google's agentic IDE) ships its MCP config under ~/.gemini.
418
+ // The newer unified location (shared across Antigravity CLI / IDE / SDK) is
419
+ // ~/.gemini/config/mcp_config.json; the older IDE-specific one is
420
+ // ~/.gemini/antigravity/mcp_config.json. We write to the unified path when its
421
+ // parent dir already exists, else fall back to the IDE-specific path.
422
+ function resolveAntigravityConfigPath(home) {
423
+ // Only target the unified location when its file already exists (the user has
424
+ // adopted it); otherwise write the IDE-specific path, which is the location
425
+ // Antigravity reliably reads and was confirmed working in the field.
426
+ const unified = path.join(home, '.gemini', 'config', 'mcp_config.json');
427
+ if (fs.existsSync(unified)) return unified;
428
+ return path.join(home, '.gemini', 'antigravity', 'mcp_config.json');
429
+ }
430
+
431
+ // OpenCode CLI accepts either opencode.jsonc or opencode.json. Prefer an
432
+ // existing .jsonc so we don't strand the user's commented config, else .json.
433
+ function resolveOpenCodeConfigPath(xdgConfig) {
434
+ const jsonc = path.join(xdgConfig, 'opencode', 'opencode.jsonc');
435
+ if (fs.existsSync(jsonc)) return jsonc;
436
+ return path.join(xdgConfig, 'opencode', 'opencode.json');
437
+ }
438
+
439
+ // VS Code stores its user profile (and native MCP mcp.json) in a per-platform
440
+ // location. `variant` is 'Code' (stable) or 'Code - Insiders'.
441
+ function vscodeUserDir(variant, { appData, macAppSupport, xdgConfig }) {
442
+ if (process.platform === 'win32') return path.join(appData, variant, 'User');
443
+ if (process.platform === 'darwin') return path.join(macAppSupport, variant, 'User');
444
+ return path.join(xdgConfig, variant, 'User');
445
+ }
446
+
301
447
  function getClientConfigTargets() {
302
448
  const home = os.homedir();
303
449
  const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
450
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
451
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
452
+ const macAppSupport = path.join(home, 'Library', 'Application Support');
453
+ const vscodeStableUser = vscodeUserDir('Code', { appData, macAppSupport, xdgConfig });
454
+ const vscodeInsidersUser = vscodeUserDir('Code - Insiders', { appData, macAppSupport, xdgConfig });
455
+
456
+ // `installMarkers` prove the AGENT is installed, independent of whether our
457
+ // MCP config file exists yet. This is the fix for the field report where the
458
+ // wizard showed Antigravity as "not detected": Antigravity does not create
459
+ // ~/.gemini/antigravity/mcp_config.json until the user adds an MCP server, so
460
+ // detecting by config-file presence alone was chicken-and-egg.
304
461
  return [
305
462
  {
306
463
  id: 'claude-desktop-win',
307
464
  name: 'Claude Desktop (Windows)',
308
465
  format: 'mcpServers',
309
466
  path: path.join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json'),
310
- platforms: ['win32']
467
+ platforms: ['win32'],
468
+ installMarkers: [
469
+ path.join(localAppData, 'AnthropicClaude'),
470
+ path.join(appData, 'Claude')
471
+ ]
311
472
  },
312
473
  {
313
474
  id: 'claude-desktop-mac',
314
475
  name: 'Claude Desktop (macOS)',
315
476
  format: 'mcpServers',
316
477
  path: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
317
- platforms: ['darwin']
478
+ platforms: ['darwin'],
479
+ installMarkers: [
480
+ path.join(macAppSupport, 'Claude'),
481
+ '/Applications/Claude.app'
482
+ ]
318
483
  },
319
484
  {
320
485
  id: 'antigravity',
321
486
  name: 'Antigravity',
322
487
  format: 'mcpServers',
323
- path: path.join(home, '.gemini', 'antigravity', 'mcp_config.json')
488
+ path: resolveAntigravityConfigPath(home),
489
+ // Unambiguous Antigravity markers only. ~/.gemini/config is NOT a
490
+ // marker — gemini-cli can create ~/.gemini, and we'd false-positive;
491
+ // it's still used as the write path (resolveAntigravityConfigPath)
492
+ // once a real Antigravity install is confirmed by these markers.
493
+ installMarkers: [
494
+ path.join(localAppData, 'Programs', 'Antigravity'),
495
+ path.join(appData, 'Antigravity'),
496
+ path.join(home, '.antigravity'),
497
+ path.join(home, '.gemini', 'antigravity')
498
+ ]
324
499
  },
325
500
  {
326
501
  id: 'claude-code',
327
502
  name: 'Claude Code',
328
503
  format: 'mcpServers',
329
- path: path.join(home, '.claude.json')
504
+ path: path.join(home, '.claude.json'),
505
+ installMarkers: [
506
+ path.join(home, '.claude.json'),
507
+ path.join(home, '.claude')
508
+ ]
330
509
  },
331
510
  {
332
511
  id: 'gemini-cli',
333
512
  name: 'Gemini CLI',
334
513
  format: 'mcpServers',
335
- path: path.join(home, '.gemini', 'settings.json')
514
+ path: path.join(home, '.gemini', 'settings.json'),
515
+ installMarkers: [
516
+ path.join(home, '.gemini', 'settings.json')
517
+ ]
336
518
  },
337
519
  {
338
520
  id: 'cursor',
339
521
  name: 'Cursor',
340
522
  format: 'mcpServers',
341
- path: path.join(home, '.cursor', 'mcp.json')
523
+ path: path.join(home, '.cursor', 'mcp.json'),
524
+ installMarkers: [
525
+ path.join(home, '.cursor'),
526
+ path.join(localAppData, 'Programs', 'cursor'),
527
+ '/Applications/Cursor.app'
528
+ ]
342
529
  },
343
530
  {
344
531
  id: 'opencode',
345
- name: 'OpenCode',
532
+ name: 'OpenCode (CLI)',
346
533
  format: 'opencode',
347
- path: path.join(xdgConfig, 'opencode', 'opencode.json')
534
+ path: resolveOpenCodeConfigPath(xdgConfig),
535
+ installMarkers: [
536
+ path.join(xdgConfig, 'opencode'),
537
+ path.join(home, '.local', 'share', 'opencode')
538
+ ]
348
539
  },
349
540
  {
350
541
  id: 'codex-cli',
351
542
  name: 'Codex CLI',
352
543
  format: 'codex-toml',
353
- path: path.join(home, '.codex', 'config.toml')
544
+ path: path.join(home, '.codex', 'config.toml'),
545
+ installMarkers: [
546
+ path.join(home, '.codex')
547
+ ]
548
+ },
549
+ {
550
+ id: 'opencode-desktop',
551
+ name: 'OpenCode Desktop',
552
+ // Detect-only: the Desktop app's MCP config schema differs from the CLI
553
+ // and isn't auto-written yet. We report it so the user knows it's there
554
+ // and how to wire it up, but never mutate its config blindly.
555
+ format: 'manual',
556
+ writeSupported: false,
557
+ manualNote: 'OpenCode Desktop: add the genexus MCP server from the app\'s settings (automatic registration not supported yet).',
558
+ path: path.join(appData, 'ai.opencode.desktop', 'mcp.json'),
559
+ installMarkers: [
560
+ path.join(localAppData, 'Programs', '@opencode-aidesktop'),
561
+ path.join(appData, 'ai.opencode.desktop'),
562
+ '/Applications/OpenCode.app'
563
+ ]
564
+ },
565
+ {
566
+ id: 'vscode',
567
+ name: 'VS Code',
568
+ format: 'vscode-servers',
569
+ path: path.join(vscodeStableUser, 'mcp.json'),
570
+ installMarkers: [vscodeStableUser]
571
+ },
572
+ {
573
+ id: 'vscode-insiders',
574
+ name: 'VS Code Insiders',
575
+ format: 'vscode-servers',
576
+ path: path.join(vscodeInsidersUser, 'mcp.json'),
577
+ installMarkers: [vscodeInsidersUser]
354
578
  }
355
579
  ];
356
580
  }
357
581
 
582
+ // Decide whether an agent is installed (independent of whether OUR config file
583
+ // exists). Returns the installed flag plus diagnostics so the wizard can show
584
+ // the user exactly where it looked when an agent is reported "not detected".
585
+ function detectClientInstalled(client) {
586
+ const markers = Array.isArray(client.installMarkers) ? client.installMarkers : [];
587
+ const hasConfig = fs.existsSync(client.path);
588
+ let markerHit = null;
589
+ for (const m of markers) {
590
+ if (fs.existsSync(m)) {
591
+ markerHit = m;
592
+ break;
593
+ }
594
+ }
595
+ return {
596
+ installed: hasConfig || markerHit !== null,
597
+ hasConfig,
598
+ markerHit,
599
+ markersChecked: markers
600
+ };
601
+ }
602
+
358
603
  function listSupportedClientIds() {
359
604
  return getClientConfigTargets().map((c) => c.id);
360
605
  }
361
606
 
607
+ // Judge whether a registered launcher command is healthy. npx/node/genexus-mcp
608
+ // shims resolve at runtime so we can't fault them; any other launcher referenced
609
+ // by an explicit path (a separator in the command) that no longer exists on disk
610
+ // is the classic "Failed to connect / still on old version" cause after an
611
+ // install dir moved or was cleaned — covers .exe, .bat, .cmd, .sh, extensionless.
612
+ function clientCommandHealth(entry) {
613
+ if (!entry || !entry.command) return { stale: false, reason: null };
614
+ const cmd = String(entry.command);
615
+ if (/(^|[\\/])(npx|npx\.cmd|node|node\.exe|genexus-mcp|genexus-mcp\.cmd)$/i.test(cmd)) {
616
+ return { stale: false, reason: null };
617
+ }
618
+ if (/[\\/]/.test(cmd) && !fs.existsSync(cmd)) {
619
+ return { stale: true, reason: 'configured launcher does not exist on disk' };
620
+ }
621
+ return { stale: false, reason: null };
622
+ }
623
+
624
+ // Read-only report of every supported agent on this platform: is it installed,
625
+ // is genexus registered, where, what launcher command it points at, and whether
626
+ // that command is stale. Backs the `genexus-mcp clients` command.
627
+ function clientsStatus(opts = {}) {
628
+ const targets = filterClientTargets(getClientConfigTargets(), {
629
+ ids: opts.ids,
630
+ platform: process.platform
631
+ });
632
+ return targets.map((client) => {
633
+ const det = detectClientInstalled(client);
634
+ const entry = readClientCommandEntry(client);
635
+ const health = clientCommandHealth(entry);
636
+ return {
637
+ id: client.id,
638
+ name: client.name,
639
+ installed: det.installed,
640
+ registered: entry !== null,
641
+ writeSupported: client.writeSupported !== false,
642
+ configPath: client.path,
643
+ command: entry && entry.command ? entry.command : null,
644
+ commandStale: health.stale,
645
+ commandStaleReason: health.reason,
646
+ detectedAt: det.markerHit || (det.hasConfig ? client.path : null),
647
+ note: client.writeSupported === false ? (client.manualNote || null) : null
648
+ };
649
+ });
650
+ }
651
+
362
652
  function filterClientTargets(targets, opts = {}) {
363
653
  const { ids, onlyExisting, platform } = opts;
364
654
  let out = targets;
@@ -396,13 +686,29 @@ function patchClientConfig(targetConfigPath, opts = {}) {
396
686
  const skipped = [];
397
687
 
398
688
  for (const client of candidates) {
399
- if (onlyExisting && !fs.existsSync(client.path)) {
689
+ // Detect-only agents (e.g. OpenCode Desktop) can't be auto-written; surface
690
+ // the manual step instead of pretending we registered them.
691
+ if (client.writeSupported === false) {
692
+ if (detectClientInstalled(client).installed) {
693
+ skipped.push({ client: client.name, reason: client.manualNote || 'manual setup required' });
694
+ }
695
+ continue;
696
+ }
697
+ // "Installed" keys off install markers (the agent itself is present), not
698
+ // just our config file — otherwise agents that don't pre-create their MCP
699
+ // config (e.g. Antigravity) are wrongly skipped as "not installed".
700
+ if (onlyExisting && !detectClientInstalled(client).installed) {
400
701
  skipped.push({ client: client.name, reason: 'not installed' });
401
702
  continue;
402
703
  }
403
704
  try {
404
705
  fs.mkdirSync(path.dirname(client.path), { recursive: true });
405
706
  applyClientEntry(client, launcher, targetConfigPath);
707
+ // Read-back: confirm the entry is actually present and the file still
708
+ // parses, so a silently-corrupted write is reported as a failure.
709
+ if (!readClientCommandEntry(client)) {
710
+ throw new Error('post-write verification failed (genexus entry not found after write)');
711
+ }
406
712
  patched.push(client.name);
407
713
  } catch (err) {
408
714
  failed.push({ client: client.name, reason: err && err.message ? err.message : 'Unknown error' });
@@ -423,6 +729,11 @@ function unpatchClientConfig(opts = {}) {
423
729
  const failed = [];
424
730
 
425
731
  for (const client of targets) {
732
+ // Detect-only agents were never written by us — nothing to remove.
733
+ if (client.writeSupported === false) {
734
+ skipped.push({ client: client.name, reason: 'manual setup (not managed by genexus-mcp)' });
735
+ continue;
736
+ }
426
737
  try {
427
738
  const wasRemoved = removeClientEntry(client);
428
739
  if (wasRemoved) removed.push(client.name);
@@ -443,6 +754,8 @@ function applyClientEntry(client, launcher, targetConfigPath) {
443
754
  return applyOpenCodeJson(client.path, launcher, targetConfigPath);
444
755
  case 'codex-toml':
445
756
  return applyCodexToml(client.path, launcher, targetConfigPath);
757
+ case 'vscode-servers':
758
+ return applyVsCodeServersJson(client.path, launcher, targetConfigPath);
446
759
  default:
447
760
  throw new Error(`Unknown client format: ${client.format}`);
448
761
  }
@@ -456,6 +769,8 @@ function removeClientEntry(client) {
456
769
  return removeOpenCodeJson(client.path);
457
770
  case 'codex-toml':
458
771
  return removeCodexToml(client.path);
772
+ case 'vscode-servers':
773
+ return removeVsCodeServersJson(client.path);
459
774
  default:
460
775
  throw new Error(`Unknown client format: ${client.format}`);
461
776
  }
@@ -467,16 +782,63 @@ function applyMcpServersJson(filePath, launcher, targetConfigPath) {
467
782
  const cfgObj = parsed || {};
468
783
  cfgObj.mcpServers = cfgObj.mcpServers || {};
469
784
  cfgObj.mcpServers.genexus = { ...launcher, env: { GX_CONFIG_PATH: targetConfigPath } };
470
- fs.writeFileSync(filePath, JSON.stringify(cfgObj, null, 2));
785
+ // Drop the legacy `genexus18` key from older build-from-source installs so the
786
+ // user isn't left with two duplicate servers (and colliding tool names).
787
+ if (cfgObj.mcpServers.genexus18) delete cfgObj.mcpServers.genexus18;
788
+ writeClientJson(filePath, cfgObj);
471
789
  }
472
790
 
473
791
  function removeMcpServersJson(filePath) {
474
792
  const parsed = readJsonFileSafe(filePath);
475
793
  if (parsed === null) throw new Error('Invalid JSON');
476
794
  const cfgObj = parsed || {};
477
- if (!cfgObj.mcpServers || !cfgObj.mcpServers.genexus) return false;
478
- delete cfgObj.mcpServers.genexus;
479
- fs.writeFileSync(filePath, JSON.stringify(cfgObj, null, 2));
795
+ if (!cfgObj.mcpServers) return false;
796
+ // Remove the current key plus the legacy `genexus18` key written by older
797
+ // versions of the build-from-source install.ps1, so uninstall fully cleans up
798
+ // regardless of which installer wrote the entry.
799
+ let removedAny = false;
800
+ for (const key of ['genexus', 'genexus18']) {
801
+ if (cfgObj.mcpServers[key]) {
802
+ delete cfgObj.mcpServers[key];
803
+ removedAny = true;
804
+ }
805
+ }
806
+ if (!removedAny) return false;
807
+ writeClientJson(filePath, cfgObj);
808
+ return true;
809
+ }
810
+
811
+ // VS Code native MCP lives in User\mcp.json and uses a top-level `servers` map
812
+ // with `type: "stdio"` (distinct from the `mcpServers` shape Claude/Cursor use).
813
+ function applyVsCodeServersJson(filePath, launcher, targetConfigPath) {
814
+ const parsed = fs.existsSync(filePath) ? readJsonFileSafe(filePath) : {};
815
+ if (parsed === null) throw new Error('Invalid JSON');
816
+ const cfgObj = parsed || {};
817
+ cfgObj.servers = cfgObj.servers || {};
818
+ cfgObj.servers.genexus = {
819
+ type: 'stdio',
820
+ ...launcher,
821
+ env: { GX_CONFIG_PATH: targetConfigPath }
822
+ };
823
+ // Drop the legacy `genexus18` key written by older build-from-source installs.
824
+ if (cfgObj.servers.genexus18) delete cfgObj.servers.genexus18;
825
+ writeClientJson(filePath, cfgObj);
826
+ }
827
+
828
+ function removeVsCodeServersJson(filePath) {
829
+ const parsed = readJsonFileSafe(filePath);
830
+ if (parsed === null) throw new Error('Invalid JSON');
831
+ const cfgObj = parsed || {};
832
+ if (!cfgObj.servers) return false;
833
+ let removedAny = false;
834
+ for (const key of ['genexus', 'genexus18']) {
835
+ if (cfgObj.servers[key]) {
836
+ delete cfgObj.servers[key];
837
+ removedAny = true;
838
+ }
839
+ }
840
+ if (!removedAny) return false;
841
+ writeClientJson(filePath, cfgObj);
480
842
  return true;
481
843
  }
482
844
 
@@ -485,6 +847,9 @@ function applyOpenCodeJson(filePath, launcher, targetConfigPath) {
485
847
  const parsed = fs.existsSync(filePath) ? readJsonFileSafe(filePath) : {};
486
848
  if (parsed === null) throw new Error('Invalid JSON');
487
849
  const cfgObj = parsed || {};
850
+ // OpenCode configs carry a top-level $schema for editor validation; set it when
851
+ // absent (new file or a config that never had one) without clobbering a custom one.
852
+ if (!cfgObj.$schema) cfgObj.$schema = 'https://opencode.ai/config.json';
488
853
  cfgObj.mcp = cfgObj.mcp || {};
489
854
  cfgObj.mcp.genexus = {
490
855
  type: 'local',
@@ -492,7 +857,8 @@ function applyOpenCodeJson(filePath, launcher, targetConfigPath) {
492
857
  environment: { GX_CONFIG_PATH: targetConfigPath },
493
858
  enabled: true
494
859
  };
495
- fs.writeFileSync(filePath, JSON.stringify(cfgObj, null, 2));
860
+ if (cfgObj.mcp.genexus18) delete cfgObj.mcp.genexus18;
861
+ writeClientJson(filePath, cfgObj);
496
862
  }
497
863
 
498
864
  function removeOpenCodeJson(filePath) {
@@ -501,7 +867,7 @@ function removeOpenCodeJson(filePath) {
501
867
  const cfgObj = parsed || {};
502
868
  if (!cfgObj.mcp || !cfgObj.mcp.genexus) return false;
503
869
  delete cfgObj.mcp.genexus;
504
- fs.writeFileSync(filePath, JSON.stringify(cfgObj, null, 2));
870
+ writeClientJson(filePath, cfgObj);
505
871
  return true;
506
872
  }
507
873
 
@@ -524,7 +890,7 @@ function applyCodexToml(filePath, launcher, targetConfigPath) {
524
890
  lines.push('[mcp_servers.genexus.env]');
525
891
  lines.push(`GX_CONFIG_PATH = ${tomlString(targetConfigPath)}`);
526
892
  lines.push('');
527
- fs.writeFileSync(filePath, stripped + lines.join('\n'));
893
+ writeClientText(filePath, stripped + lines.join('\n'));
528
894
  }
529
895
 
530
896
  function removeCodexToml(filePath) {
@@ -532,7 +898,7 @@ function removeCodexToml(filePath) {
532
898
  const existing = fs.readFileSync(filePath, 'utf8');
533
899
  const stripped = stripCodexGenexusBlocks(existing);
534
900
  if (stripped === existing) return false;
535
- fs.writeFileSync(filePath, stripped);
901
+ writeClientText(filePath, stripped);
536
902
  return true;
537
903
  }
538
904
 
@@ -588,6 +954,7 @@ function normalizeExePath(p) {
588
954
  }
589
955
 
590
956
  function readClientCommandEntry(client) {
957
+ if (client.writeSupported === false) return null;
591
958
  if (!fs.existsSync(client.path)) return null;
592
959
  try {
593
960
  if (client.format === 'mcpServers') {
@@ -604,6 +971,13 @@ function readClientCommandEntry(client) {
604
971
  if (!entry || !Array.isArray(entry.command) || entry.command.length === 0) return null;
605
972
  return { command: entry.command[0], args: entry.command.slice(1) };
606
973
  }
974
+ if (client.format === 'vscode-servers') {
975
+ const parsed = readJsonFileSafe(client.path);
976
+ if (!parsed || typeof parsed !== 'object') return null;
977
+ const entry = parsed.servers && parsed.servers.genexus;
978
+ if (!entry) return null;
979
+ return { command: entry.command || null, args: Array.isArray(entry.args) ? entry.args : [] };
980
+ }
607
981
  if (client.format === 'codex-toml') {
608
982
  const raw = fs.readFileSync(client.path, 'utf8');
609
983
  // Minimal extraction: find [mcp_servers.genexus] block and pull command = "..."
@@ -786,6 +1160,8 @@ module.exports = {
786
1160
  patchClientConfig,
787
1161
  unpatchClientConfig,
788
1162
  getClientConfigTargets,
1163
+ detectClientInstalled,
1164
+ clientsStatus,
789
1165
  listSupportedClientIds,
790
1166
  filterClientTargets,
791
1167
  getLocalAppDataCacheDir,
package/cli/run.test.js CHANGED
@@ -6,6 +6,7 @@ const os = require('node:os');
6
6
  const fs = require('node:fs');
7
7
  const { renderOutput } = require('./lib/output');
8
8
  const { compareSemver } = require('./lib/update-check');
9
+ const { detectClientInstalled, readJsonFileSafe } = require('./lib/config');
9
10
 
10
11
  const cliPath = path.join(__dirname, 'run.js');
11
12
 
@@ -599,6 +600,227 @@ test('update --help returns usage entry', () => {
599
600
  assert.ok(parsed.ok.usage.includes('genexus-mcp update'));
600
601
  });
601
602
 
603
+ test('detectClientInstalled flags an agent installed via marker even with no MCP config', () => {
604
+ // Regression for the field report where Antigravity showed "not detected":
605
+ // an agent whose install dir exists but which hasn't created our MCP config
606
+ // file yet must still be detected as installed.
607
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-detect-'));
608
+ const installDir = path.join(tempRoot, 'Programs', 'Antigravity');
609
+ fs.mkdirSync(installDir, { recursive: true });
610
+
611
+ const client = {
612
+ name: 'Antigravity',
613
+ path: path.join(tempRoot, 'never-created', 'mcp_config.json'),
614
+ installMarkers: [installDir]
615
+ };
616
+
617
+ const det = detectClientInstalled(client);
618
+ assert.equal(det.installed, true, 'marker dir present => installed');
619
+ assert.equal(det.hasConfig, false, 'config file does not exist yet');
620
+ assert.equal(det.markerHit, installDir);
621
+
622
+ fs.rmSync(tempRoot, { recursive: true, force: true });
623
+ });
624
+
625
+ test('detectClientInstalled reports not-installed and lists checked paths', () => {
626
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-detect-'));
627
+ const client = {
628
+ name: 'Antigravity',
629
+ path: path.join(tempRoot, 'nope.json'),
630
+ installMarkers: [path.join(tempRoot, 'absent-a'), path.join(tempRoot, 'absent-b')]
631
+ };
632
+
633
+ const det = detectClientInstalled(client);
634
+ assert.equal(det.installed, false);
635
+ assert.equal(det.markerHit, null);
636
+ assert.deepEqual(det.markersChecked, client.installMarkers);
637
+
638
+ fs.rmSync(tempRoot, { recursive: true, force: true });
639
+ });
640
+
641
+ test('detectClientInstalled treats an existing config file as installed', () => {
642
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-detect-'));
643
+ const cfg = path.join(tempRoot, 'settings.json');
644
+ fs.writeFileSync(cfg, '{}');
645
+ const client = { name: 'Gemini CLI', path: cfg, installMarkers: [] };
646
+
647
+ const det = detectClientInstalled(client);
648
+ assert.equal(det.installed, true);
649
+ assert.equal(det.hasConfig, true);
650
+
651
+ fs.rmSync(tempRoot, { recursive: true, force: true });
652
+ });
653
+
654
+ // Build a throwaway HOME so client-config writes never touch the real machine.
655
+ function sandboxHomeEnv(root) {
656
+ return {
657
+ HOME: root,
658
+ USERPROFILE: root,
659
+ APPDATA: path.join(root, 'AppData', 'Roaming'),
660
+ LOCALAPPDATA: path.join(root, 'AppData', 'Local'),
661
+ XDG_CONFIG_HOME: path.join(root, '.config')
662
+ };
663
+ }
664
+
665
+ test('clients list returns structured status with summary', () => {
666
+ const result = runCli(['clients', '--format', 'json']);
667
+ assert.equal(result.status, 0);
668
+ const parsed = JSON.parse(result.stdout);
669
+ assert.equal(parsed.meta.command, 'clients.list');
670
+ assert.ok(Array.isArray(parsed.ok.clients));
671
+ assert.ok(parsed.ok.clients.length >= 8);
672
+ assert.equal(typeof parsed.ok.summary.installed, 'number');
673
+ assert.equal(typeof parsed.ok.summary.registered, 'number');
674
+ const row = parsed.ok.clients.find((c) => c.id === 'antigravity');
675
+ assert.ok(row, 'antigravity should be listed');
676
+ assert.equal(typeof row.installed, 'boolean');
677
+ assert.equal(typeof row.registered, 'boolean');
678
+ });
679
+
680
+ test('clients add registers a client into a sandbox home with backup + atomic write', () => {
681
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-clients-'));
682
+ const env = sandboxHomeEnv(tempRoot);
683
+ const cfgPath = path.join(tempRoot, 'config.json');
684
+ fs.writeFileSync(cfgPath, JSON.stringify({ Environment: { KBPath: tempRoot } }));
685
+
686
+ // Pre-existing cursor config so we can assert a backup is taken.
687
+ const cursorCfg = path.join(tempRoot, '.cursor', 'mcp.json');
688
+ fs.mkdirSync(path.dirname(cursorCfg), { recursive: true });
689
+ fs.writeFileSync(cursorCfg, JSON.stringify({ mcpServers: { other: { command: 'x' } } }, null, 2));
690
+
691
+ const res = runCli(['clients', 'add', '--clients', 'cursor', '--format', 'json'], {
692
+ env: { ...env, GX_CONFIG_PATH: cfgPath }
693
+ });
694
+ assert.equal(res.status, 0);
695
+ const parsed = JSON.parse(res.stdout);
696
+ assert.equal(parsed.meta.command, 'clients.add');
697
+ assert.ok(parsed.ok.patchedClients.includes('Cursor'));
698
+
699
+ const written = JSON.parse(fs.readFileSync(cursorCfg, 'utf8'));
700
+ assert.ok(written.mcpServers.genexus, 'genexus entry should be written');
701
+ assert.ok(written.mcpServers.other, 'pre-existing entries preserved');
702
+
703
+ const baks = fs.readdirSync(path.dirname(cursorCfg)).filter((f) => f.includes('.bak'));
704
+ assert.ok(baks.length >= 1, 'a .bak backup should be created before mutating');
705
+
706
+ fs.rmSync(tempRoot, { recursive: true, force: true });
707
+ });
708
+
709
+ test('clients add tolerates a JSONC (commented) VS Code mcp.json', () => {
710
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-jsonc-'));
711
+ const env = sandboxHomeEnv(tempRoot);
712
+ const cfgPath = path.join(tempRoot, 'config.json');
713
+ fs.writeFileSync(cfgPath, JSON.stringify({ Environment: { KBPath: tempRoot } }));
714
+
715
+ const vscodeCfg = path.join(env.APPDATA, 'Code', 'User', 'mcp.json');
716
+ fs.mkdirSync(path.dirname(vscodeCfg), { recursive: true });
717
+ fs.writeFileSync(vscodeCfg, '{\n // user comment\n "servers": {\n "foo": { "command": "bar" },\n }\n}\n');
718
+
719
+ const res = runCli(['clients', 'add', '--clients', 'vscode', '--format', 'json'], {
720
+ env: { ...env, GX_CONFIG_PATH: cfgPath }
721
+ });
722
+ assert.equal(res.status, 0);
723
+ const parsed = JSON.parse(res.stdout);
724
+ assert.ok(parsed.ok.patchedClients.includes('VS Code'), 'VS Code should be patched despite comments');
725
+
726
+ const written = JSON.parse(fs.readFileSync(vscodeCfg, 'utf8'));
727
+ assert.ok(written.servers.genexus, 'genexus server entry written');
728
+ assert.ok(written.servers.foo, 'pre-existing server preserved');
729
+
730
+ fs.rmSync(tempRoot, { recursive: true, force: true });
731
+ });
732
+
733
+ test('clients add replaces a legacy genexus18 entry instead of duplicating it', () => {
734
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-legacy-'));
735
+ const env = sandboxHomeEnv(tempRoot);
736
+ const cfgPath = path.join(tempRoot, 'config.json');
737
+ fs.writeFileSync(cfgPath, JSON.stringify({ Environment: { KBPath: tempRoot } }));
738
+
739
+ const cursorCfg = path.join(tempRoot, '.cursor', 'mcp.json');
740
+ fs.mkdirSync(path.dirname(cursorCfg), { recursive: true });
741
+ fs.writeFileSync(cursorCfg, JSON.stringify({ mcpServers: { genexus18: { command: 'C:\\old\\start_mcp.bat' } } }, null, 2));
742
+
743
+ const res = runCli(['clients', 'add', '--clients', 'cursor', '--format', 'json'], {
744
+ env: { ...env, GX_CONFIG_PATH: cfgPath }
745
+ });
746
+ assert.equal(res.status, 0);
747
+
748
+ const written = JSON.parse(fs.readFileSync(cursorCfg, 'utf8'));
749
+ assert.ok(written.mcpServers.genexus, 'new genexus entry present');
750
+ assert.equal(written.mcpServers.genexus18, undefined, 'legacy genexus18 removed (no duplicate)');
751
+
752
+ fs.rmSync(tempRoot, { recursive: true, force: true });
753
+ });
754
+
755
+ test('clients list flags a registered command pointing at a missing launcher as stale (.bat too)', () => {
756
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-stale-'));
757
+ const env = sandboxHomeEnv(tempRoot);
758
+ const cursorCfg = path.join(tempRoot, '.cursor', 'mcp.json');
759
+ fs.mkdirSync(path.dirname(cursorCfg), { recursive: true });
760
+ // A non-.exe launcher (.bat) that no longer exists must also be flagged stale.
761
+ const missing = path.join(tempRoot, 'gone', 'start_mcp.bat');
762
+ fs.writeFileSync(cursorCfg, JSON.stringify({ mcpServers: { genexus: { command: missing, args: [] } } }, null, 2));
763
+
764
+ const res = runCli(['clients', '--format', 'json'], { env });
765
+ assert.equal(res.status, 0);
766
+ const parsed = JSON.parse(res.stdout);
767
+ const cursor = parsed.ok.clients.find((c) => c.id === 'cursor');
768
+ assert.ok(cursor.registered, 'cursor should read as registered');
769
+ assert.equal(cursor.commandStale, true, 'missing launcher => stale');
770
+ assert.ok(parsed.help.some((h) => h.includes('missing gateway exe')), 'help should call out the stale client');
771
+
772
+ fs.rmSync(tempRoot, { recursive: true, force: true });
773
+ });
774
+
775
+ test('readJsonFileSafe parses JSONC without corrupting string values containing commas', () => {
776
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-jsonc2-'));
777
+ const f = path.join(tempRoot, 'mcp.json');
778
+ // Leading comment forces the JSONC fallback; the string value contains ", ]"
779
+ // and the object has a legitimate trailing comma that SHOULD be stripped.
780
+ fs.writeFileSync(f, '{\n // comment\n "servers": { "x": { "command": "a, ]b", "args": ["c,]"] } },\n}\n');
781
+ const parsed = readJsonFileSafe(f);
782
+ assert.ok(parsed, 'should parse');
783
+ assert.equal(parsed.servers.x.command, 'a, ]b', 'comma inside string value preserved');
784
+ assert.deepEqual(parsed.servers.x.args, ['c,]'], 'comma inside array string preserved');
785
+ fs.rmSync(tempRoot, { recursive: true, force: true });
786
+ });
787
+
788
+ test('readJsonFileSafe strips a genuine trailing comma', () => {
789
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-jsonc3-'));
790
+ const f = path.join(tempRoot, 'mcp.json');
791
+ fs.writeFileSync(f, '{\n // c\n "a": [1, 2, 3,],\n "b": { "x": 1, },\n}\n');
792
+ const parsed = readJsonFileSafe(f);
793
+ assert.deepEqual(parsed.a, [1, 2, 3]);
794
+ assert.deepEqual(parsed.b, { x: 1 });
795
+ fs.rmSync(tempRoot, { recursive: true, force: true });
796
+ });
797
+
798
+ test('clients add without --clients is a usage error', () => {
799
+ const res = runCli(['clients', 'add', '--format', 'json']);
800
+ assert.equal(res.status, 2);
801
+ const parsed = JSON.parse(res.stdout);
802
+ assert.equal(parsed.error.code, 'usage_error');
803
+ });
804
+
805
+ test('clients remove drops the genexus entry (sandbox home)', () => {
806
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genexus-mcp-rm-'));
807
+ const env = sandboxHomeEnv(tempRoot);
808
+ const cursorCfg = path.join(tempRoot, '.cursor', 'mcp.json');
809
+ fs.mkdirSync(path.dirname(cursorCfg), { recursive: true });
810
+ fs.writeFileSync(cursorCfg, JSON.stringify({ mcpServers: { genexus: { command: 'npx' }, genexus18: { command: 'old' } } }, null, 2));
811
+
812
+ const res = runCli(['clients', 'remove', '--clients', 'cursor', '--format', 'json'], { env });
813
+ assert.equal(res.status, 0);
814
+ const parsed = JSON.parse(res.stdout);
815
+ assert.ok(parsed.ok.removedClients.includes('Cursor'));
816
+
817
+ const written = JSON.parse(fs.readFileSync(cursorCfg, 'utf8'));
818
+ assert.equal(written.mcpServers.genexus, undefined, 'genexus removed');
819
+ assert.equal(written.mcpServers.genexus18, undefined, 'legacy genexus18 also removed');
820
+
821
+ fs.rmSync(tempRoot, { recursive: true, force: true });
822
+ });
823
+
602
824
  test('compareSemver detects newer, older, equal versions', () => {
603
825
  assert.equal(compareSemver('1.3.1', '1.3.0'), 1);
604
826
  assert.equal(compareSemver('v1.4.0', '1.3.9'), 1);
@@ -27,6 +27,7 @@ Entry points:
27
27
  - `genexus-mcp llm help`
28
28
  - `genexus-mcp status`
29
29
  - `genexus-mcp doctor --mcp-smoke`
30
+ - `genexus-mcp clients` (which AI agents are installed/registered; `clients add|remove --clients <csv>`)
30
31
  - `genexus-mcp tools list`
31
32
  - `genexus-mcp config show`
32
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genexus-mcp",
3
- "version": "2.8.2",
3
+ "version": "2.8.3",
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": [
@@ -7,7 +7,7 @@
7
7
  "targets": {
8
8
  ".NETCoreApp,Version=v8.0": {},
9
9
  ".NETCoreApp,Version=v8.0/win-x64": {
10
- "GxMcp.Gateway/2.8.2": {
10
+ "GxMcp.Gateway/2.8.3": {
11
11
  "dependencies": {
12
12
  "Newtonsoft.Json": "13.0.3",
13
13
  "System.Management": "10.0.5",
@@ -66,7 +66,7 @@
66
66
  }
67
67
  },
68
68
  "libraries": {
69
- "GxMcp.Gateway/2.8.2": {
69
+ "GxMcp.Gateway/2.8.3": {
70
70
  "type": "project",
71
71
  "serviceable": false,
72
72
  "sha512": ""
Binary file
Binary file
Binary file
Binary file
Binary file