genexus-mcp 3.7.2 → 3.9.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/commands/axi.js +52 -0
- package/cli/index.js +10 -1
- package/cli/lib/client-adapters.test.js +1 -0
- package/cli/lib/config.js +11 -0
- package/cli/lib/runtime-stager.js +332 -0
- package/cli/lib/runtime-stager.test.js +221 -0
- package/cli/run.test.js +18 -3
- package/docs/llm_cli_mcp_playbook.md +28 -0
- package/package.json +2 -2
- package/publish/GxMcp.Gateway.deps.json +2 -2
- package/publish/GxMcp.Gateway.dll +0 -0
- package/publish/GxMcp.Gateway.exe +0 -0
- package/publish/config.json +12 -12
- package/publish/gxmcp-manifest.json +10 -10
- package/publish/gxmcp-sbom.json +4 -4
- package/publish/tool_definitions.json +9 -9
- package/publish/worker/GxMcp.Worker.exe +0 -0
package/cli/commands/axi.js
CHANGED
|
@@ -813,6 +813,57 @@ function buildLegacyIdeLockCheck(gxMajor) {
|
|
|
813
813
|
return { id: 'legacy_ide_lock', status: 'pass', detail: 'No conflicting GeneXus IDE process (gx.exe) detected.' };
|
|
814
814
|
}
|
|
815
815
|
|
|
816
|
+
function buildRuntimeStagingCheck() {
|
|
817
|
+
try {
|
|
818
|
+
const {
|
|
819
|
+
resolveDefaultRuntimeRoot,
|
|
820
|
+
listStagedRuntimes,
|
|
821
|
+
getRunningGxMcpProcesses,
|
|
822
|
+
classifyProcessRuntime,
|
|
823
|
+
shouldStage
|
|
824
|
+
} = require('../lib/runtime-stager');
|
|
825
|
+
|
|
826
|
+
const runtimeRoot = resolveDefaultRuntimeRoot();
|
|
827
|
+
const stagedList = listStagedRuntimes(runtimeRoot);
|
|
828
|
+
const runningProcs = getRunningGxMcpProcesses();
|
|
829
|
+
const npxProcs = runningProcs.filter((p) => classifyProcessRuntime(p.exePath, runtimeRoot) === 'npx-cache');
|
|
830
|
+
const stagedProcs = runningProcs.filter((p) => classifyProcessRuntime(p.exePath, runtimeRoot) === 'staged');
|
|
831
|
+
|
|
832
|
+
if (npxProcs.length > 0) {
|
|
833
|
+
return {
|
|
834
|
+
id: 'runtime_staging',
|
|
835
|
+
status: 'warn',
|
|
836
|
+
detail: `${npxProcs.length} process(es) running directly from npx cache (PIDs: ${npxProcs.map((p) => p.pid).join(', ')}). Terminate them to prevent EBUSY during upgrades.`
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
if (shouldStage()) {
|
|
840
|
+
return {
|
|
841
|
+
id: 'runtime_staging',
|
|
842
|
+
status: 'pass',
|
|
843
|
+
detail: `Staging active at ${runtimeRoot}. ${stagedList.length} staged version(s) found, ${stagedProcs.length} active process(es).`
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
if (process.env.GENEXUS_MCP_GATEWAY_EXE) {
|
|
847
|
+
return {
|
|
848
|
+
id: 'runtime_staging',
|
|
849
|
+
status: 'pass',
|
|
850
|
+
detail: `Fixed-path runtime override active via GENEXUS_MCP_GATEWAY_EXE (${process.env.GENEXUS_MCP_GATEWAY_EXE}). Staging bypassed.`
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
return {
|
|
854
|
+
id: 'runtime_staging',
|
|
855
|
+
status: 'pass',
|
|
856
|
+
detail: 'Development checkout active. Runtime staging bypassed.'
|
|
857
|
+
};
|
|
858
|
+
} catch {
|
|
859
|
+
return {
|
|
860
|
+
id: 'runtime_staging',
|
|
861
|
+
status: 'not_applicable',
|
|
862
|
+
detail: 'Runtime staging inspection unavailable.'
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
816
867
|
function redactConfig(cfg) {
|
|
817
868
|
// Replace absolute paths with `<redacted:hash8>` so the structure is preserved
|
|
818
869
|
// but filesystem layout, usernames, and KB names are not leaked. Hash is stable
|
|
@@ -1124,6 +1175,7 @@ async function handleDoctor(options, ctx) {
|
|
|
1124
1175
|
checks.push({ id: 'gxpublic_com_registration', status: comCheck.status, detail: comCheck.detail });
|
|
1125
1176
|
const ideLockCheck = buildLegacyIdeLockCheck(gxMajor);
|
|
1126
1177
|
checks.push({ id: 'legacy_ide_lock', status: ideLockCheck.status, detail: ideLockCheck.detail });
|
|
1178
|
+
checks.push(buildRuntimeStagingCheck());
|
|
1127
1179
|
|
|
1128
1180
|
// Client registration summary — one line answering "are my AI agents wired up?".
|
|
1129
1181
|
const installedRows = clientRows.filter((r) => r.installed);
|
package/cli/index.js
CHANGED
|
@@ -450,7 +450,16 @@ async function launchGateway(passthroughArgs, options) {
|
|
|
450
450
|
return EXIT_CODES.ERROR;
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
-
|
|
453
|
+
let gatewayExePath = getGatewayExePath();
|
|
454
|
+
try {
|
|
455
|
+
const { ensureStagedGateway } = require('./lib/runtime-stager');
|
|
456
|
+
const staged = ensureStagedGateway();
|
|
457
|
+
gatewayExePath = staged.gatewayExePath;
|
|
458
|
+
} catch (stageErr) {
|
|
459
|
+
if (!options.quiet) {
|
|
460
|
+
launcherStderr.write(`[genexus-mcp] Warning: Runtime staging failed (${stageErr.message}), falling back to direct binary.\n`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
454
463
|
if (!require('fs').existsSync(gatewayExePath)) {
|
|
455
464
|
const message = `[genexus-mcp] ERROR: Gateway executable not found at ${gatewayExePath}`;
|
|
456
465
|
launcherStderr.write(`${message}\n`);
|
|
@@ -151,6 +151,7 @@ test('generateNeutralConfig produces valid shape without KB fields', () => {
|
|
|
151
151
|
assert.equal(cfg.GeneXus.InstallationPath, 'C:\\GeneXus18');
|
|
152
152
|
assert.ok(cfg.GeneXus.WorkerExecutable.endsWith(path.join('worker', 'GxMcp.Worker.exe')));
|
|
153
153
|
assert.equal(cfg.Server.McpStdio, true);
|
|
154
|
+
assert.equal(cfg.Server.ToolProfile, 'standard');
|
|
154
155
|
assert.equal(cfg.Server.TransportMode, undefined);
|
|
155
156
|
assert.equal(cfg.Server.HttpPort, 0);
|
|
156
157
|
assert.equal(cfg.Environment.ResolutionPolicy, 'strict');
|
package/cli/lib/config.js
CHANGED
|
@@ -8,6 +8,7 @@ function generateConfig(gxPath, kbPath) {
|
|
|
8
8
|
Server: {
|
|
9
9
|
HttpPort: 5000,
|
|
10
10
|
McpStdio: true,
|
|
11
|
+
ToolProfile: 'standard',
|
|
11
12
|
SessionIdleTimeoutMinutes: 10,
|
|
12
13
|
WorkerIdleTimeoutMinutes: 5,
|
|
13
14
|
// Lean + terse defaults: the MCP client is an LLM agent that reads
|
|
@@ -33,6 +34,7 @@ function generateNeutralConfig(gxPath, { workerPath, gatewayMode = 'stdio-isolat
|
|
|
33
34
|
Server: {
|
|
34
35
|
HttpPort: 0,
|
|
35
36
|
McpStdio: true,
|
|
37
|
+
ToolProfile: 'standard',
|
|
36
38
|
WorkerSharingMode: 'isolated',
|
|
37
39
|
SessionIdleTimeoutMinutes: 10,
|
|
38
40
|
WorkerIdleTimeoutMinutes: 5,
|
|
@@ -657,9 +659,18 @@ function createConfigFile(kbPath, gxPath) {
|
|
|
657
659
|
}
|
|
658
660
|
const nextConfig = {
|
|
659
661
|
...baseConfig,
|
|
662
|
+
Server: { ...baseConfig.Server },
|
|
660
663
|
Environment: { ...baseConfig.Environment, ...preservedEnv }
|
|
661
664
|
};
|
|
662
665
|
|
|
666
|
+
if (existing) {
|
|
667
|
+
if (existing.Server && Object.prototype.hasOwnProperty.call(existing.Server, 'ToolProfile')) {
|
|
668
|
+
nextConfig.Server.ToolProfile = existing.Server.ToolProfile;
|
|
669
|
+
} else {
|
|
670
|
+
delete nextConfig.Server.ToolProfile;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
663
674
|
const changed = !existing || JSON.stringify(existing) !== JSON.stringify(nextConfig);
|
|
664
675
|
if (changed) {
|
|
665
676
|
writeFileAtomic(targetConfigPath, JSON.stringify(nextConfig, null, 2));
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const { execFileSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
function resolveDefaultRuntimeRoot() {
|
|
10
|
+
if (process.env.GENEXUS_MCP_RUNTIME_DIR) {
|
|
11
|
+
return process.env.GENEXUS_MCP_RUNTIME_DIR;
|
|
12
|
+
}
|
|
13
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
14
|
+
return path.join(localAppData, 'GenexusMCP', 'runtime');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isCheckoutMode(packageRoot) {
|
|
18
|
+
try {
|
|
19
|
+
const gitMarker = fs.statSync(path.join(packageRoot, '.git'));
|
|
20
|
+
return gitMarker.isDirectory() || gitMarker.isFile();
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function shouldStage(packageRoot = path.resolve(__dirname, '..', '..')) {
|
|
27
|
+
if (process.env.GENEXUS_MCP_GATEWAY_EXE) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (process.env.GENEXUS_MCP_NO_STAGING === '1') {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (process.env.GENEXUS_MCP_FORCE_STAGING === '1') {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return !isCheckoutMode(packageRoot);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function computeFileSha256(filePath) {
|
|
40
|
+
const hash = crypto.createHash('sha256');
|
|
41
|
+
const buffer = fs.readFileSync(filePath);
|
|
42
|
+
hash.update(buffer);
|
|
43
|
+
return hash.digest('hex');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function computePublishFingerprint(publishDir) {
|
|
47
|
+
const manifestPath = path.join(publishDir, 'gxmcp-manifest.json');
|
|
48
|
+
if (fs.existsSync(manifestPath)) {
|
|
49
|
+
try {
|
|
50
|
+
return computeFileSha256(manifestPath).slice(0, 8);
|
|
51
|
+
} catch {
|
|
52
|
+
// fallback
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const gatewayExe = path.join(publishDir, 'GxMcp.Gateway.exe');
|
|
56
|
+
if (fs.existsSync(gatewayExe)) {
|
|
57
|
+
try {
|
|
58
|
+
return computeFileSha256(gatewayExe).slice(0, 8);
|
|
59
|
+
} catch {
|
|
60
|
+
// fallback
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return '00000000';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function verifyManifest(stagedDir, manifestPath) {
|
|
67
|
+
if (!fs.existsSync(manifestPath)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let manifest;
|
|
71
|
+
try {
|
|
72
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
73
|
+
} catch (err) {
|
|
74
|
+
throw new Error(`Failed to parse manifest at ${manifestPath}: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
|
|
78
|
+
for (const artifact of artifacts) {
|
|
79
|
+
if (!artifact || !artifact.path) continue;
|
|
80
|
+
const fullPath = path.join(stagedDir, artifact.path);
|
|
81
|
+
if (!fs.existsSync(fullPath)) {
|
|
82
|
+
throw new Error(`Manifest verification failed: missing artifact '${artifact.path}'`);
|
|
83
|
+
}
|
|
84
|
+
if (typeof artifact.size === 'number') {
|
|
85
|
+
const stat = fs.statSync(fullPath);
|
|
86
|
+
if (stat.size !== artifact.size) {
|
|
87
|
+
throw new Error(`Manifest verification failed: size mismatch for '${artifact.path}' (expected ${artifact.size}, got ${stat.size})`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (artifact.sha256) {
|
|
91
|
+
const actualSha = computeFileSha256(fullPath);
|
|
92
|
+
if (actualSha.toLowerCase() !== artifact.sha256.toLowerCase()) {
|
|
93
|
+
throw new Error(`Manifest verification failed: sha256 mismatch for '${artifact.path}'`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function cleanOldRuntimes(runtimeRoot, currentTargetDir, keepCount = 2) {
|
|
100
|
+
try {
|
|
101
|
+
if (!fs.existsSync(runtimeRoot)) return;
|
|
102
|
+
const entries = fs.readdirSync(runtimeRoot, { withFileTypes: true });
|
|
103
|
+
|
|
104
|
+
// Clean up stale tmp directories older than 10 minutes
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (entry.isDirectory() && entry.name.includes('.tmp-')) {
|
|
108
|
+
const fullTmpPath = path.join(runtimeRoot, entry.name);
|
|
109
|
+
try {
|
|
110
|
+
const stat = fs.statSync(fullTmpPath);
|
|
111
|
+
if (now - stat.mtimeMs > 10 * 60 * 1000) {
|
|
112
|
+
fs.rmSync(fullTmpPath, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Ignore removal failures on locked tmp dirs
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Gather valid version directories
|
|
121
|
+
const versionDirs = [];
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (!entry.isDirectory() || entry.name.includes('.tmp-')) continue;
|
|
124
|
+
const fullPath = path.join(runtimeRoot, entry.name);
|
|
125
|
+
try {
|
|
126
|
+
const stat = fs.statSync(fullPath);
|
|
127
|
+
versionDirs.push({
|
|
128
|
+
name: entry.name,
|
|
129
|
+
path: fullPath,
|
|
130
|
+
mtimeMs: stat.mtimeMs
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
// Ignore stat errors
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Sort newest first
|
|
138
|
+
versionDirs.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
139
|
+
|
|
140
|
+
// Keep current targetDir always, plus up to keepCount newest
|
|
141
|
+
const normalizedCurrent = path.resolve(currentTargetDir).toLowerCase();
|
|
142
|
+
let kept = 0;
|
|
143
|
+
const toDelete = [];
|
|
144
|
+
|
|
145
|
+
for (const vdir of versionDirs) {
|
|
146
|
+
const normalized = path.resolve(vdir.path).toLowerCase();
|
|
147
|
+
if (normalized === normalizedCurrent) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (kept < keepCount) {
|
|
151
|
+
kept++;
|
|
152
|
+
} else {
|
|
153
|
+
toDelete.push(vdir.path);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const dirToDelete of toDelete) {
|
|
158
|
+
try {
|
|
159
|
+
fs.rmSync(dirToDelete, { recursive: true, force: true });
|
|
160
|
+
} catch {
|
|
161
|
+
// EBUSY or EPERM means another running process has loaded DLLs from this runtime.
|
|
162
|
+
// Keep it for later cleanup.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// Garbage collection is best-effort
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function ensureStagedGateway(options = {}) {
|
|
171
|
+
const packageRoot = options.packageRoot || path.resolve(__dirname, '..', '..');
|
|
172
|
+
const publishDir = options.publishDir || path.join(packageRoot, 'publish');
|
|
173
|
+
const defaultGatewayExe = options.defaultGatewayExe || path.join(publishDir, 'GxMcp.Gateway.exe');
|
|
174
|
+
|
|
175
|
+
if (!options.forceStaging && !shouldStage(packageRoot)) {
|
|
176
|
+
return {
|
|
177
|
+
staged: false,
|
|
178
|
+
gatewayExePath: process.env.GENEXUS_MCP_GATEWAY_EXE || defaultGatewayExe,
|
|
179
|
+
runtimeDir: path.dirname(process.env.GENEXUS_MCP_GATEWAY_EXE || defaultGatewayExe),
|
|
180
|
+
reason: process.env.GENEXUS_MCP_GATEWAY_EXE ? 'GENEXUS_MCP_GATEWAY_EXE' : (process.env.GENEXUS_MCP_NO_STAGING === '1' ? 'NO_STAGING' : 'checkout')
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let version = '0.0.0';
|
|
185
|
+
try {
|
|
186
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
187
|
+
if (pkg.version) version = pkg.version;
|
|
188
|
+
} catch {
|
|
189
|
+
// Fallback
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const sha8 = computePublishFingerprint(publishDir);
|
|
193
|
+
const runtimeRoot = options.runtimeRoot || resolveDefaultRuntimeRoot();
|
|
194
|
+
const targetDir = path.join(runtimeRoot, `${version}-${sha8}`);
|
|
195
|
+
const stagedGatewayExe = path.join(targetDir, 'GxMcp.Gateway.exe');
|
|
196
|
+
|
|
197
|
+
if (fs.existsSync(stagedGatewayExe)) {
|
|
198
|
+
cleanOldRuntimes(runtimeRoot, targetDir);
|
|
199
|
+
return {
|
|
200
|
+
staged: true,
|
|
201
|
+
gatewayExePath: stagedGatewayExe,
|
|
202
|
+
runtimeDir: targetDir,
|
|
203
|
+
fresh: false
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!fs.existsSync(publishDir)) {
|
|
208
|
+
throw new Error(`Cannot stage runtime: publish directory not found at ${publishDir}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
fs.mkdirSync(runtimeRoot, { recursive: true });
|
|
212
|
+
const tmpDir = `${targetDir}.tmp-${process.pid}-${Date.now()}`;
|
|
213
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
fs.cpSync(publishDir, tmpDir, { recursive: true });
|
|
217
|
+
const manifestPath = path.join(tmpDir, 'gxmcp-manifest.json');
|
|
218
|
+
verifyManifest(tmpDir, manifestPath);
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
fs.renameSync(tmpDir, targetDir);
|
|
222
|
+
} catch (renameErr) {
|
|
223
|
+
// Concurrent launcher might have completed the rename first
|
|
224
|
+
if (fs.existsSync(stagedGatewayExe)) {
|
|
225
|
+
try {
|
|
226
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
227
|
+
} catch {
|
|
228
|
+
// Ignore tmp cleanup error
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
throw renameErr;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
try {
|
|
236
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
237
|
+
} catch {
|
|
238
|
+
// Ignore tmp cleanup error
|
|
239
|
+
}
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
cleanOldRuntimes(runtimeRoot, targetDir);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
staged: true,
|
|
247
|
+
gatewayExePath: stagedGatewayExe,
|
|
248
|
+
runtimeDir: targetDir,
|
|
249
|
+
fresh: true
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function listStagedRuntimes(runtimeRoot = resolveDefaultRuntimeRoot()) {
|
|
254
|
+
if (!fs.existsSync(runtimeRoot)) return [];
|
|
255
|
+
try {
|
|
256
|
+
const entries = fs.readdirSync(runtimeRoot, { withFileTypes: true });
|
|
257
|
+
return entries
|
|
258
|
+
.filter((e) => e.isDirectory() && !e.name.includes('.tmp-'))
|
|
259
|
+
.map((e) => {
|
|
260
|
+
const dirPath = path.join(runtimeRoot, e.name);
|
|
261
|
+
let stat = null;
|
|
262
|
+
try {
|
|
263
|
+
stat = fs.statSync(dirPath);
|
|
264
|
+
} catch {
|
|
265
|
+
// Ignore
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
name: e.name,
|
|
269
|
+
path: dirPath,
|
|
270
|
+
hasGateway: fs.existsSync(path.join(dirPath, 'GxMcp.Gateway.exe')),
|
|
271
|
+
mtime: stat ? stat.mtime : null
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
} catch {
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function getRunningGxMcpProcesses() {
|
|
280
|
+
if (process.platform !== 'win32') return [];
|
|
281
|
+
try {
|
|
282
|
+
// Query CIM Win32_Process for GxMcp processes
|
|
283
|
+
const output = execFileSync('powershell.exe', [
|
|
284
|
+
'-NoProfile',
|
|
285
|
+
'-NonInteractive',
|
|
286
|
+
'-Command',
|
|
287
|
+
`Get-CimInstance Win32_Process -Filter "name like 'GxMcp%'" | Select-Object ProcessId, ExecutablePath, CommandLine | ConvertTo-Json -Compress`
|
|
288
|
+
], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
289
|
+
|
|
290
|
+
if (!output) return [];
|
|
291
|
+
let data;
|
|
292
|
+
try {
|
|
293
|
+
data = JSON.parse(output);
|
|
294
|
+
} catch {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
const rows = Array.isArray(data) ? data : [data];
|
|
298
|
+
return rows.map((r) => ({
|
|
299
|
+
pid: r.ProcessId,
|
|
300
|
+
exePath: r.ExecutablePath || '',
|
|
301
|
+
commandLine: r.CommandLine || ''
|
|
302
|
+
}));
|
|
303
|
+
} catch {
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function classifyProcessRuntime(exePath, runtimeRoot = resolveDefaultRuntimeRoot()) {
|
|
309
|
+
if (!exePath) return 'unknown';
|
|
310
|
+
const norm = path.resolve(exePath).toLowerCase();
|
|
311
|
+
const normRuntime = path.resolve(runtimeRoot).toLowerCase();
|
|
312
|
+
if (norm.startsWith(normRuntime)) {
|
|
313
|
+
return 'staged';
|
|
314
|
+
}
|
|
315
|
+
if (norm.includes('npm-cache') || norm.includes('_npx') || norm.includes('node_modules')) {
|
|
316
|
+
return 'npx-cache';
|
|
317
|
+
}
|
|
318
|
+
return 'other';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
module.exports = {
|
|
322
|
+
resolveDefaultRuntimeRoot,
|
|
323
|
+
isCheckoutMode,
|
|
324
|
+
shouldStage,
|
|
325
|
+
computePublishFingerprint,
|
|
326
|
+
verifyManifest,
|
|
327
|
+
cleanOldRuntimes,
|
|
328
|
+
ensureStagedGateway,
|
|
329
|
+
listStagedRuntimes,
|
|
330
|
+
getRunningGxMcpProcesses,
|
|
331
|
+
classifyProcessRuntime
|
|
332
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
resolveDefaultRuntimeRoot,
|
|
12
|
+
shouldStage,
|
|
13
|
+
ensureStagedGateway,
|
|
14
|
+
cleanOldRuntimes,
|
|
15
|
+
verifyManifest,
|
|
16
|
+
classifyProcessRuntime
|
|
17
|
+
} = require('./runtime-stager');
|
|
18
|
+
|
|
19
|
+
function sha256(content) {
|
|
20
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('runtime-stager: resolveDefaultRuntimeRoot uses GENEXUS_MCP_RUNTIME_DIR or LocalAppData', (t) => {
|
|
24
|
+
const orig = process.env.GENEXUS_MCP_RUNTIME_DIR;
|
|
25
|
+
t.after(() => {
|
|
26
|
+
if (orig !== undefined) process.env.GENEXUS_MCP_RUNTIME_DIR = orig;
|
|
27
|
+
else delete process.env.GENEXUS_MCP_RUNTIME_DIR;
|
|
28
|
+
});
|
|
29
|
+
process.env.GENEXUS_MCP_RUNTIME_DIR = 'C:\\custom\\runtime';
|
|
30
|
+
assert.equal(resolveDefaultRuntimeRoot(), 'C:\\custom\\runtime');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('runtime-stager: verifyManifest succeeds on valid files', () => {
|
|
34
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-verify-'));
|
|
35
|
+
try {
|
|
36
|
+
const file = path.join(tmp, 'test.txt');
|
|
37
|
+
fs.writeFileSync(file, 'hello');
|
|
38
|
+
const manifest = path.join(tmp, 'manifest.json');
|
|
39
|
+
fs.writeFileSync(manifest, JSON.stringify({
|
|
40
|
+
artifacts: [{ path: 'test.txt', size: 5, sha256: sha256('hello') }]
|
|
41
|
+
}));
|
|
42
|
+
assert.doesNotThrow(() => verifyManifest(tmp, manifest));
|
|
43
|
+
} finally {
|
|
44
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('runtime-stager: shouldStage respects env overrides and checkout markers', (t) => {
|
|
49
|
+
const origEnv = { ...process.env };
|
|
50
|
+
t.after(() => {
|
|
51
|
+
process.env = origEnv;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const tmpPkg = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-pkg-'));
|
|
55
|
+
t.after(() => {
|
|
56
|
+
try { fs.rmSync(tmpPkg, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Case 1: GENEXUS_MCP_GATEWAY_EXE set -> false
|
|
60
|
+
process.env.GENEXUS_MCP_GATEWAY_EXE = 'C:\\custom\\GxMcp.Gateway.exe';
|
|
61
|
+
delete process.env.GENEXUS_MCP_NO_STAGING;
|
|
62
|
+
delete process.env.GENEXUS_MCP_FORCE_STAGING;
|
|
63
|
+
assert.equal(shouldStage(tmpPkg), false);
|
|
64
|
+
|
|
65
|
+
// Case 2: GENEXUS_MCP_NO_STAGING === '1' -> false
|
|
66
|
+
delete process.env.GENEXUS_MCP_GATEWAY_EXE;
|
|
67
|
+
process.env.GENEXUS_MCP_NO_STAGING = '1';
|
|
68
|
+
assert.equal(shouldStage(tmpPkg), false);
|
|
69
|
+
|
|
70
|
+
// Case 3: In packaged mode (no .git) -> true
|
|
71
|
+
delete process.env.GENEXUS_MCP_NO_STAGING;
|
|
72
|
+
assert.equal(shouldStage(tmpPkg), true);
|
|
73
|
+
|
|
74
|
+
// Case 4: With .git marker -> false
|
|
75
|
+
fs.mkdirSync(path.join(tmpPkg, '.git'));
|
|
76
|
+
assert.equal(shouldStage(tmpPkg), false);
|
|
77
|
+
|
|
78
|
+
// Case 5: With .git marker but GENEXUS_MCP_FORCE_STAGING === '1' -> true
|
|
79
|
+
process.env.GENEXUS_MCP_FORCE_STAGING = '1';
|
|
80
|
+
assert.equal(shouldStage(tmpPkg), true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('runtime-stager: ensureStagedGateway stages publish folder, verifies manifest and reuses staged runtime', (t) => {
|
|
84
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-stage-test-'));
|
|
85
|
+
t.after(() => {
|
|
86
|
+
try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const fakePkg = path.join(tmpRoot, 'package');
|
|
90
|
+
const fakePublish = path.join(fakePkg, 'publish');
|
|
91
|
+
fs.mkdirSync(fakePublish, { recursive: true });
|
|
92
|
+
|
|
93
|
+
fs.writeFileSync(path.join(fakePkg, 'package.json'), JSON.stringify({ version: '3.9.0' }));
|
|
94
|
+
const fakeExeContent = 'fake gateway binary';
|
|
95
|
+
fs.writeFileSync(path.join(fakePublish, 'GxMcp.Gateway.exe'), fakeExeContent);
|
|
96
|
+
|
|
97
|
+
const manifestContent = JSON.stringify({
|
|
98
|
+
schemaVersion: 'gxmcp-release-manifest/1',
|
|
99
|
+
artifacts: [
|
|
100
|
+
{
|
|
101
|
+
path: 'GxMcp.Gateway.exe',
|
|
102
|
+
size: Buffer.byteLength(fakeExeContent),
|
|
103
|
+
sha256: sha256(fakeExeContent)
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
});
|
|
107
|
+
fs.writeFileSync(path.join(fakePublish, 'gxmcp-manifest.json'), manifestContent);
|
|
108
|
+
|
|
109
|
+
const runtimeRoot = path.join(tmpRoot, 'runtime');
|
|
110
|
+
|
|
111
|
+
// First run: stages fresh
|
|
112
|
+
const result1 = ensureStagedGateway({
|
|
113
|
+
packageRoot: fakePkg,
|
|
114
|
+
publishDir: fakePublish,
|
|
115
|
+
runtimeRoot,
|
|
116
|
+
forceStaging: true
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
assert.equal(result1.staged, true);
|
|
120
|
+
assert.equal(result1.fresh, true);
|
|
121
|
+
assert.equal(fs.existsSync(result1.gatewayExePath), true);
|
|
122
|
+
assert.equal(fs.readFileSync(result1.gatewayExePath, 'utf8'), fakeExeContent);
|
|
123
|
+
|
|
124
|
+
// Second run: reuses already staged
|
|
125
|
+
const result2 = ensureStagedGateway({
|
|
126
|
+
packageRoot: fakePkg,
|
|
127
|
+
publishDir: fakePublish,
|
|
128
|
+
runtimeRoot,
|
|
129
|
+
forceStaging: true
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
assert.equal(result2.staged, true);
|
|
133
|
+
assert.equal(result2.fresh, false);
|
|
134
|
+
assert.equal(result2.gatewayExePath, result1.gatewayExePath);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('runtime-stager: ensureStagedGateway fails closed and cleans tmp on manifest corruption', (t) => {
|
|
138
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-corrupt-test-'));
|
|
139
|
+
t.after(() => {
|
|
140
|
+
try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const fakePkg = path.join(tmpRoot, 'package');
|
|
144
|
+
const fakePublish = path.join(fakePkg, 'publish');
|
|
145
|
+
fs.mkdirSync(fakePublish, { recursive: true });
|
|
146
|
+
|
|
147
|
+
fs.writeFileSync(path.join(fakePkg, 'package.json'), JSON.stringify({ version: '3.9.0' }));
|
|
148
|
+
const fakeExeContent = 'tampered gateway binary';
|
|
149
|
+
fs.writeFileSync(path.join(fakePublish, 'GxMcp.Gateway.exe'), fakeExeContent);
|
|
150
|
+
|
|
151
|
+
const manifestContent = JSON.stringify({
|
|
152
|
+
schemaVersion: 'gxmcp-release-manifest/1',
|
|
153
|
+
artifacts: [
|
|
154
|
+
{
|
|
155
|
+
path: 'GxMcp.Gateway.exe',
|
|
156
|
+
size: 9999, // mismatched size
|
|
157
|
+
sha256: 'deadbeef'
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
});
|
|
161
|
+
fs.writeFileSync(path.join(fakePublish, 'gxmcp-manifest.json'), manifestContent);
|
|
162
|
+
|
|
163
|
+
const runtimeRoot = path.join(tmpRoot, 'runtime');
|
|
164
|
+
|
|
165
|
+
assert.throws(() => {
|
|
166
|
+
ensureStagedGateway({
|
|
167
|
+
packageRoot: fakePkg,
|
|
168
|
+
publishDir: fakePublish,
|
|
169
|
+
runtimeRoot,
|
|
170
|
+
forceStaging: true
|
|
171
|
+
});
|
|
172
|
+
}, /Manifest verification failed/);
|
|
173
|
+
|
|
174
|
+
// Assert that target dir was not created and no tmp dir left behind
|
|
175
|
+
assert.equal(fs.readdirSync(runtimeRoot).length, 0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('runtime-stager: cleanOldRuntimes retains current version plus keepCount newest versions', (t) => {
|
|
179
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gx-gc-test-'));
|
|
180
|
+
t.after(() => {
|
|
181
|
+
try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const v1 = path.join(tmpRoot, '3.6.0-aaaa1111');
|
|
185
|
+
const v2 = path.join(tmpRoot, '3.7.0-bbbb2222');
|
|
186
|
+
const v3 = path.join(tmpRoot, '3.8.0-cccc3333');
|
|
187
|
+
const v4 = path.join(tmpRoot, '3.9.0-dddd4444');
|
|
188
|
+
|
|
189
|
+
fs.mkdirSync(v1);
|
|
190
|
+
fs.utimesSync(v1, 1000, 1000);
|
|
191
|
+
fs.mkdirSync(v2);
|
|
192
|
+
fs.utimesSync(v2, 2000, 2000);
|
|
193
|
+
fs.mkdirSync(v3);
|
|
194
|
+
fs.utimesSync(v3, 3000, 3000);
|
|
195
|
+
fs.mkdirSync(v4);
|
|
196
|
+
fs.utimesSync(v4, 4000, 4000);
|
|
197
|
+
|
|
198
|
+
// Current is v4. Keep count = 2 -> keep v4, v3, v2. v1 should be deleted.
|
|
199
|
+
cleanOldRuntimes(tmpRoot, v4, 2);
|
|
200
|
+
|
|
201
|
+
assert.equal(fs.existsSync(v4), true, 'current version must be kept');
|
|
202
|
+
assert.equal(fs.existsSync(v3), true, 'newest previous version must be kept');
|
|
203
|
+
assert.equal(fs.existsSync(v2), true, 'second previous version must be kept');
|
|
204
|
+
assert.equal(fs.existsSync(v1), false, 'oldest version beyond keepCount must be deleted');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('runtime-stager: classifyProcessRuntime identifies staged vs npx-cache', () => {
|
|
208
|
+
const runtimeRoot = 'C:\\Users\\user\\AppData\\Local\\GenexusMCP\\runtime';
|
|
209
|
+
assert.equal(
|
|
210
|
+
classifyProcessRuntime('C:\\Users\\user\\AppData\\Local\\GenexusMCP\\runtime\\3.8.0-abcd\\GxMcp.Gateway.exe', runtimeRoot),
|
|
211
|
+
'staged'
|
|
212
|
+
);
|
|
213
|
+
assert.equal(
|
|
214
|
+
classifyProcessRuntime('C:\\Users\\user\\AppData\\Local\\npm-cache\\_npx\\12345\\node_modules\\genexus-mcp\\publish\\GxMcp.Gateway.exe', runtimeRoot),
|
|
215
|
+
'npx-cache'
|
|
216
|
+
);
|
|
217
|
+
assert.equal(
|
|
218
|
+
classifyProcessRuntime('C:\\Projetos\\Genexus18MCP\\publish\\GxMcp.Gateway.exe', runtimeRoot),
|
|
219
|
+
'other'
|
|
220
|
+
);
|
|
221
|
+
});
|