mcp-for-i 0.1.13 → 0.2.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.
Files changed (67) hide show
  1. package/README.md +17 -0
  2. package/dist/config/store.js +229 -109
  3. package/dist/controlplane/auth.js +128 -0
  4. package/dist/controlplane/clientIntegrationService.js +83 -4
  5. package/dist/controlplane/commands.js +14 -9
  6. package/dist/controlplane/connectionService.js +110 -5
  7. package/dist/controlplane/connectionTest.js +329 -0
  8. package/dist/controlplane/errors.js +12 -0
  9. package/dist/controlplane/runtimeService.js +98 -26
  10. package/dist/controlplane/server.js +122 -11
  11. package/dist/controlplane/serverShared.js +25 -6
  12. package/dist/controlplane/serviceManager.js +113 -100
  13. package/dist/controlplane/ui.js +1742 -920
  14. package/dist/ibmi/CompileTools.js +29 -1
  15. package/dist/ibmi/IBMiContent.js +435 -63
  16. package/dist/ibmi/IbmDocs.js +685 -0
  17. package/dist/ibmi/LocalLanguageActions.js +129 -3
  18. package/dist/ibmi/Search.js +158 -52
  19. package/dist/ibmi/Tools.js +7 -10
  20. package/dist/ibmi/client.js +220 -31
  21. package/dist/ibmi/evfevent.js +256 -0
  22. package/dist/ibmi/knownHosts.js +190 -0
  23. package/dist/ibmi/libraryList.js +93 -0
  24. package/dist/ibmi/sourceInspect.js +815 -0
  25. package/dist/ibmi/sqlJob.js +888 -0
  26. package/dist/ibmi/sqlParse.js +317 -0
  27. package/dist/ibmi/tn5250/certTrust.js +59 -0
  28. package/dist/ibmi/tn5250/client.js +310 -0
  29. package/dist/ibmi/tn5250/datastream.js +109 -0
  30. package/dist/ibmi/tn5250/ebcdic.js +74 -0
  31. package/dist/ibmi/tn5250/index.js +6 -0
  32. package/dist/ibmi/tn5250/screen.js +827 -0
  33. package/dist/ibmi/tn5250/telnet.js +186 -0
  34. package/dist/ibmi/variables.js +18 -7
  35. package/dist/index.js +43 -22
  36. package/dist/mcp/annotations.js +155 -0
  37. package/dist/mcp/audit.js +490 -133
  38. package/dist/mcp/confirm.js +48 -0
  39. package/dist/mcp/context.js +43 -5
  40. package/dist/mcp/deployMethods.js +192 -0
  41. package/dist/mcp/logger.js +5 -10
  42. package/dist/mcp/modules/bulkMembers.js +476 -0
  43. package/dist/mcp/modules/changedLines.js +189 -0
  44. package/dist/mcp/modules/compare.js +198 -0
  45. package/dist/mcp/modules/compile.js +515 -0
  46. package/dist/mcp/modules/ifsOps.js +123 -0
  47. package/dist/mcp/modules/index.js +38 -0
  48. package/dist/mcp/modules/inspect.js +811 -0
  49. package/dist/mcp/modules/libl.js +246 -0
  50. package/dist/mcp/modules/members.js +171 -0
  51. package/dist/mcp/modules/objects.js +339 -0
  52. package/dist/mcp/modules/system.js +266 -0
  53. package/dist/mcp/modules/tn5250.js +515 -0
  54. package/dist/mcp/policy.js +412 -0
  55. package/dist/mcp/tools.js +1700 -698
  56. package/dist/security/credentialStore.js +62 -13
  57. package/dist/security/localPaths.js +138 -0
  58. package/dist/security/redact.js +79 -0
  59. package/dist/util/atomicWrite.js +101 -0
  60. package/dist/util/fileLock.js +206 -0
  61. package/docs/capabilities.md +50 -7
  62. package/docs/configuration.md +189 -60
  63. package/docs/examples.md +109 -73
  64. package/docs/overview.md +15 -15
  65. package/docs/security.md +101 -16
  66. package/docs/tools.md +363 -188
  67. package/package.json +56 -53
@@ -0,0 +1,888 @@
1
+ import { EventEmitter } from "events";
2
+ import net from "net";
3
+ import tls from "tls";
4
+ import { Tools } from "./Tools.js";
5
+ import { splitSqlStatements, isReadOnlySql } from "./sqlParse.js";
6
+ import { checkCertificate, certificateFingerprint } from "./knownHosts.js";
7
+ import { desiredLibraryList, existingLibrariesSql, invalidNamesWarning, libraryListCommand, libraryNamesFromRows, referencedLibraries, resolveAgainstExisting } from "./libraryList.js";
8
+ export const DEFAULT_MAPEPIRE_PORT = 8076;
9
+ const DEFAULT_PROBE_TIMEOUT_MS = 4000;
10
+ const DEFAULT_CANCEL_GRACE_MS = 5000;
11
+ const DEFAULT_OPEN_WAIT_MS = 10000;
12
+ const FETCH_SIZE = 1000;
13
+ const MAPEPIRE_SERVER_PATHS = [
14
+ "/QOpenSys/pkgs/lib/mapepire/mapepire-server.jar",
15
+ "/opt/mapepire/lib/mapepire/mapepire-server.jar"
16
+ ];
17
+ const JAVA_PATHS = [
18
+ "/QOpenSys/QIBM/ProdData/JavaVM/jdk21/64bit/bin/java",
19
+ "/QOpenSys/QIBM/ProdData/JavaVM/jdk17/64bit/bin/java",
20
+ "/QOpenSys/QIBM/ProdData/JavaVM/jdk11/64bit/bin/java",
21
+ "/QOpenSys/QIBM/ProdData/JavaVM/jdk80/64bit/bin/java"
22
+ ];
23
+ /**
24
+ * JDBC properties for the job. System naming makes unqualified names resolve
25
+ * through the library list (like a 5250 session); commitment control is off so
26
+ * writes to files that are not journaled behave as they do under QZDFMDB2.
27
+ */
28
+ const JDBC_OPTIONS = {
29
+ naming: "system",
30
+ "transaction isolation": "none",
31
+ "date format": "iso",
32
+ "time format": "iso"
33
+ };
34
+ const NUMERIC_TYPES = new Set(["DECIMAL", "NUMERIC", "INTEGER", "SMALLINT", "BIGINT", "DECFLOAT", "DOUBLE", "REAL", "FLOAT"]);
35
+ /** A CL command failed; the message carries its diagnostic and escape messages. */
36
+ export class ClCommandError extends Tools.SqlError {
37
+ command;
38
+ messages;
39
+ constructor(command, messages) {
40
+ super(clFailureText(command, messages));
41
+ this.command = command;
42
+ this.messages = messages;
43
+ this.name = "ClCommandError";
44
+ this.sqlstate = "38501";
45
+ }
46
+ }
47
+ function clFailureText(command, messages) {
48
+ const relevant = messages.filter(m => /^(ESC|DIAG)/i.test(m.type || ""));
49
+ const detail = (relevant.length ? relevant : messages).map(m => (m.id ? `${m.id}: ${m.text}` : m.text)).join(" ");
50
+ return `${command.trim().split(/\s+/)[0]} failed${detail ? `: ${detail}` : ""}`;
51
+ }
52
+ /** Messages from the server's own machinery that say nothing about the command. */
53
+ export function isNoiseMessage(message) {
54
+ return message.id === "MCH3401" && /\bPPTT\b/.test(message.text);
55
+ }
56
+ const QCMDEXC_CALL = /^CALL\s+(?:(?:QSYS2|QSYS)\s*[./]\s*)?QCMDEXC\s*\(\s*'((?:[^']|'')*)'\s*(?:,\s*\d+(?:\.\d*)?\s*)?\)\s*$/i;
57
+ /** The CL command of `CALL QSYS2.QCMDEXC('...')` with a literal, else undefined. */
58
+ export function qcmdexcLiteral(statement) {
59
+ const match = statement.trim().match(QCMDEXC_CALL);
60
+ return match ? match[1].replace(/''/g, "'") : undefined;
61
+ }
62
+ /** Mapepire is not installed, not running or not reachable: falling back is fine. */
63
+ export class SqlJobUnavailableError extends Error {
64
+ constructor(message) {
65
+ super(message);
66
+ this.name = "SqlJobUnavailableError";
67
+ }
68
+ }
69
+ export function normalizeSqlTransport(value) {
70
+ const v = String(value ?? "").trim().toLowerCase();
71
+ return v === "mapepire" || v === "qzdfmdb2" ? v : "auto";
72
+ }
73
+ export function normalizeMapepireMode(value) {
74
+ const v = String(value ?? "").trim().toLowerCase();
75
+ return v === "daemon" || v === "ssh" ? v : "auto";
76
+ }
77
+ const JOB_NAME_RE = /^\d{6}\/[A-Z0-9#@$_.]{1,10}\/[A-Z0-9#@$_.]{1,10}$/i;
78
+ export function isQualifiedJobName(value) {
79
+ return typeof value === "string" && JOB_NAME_RE.test(value);
80
+ }
81
+ export class PersistentSqlJob {
82
+ options;
83
+ handle;
84
+ opening;
85
+ queue = Promise.resolve();
86
+ unavailableReason;
87
+ lastError;
88
+ libraryListWarning;
89
+ lastCancel;
90
+ /** The configured library list last applied, and to which job. */
91
+ appliedLibraryList;
92
+ closed = false;
93
+ factory;
94
+ constructor(options) {
95
+ this.options = options;
96
+ this.factory = options.factory || mapepireJobFactory;
97
+ }
98
+ transport() {
99
+ return normalizeSqlTransport(this.options.getConfig().sqlTransport);
100
+ }
101
+ status() {
102
+ const transport = this.transport();
103
+ const live = this.handle && this.handle.isAlive() ? this.handle : undefined;
104
+ let active = "not-started";
105
+ if (transport === "qzdfmdb2" || (transport === "auto" && this.unavailableReason))
106
+ active = "qzdfmdb2";
107
+ else if (live)
108
+ active = "mapepire";
109
+ const libraryListWarning = active === "qzdfmdb2"
110
+ ? this.options.fallbackLibraryListWarning?.()
111
+ : this.libraryListWarning ?? this.options.fallbackLibraryListWarning?.();
112
+ return {
113
+ transport,
114
+ active,
115
+ ...(live ? { mode: live.mode, ...(live.technique ? { technique: live.technique } : {}), jobName: live.jobName } : {}),
116
+ ...(this.unavailableReason ? { unavailableReason: this.unavailableReason } : {}),
117
+ ...(this.lastError ? { lastError: this.lastError } : {}),
118
+ ...(libraryListWarning ? { libraryListWarning } : {}),
119
+ ...(this.lastCancel ? { lastCancel: this.lastCancel } : {})
120
+ };
121
+ }
122
+ /** True when the persistent job is open and alive. */
123
+ isOpen() {
124
+ return Boolean(this.handle && this.handle.isAlive());
125
+ }
126
+ /**
127
+ * Runs SQL (one or more statements, `@` lines run as CL) in the persistent
128
+ * job. Returns undefined when the caller should use QZDFMDB2 instead.
129
+ */
130
+ async runSql(statements, options = {}) {
131
+ const handle = await this.acquire();
132
+ if (!handle)
133
+ return undefined;
134
+ try {
135
+ return await this.exclusive(options.signal, async () => {
136
+ await this.ensureLibraryList(handle);
137
+ return this.runAll(handle, statements, options.signal);
138
+ });
139
+ }
140
+ catch (err) {
141
+ // The job died under a read: nothing was changed, so reopen and retry once.
142
+ if (!handle.isAlive() && !options.signal?.aborted && isReadOnlySql(statements)) {
143
+ await this.discard(handle);
144
+ const next = await this.acquire();
145
+ if (!next)
146
+ return undefined;
147
+ return this.exclusive(options.signal, async () => {
148
+ await this.ensureLibraryList(next);
149
+ return this.runAll(next, statements, options.signal);
150
+ });
151
+ }
152
+ if (!handle.isAlive())
153
+ await this.discard(handle);
154
+ throw err;
155
+ }
156
+ }
157
+ /**
158
+ * Runs a CL command in the persistent job. Returns undefined when the job is
159
+ * not used, otherwise the job log messages the command sent.
160
+ */
161
+ async runCl(command, options = {}) {
162
+ const handle = await this.acquire();
163
+ if (!handle)
164
+ return undefined;
165
+ try {
166
+ return await this.exclusive(options.signal, async () => {
167
+ await this.ensureLibraryList(handle);
168
+ return this.execCl(handle, command, options.signal);
169
+ });
170
+ }
171
+ catch (err) {
172
+ if (!handle.isAlive())
173
+ await this.discard(handle);
174
+ throw err;
175
+ }
176
+ }
177
+ async close() {
178
+ this.closed = true;
179
+ const opening = this.opening;
180
+ if (opening)
181
+ await opening.catch(() => undefined);
182
+ const handle = this.handle;
183
+ this.handle = undefined;
184
+ if (handle)
185
+ await handle.close().catch(() => undefined);
186
+ }
187
+ async acquire() {
188
+ if (this.closed)
189
+ return undefined;
190
+ const transport = this.transport();
191
+ if (transport === "qzdfmdb2") {
192
+ if (this.handle)
193
+ await this.discard(this.handle);
194
+ return undefined;
195
+ }
196
+ if (this.handle) {
197
+ if (this.handle.isAlive())
198
+ return this.handle;
199
+ this.lastError = "The persistent SQL job ended; a new job was opened.";
200
+ await this.discard(this.handle);
201
+ }
202
+ if (transport === "auto" && this.unavailableReason)
203
+ return undefined;
204
+ if (!this.opening) {
205
+ this.opening = this.open(transport).finally(() => {
206
+ this.opening = undefined;
207
+ });
208
+ }
209
+ if (transport === "mapepire")
210
+ return this.opening;
211
+ // In auto mode a slow start (single mode starts a JVM) must not eat the
212
+ // caller's timeout: this call uses QZDFMDB2 and the job keeps opening.
213
+ const waitMs = this.options.openWaitMs ?? DEFAULT_OPEN_WAIT_MS;
214
+ let timer;
215
+ try {
216
+ return await Promise.race([
217
+ this.opening,
218
+ new Promise(resolve => { timer = setTimeout(() => resolve(undefined), waitMs); })
219
+ ]);
220
+ }
221
+ finally {
222
+ if (timer)
223
+ clearTimeout(timer);
224
+ }
225
+ }
226
+ async open(transport) {
227
+ const request = this.options.getOpenRequest();
228
+ if (!request) {
229
+ if (transport === "mapepire")
230
+ throw new Error("Not connected");
231
+ return undefined;
232
+ }
233
+ let handle;
234
+ try {
235
+ handle = await this.factory(request);
236
+ }
237
+ catch (err) {
238
+ const message = err?.message || String(err);
239
+ this.lastError = message;
240
+ if (transport === "mapepire") {
241
+ throw new Error(`Mapepire SQL job could not be opened (sqlTransport is "mapepire"): ${message}`);
242
+ }
243
+ // Cached for this connection: later SQL goes straight to QZDFMDB2.
244
+ this.unavailableReason = message;
245
+ return undefined;
246
+ }
247
+ if (this.closed) {
248
+ await handle.close().catch(() => undefined);
249
+ return undefined;
250
+ }
251
+ this.handle = handle;
252
+ this.unavailableReason = undefined;
253
+ return handle;
254
+ }
255
+ /**
256
+ * Applies the connection's configured library list again, even when it was
257
+ * applied before. Returns undefined when the persistent job is not used.
258
+ */
259
+ async reapplyLibraryList() {
260
+ const handle = await this.acquire();
261
+ if (!handle)
262
+ return undefined;
263
+ return this.exclusive(undefined, async () => {
264
+ this.appliedLibraryList = undefined;
265
+ await this.ensureLibraryList(handle);
266
+ return this.libraryListWarning ? { warning: this.libraryListWarning } : {};
267
+ });
268
+ }
269
+ /**
270
+ * Puts the connection's configured library list and current library in
271
+ * effect in the job, unless that list was already applied to this job. Runs
272
+ * inside the queue. A failure never fails the caller's statement: missing
273
+ * libraries are left out and the reason is kept for the session status.
274
+ */
275
+ async ensureLibraryList(handle) {
276
+ const desired = desiredLibraryList(this.options.getConfig());
277
+ const applied = this.appliedLibraryList;
278
+ if (applied && applied.handle === handle && applied.signature === desired.signature)
279
+ return;
280
+ // Recorded first, so a list that cannot be applied is not retried on every call.
281
+ this.appliedLibraryList = { handle, signature: desired.signature };
282
+ this.libraryListWarning = invalidNamesWarning(desired);
283
+ const command = libraryListCommand(desired.libraries, desired.currentLibrary);
284
+ if (!command)
285
+ return;
286
+ try {
287
+ await this.execCl(handle, command);
288
+ return;
289
+ }
290
+ catch (err) {
291
+ if (!handle.isAlive())
292
+ throw err;
293
+ const firstError = err?.message || String(err);
294
+ try {
295
+ const names = referencedLibraries(desired);
296
+ const result = await this.runStatement(handle, existingLibrariesSql(names)).catch(err => {
297
+ if (!handle.isAlive())
298
+ throw err;
299
+ // Older releases do not know *ALLSIMPLE.
300
+ return this.runStatement(handle, existingLibrariesSql(names, false));
301
+ });
302
+ const resolved = resolveAgainstExisting(desired, libraryNamesFromRows(result.rows));
303
+ if (!resolved.warning) {
304
+ this.libraryListWarning = `${command} failed in the SQL job: ${firstError}`;
305
+ return;
306
+ }
307
+ if (resolved.command)
308
+ await this.execCl(handle, resolved.command);
309
+ this.libraryListWarning = resolved.warning;
310
+ }
311
+ catch (retryErr) {
312
+ if (!handle.isAlive())
313
+ throw retryErr;
314
+ this.libraryListWarning = `${command} failed in the SQL job: ${firstError}`;
315
+ }
316
+ }
317
+ }
318
+ /** Runs CL so that its library list changes last (see the note at the top). */
319
+ async execCl(handle, command, signal) {
320
+ if (!handle.runCl) {
321
+ await this.runStatement(handle, "CALL QSYS2.QCMDEXC(?)", [command], signal);
322
+ return [];
323
+ }
324
+ const result = await this.withCancel(handle, handle.runCl(command, { signal }), signal);
325
+ const messages = (result.messages || []).filter(m => !isNoiseMessage(m));
326
+ if (!result.success)
327
+ throw new ClCommandError(command, messages);
328
+ return messages;
329
+ }
330
+ async discard(handle) {
331
+ if (this.handle === handle)
332
+ this.handle = undefined;
333
+ await handle.close().catch(() => undefined);
334
+ }
335
+ /** Statements share one job, so they run one at a time. */
336
+ exclusive(signal, task) {
337
+ const run = async () => {
338
+ if (signal?.aborted)
339
+ throw new Error("SQL statement cancelled before it started");
340
+ return task();
341
+ };
342
+ const result = this.queue.then(run, run);
343
+ this.queue = result.catch(() => undefined);
344
+ return result;
345
+ }
346
+ async runAll(handle, statements, signal) {
347
+ const rows = [];
348
+ let updateCount;
349
+ for (const statement of splitSqlStatements(statements)) {
350
+ if (signal?.aborted)
351
+ throw new Error("SQL statement cancelled");
352
+ const isCl = statement.tokens.length === 1 && statement.tokens[0].type === "cl";
353
+ const clCommand = isCl ? statement.tokens[0].value : qcmdexcLiteral(statement.text);
354
+ if (clCommand !== undefined) {
355
+ await this.execCl(handle, clCommand, signal);
356
+ continue;
357
+ }
358
+ const result = await this.runStatement(handle, statement.text, undefined, signal);
359
+ rows.push(...result.rows);
360
+ if (typeof result.updateCount === "number" && result.updateCount >= 0) {
361
+ updateCount = (updateCount ?? 0) + result.updateCount;
362
+ }
363
+ }
364
+ if (updateCount !== undefined) {
365
+ Object.defineProperty(rows, "updateCount", { value: updateCount, enumerable: false });
366
+ }
367
+ return rows;
368
+ }
369
+ async runStatement(handle, sql, parameters, signal) {
370
+ const work = handle.run(sql, { parameters, signal }).then(result => ({ rows: result.rows.map(row => normalizeRow(row, result.columns)), updateCount: result.updateCount }), err => { throw toSqlError(err, sql); });
371
+ return this.withCancel(handle, work, signal);
372
+ }
373
+ /** When `signal` aborts while `work` runs, the statement is cancelled on the IBM i. */
374
+ async withCancel(handle, work, signal) {
375
+ if (!signal)
376
+ return work;
377
+ if (signal.aborted) {
378
+ void this.cancelInFlight(handle, work);
379
+ return work;
380
+ }
381
+ const onAbort = () => { void this.cancelInFlight(handle, work); };
382
+ signal.addEventListener("abort", onAbort, { once: true });
383
+ try {
384
+ return await work;
385
+ }
386
+ finally {
387
+ signal.removeEventListener("abort", onAbort);
388
+ }
389
+ }
390
+ /**
391
+ * The caller timed out: ask the IBM i to cancel the statement (QSYS2.CANCEL_SQL
392
+ * from a separate job). If it is still running after a grace period, end the
393
+ * persistent job; the next call opens a new one and reapplies the library list.
394
+ */
395
+ async cancelInFlight(handle, work) {
396
+ let done = false;
397
+ const settled = work.then(() => { done = true; }, () => { done = true; });
398
+ const notes = [];
399
+ if (handle.jobName && isQualifiedJobName(handle.jobName) && this.options.cancelStatement) {
400
+ try {
401
+ await this.options.cancelStatement(handle.jobName);
402
+ notes.push(`CANCEL_SQL requested for ${handle.jobName}`);
403
+ }
404
+ catch (err) {
405
+ notes.push(`CANCEL_SQL failed: ${err?.message || String(err)}`);
406
+ }
407
+ }
408
+ if (!done) {
409
+ const grace = this.options.cancelGraceMs ?? DEFAULT_CANCEL_GRACE_MS;
410
+ let timer;
411
+ await Promise.race([settled, new Promise(resolve => { timer = setTimeout(resolve, grace); })]);
412
+ if (timer)
413
+ clearTimeout(timer);
414
+ }
415
+ if (!done) {
416
+ notes.push("statement still running; the SQL job was ended and will be reopened on the next call");
417
+ await this.discard(handle);
418
+ }
419
+ this.lastCancel = `${new Date().toISOString()}: ${notes.join("; ") || "cancelled"}`;
420
+ }
421
+ }
422
+ /** Mapepire values to the row shape QZDFMDB2 parsing produces (trimmed strings, numbers, null). */
423
+ export function normalizeRow(row, columns) {
424
+ const types = new Map();
425
+ for (const column of columns || []) {
426
+ if (column?.name)
427
+ types.set(column.name, String(column.type || "").toUpperCase());
428
+ }
429
+ const out = {};
430
+ for (const [key, value] of Object.entries(row || {})) {
431
+ if (value === undefined || value === null) {
432
+ out[key] = null;
433
+ }
434
+ else if (typeof value === "number") {
435
+ out[key] = value;
436
+ }
437
+ else if (typeof value === "string") {
438
+ const type = types.get(key);
439
+ if (type && NUMERIC_TYPES.has(type) && value.trim() !== "" && Number.isFinite(Number(value))) {
440
+ out[key] = Number(value);
441
+ }
442
+ else {
443
+ out[key] = value.trimEnd();
444
+ }
445
+ }
446
+ else if (typeof value === "boolean") {
447
+ out[key] = value ? "true" : "false";
448
+ }
449
+ else {
450
+ out[key] = JSON.stringify(value);
451
+ }
452
+ }
453
+ return out;
454
+ }
455
+ /** Mapepire errors look like "<message>, <SQLSTATE>, <SQLCODE>". */
456
+ export function toSqlError(err, sql) {
457
+ if (err instanceof Tools.SqlError)
458
+ return err;
459
+ const message = String(err?.message || err || "SQL error");
460
+ const match = message.match(/^(.*), ([0-9A-Z]{5}), (-?\d+)$/s);
461
+ if (!match)
462
+ return err instanceof Error ? err : new Error(message);
463
+ const sqlError = new Tools.SqlError(`${match[1]} (${match[2]})`);
464
+ sqlError.sqlstate = match[2];
465
+ sqlError.cause = sql;
466
+ return sqlError;
467
+ }
468
+ let mapepireModule;
469
+ async function loadMapepire() {
470
+ if (!mapepireModule) {
471
+ mapepireModule = import("@ibm/mapepire-js").then((m) => m.default ?? m);
472
+ mapepireModule.catch(() => { mapepireModule = undefined; });
473
+ }
474
+ try {
475
+ return await mapepireModule;
476
+ }
477
+ catch (err) {
478
+ throw new SqlJobUnavailableError(`The @ibm/mapepire-js package could not be loaded: ${err?.message || String(err)}`);
479
+ }
480
+ }
481
+ /**
482
+ * Opens a Mapepire job. "daemon" connects to the Mapepire server (port 8076,
483
+ * TLS, user and password). "ssh" starts mapepire-server in single mode over the
484
+ * connection's own SSH session, so it also works with key-based logins and
485
+ * needs no open port, only the server JAR and Java on the IBM i. "auto" uses
486
+ * the daemon when a password is available and falls back to ssh.
487
+ */
488
+ export const mapepireJobFactory = async (request) => {
489
+ const mapepire = await loadMapepire();
490
+ const mode = request.mode;
491
+ if (mode === "daemon" || (mode === "auto" && request.password)) {
492
+ try {
493
+ return await openDaemonJob(mapepire, request);
494
+ }
495
+ catch (err) {
496
+ if (mode === "daemon" || !(err instanceof SqlJobUnavailableError) || !request.ssh)
497
+ throw err;
498
+ try {
499
+ return await openSshJob(mapepire, request);
500
+ }
501
+ catch (sshErr) {
502
+ throw new SqlJobUnavailableError(`${err.message}; single mode over SSH: ${sshErr?.message || String(sshErr)}`);
503
+ }
504
+ }
505
+ }
506
+ return openSshJob(mapepire, request);
507
+ };
508
+ async function openDaemonJob(mapepire, request) {
509
+ if (!request.password) {
510
+ throw new SqlJobUnavailableError("The Mapepire server needs a password; this connection has none (use mapepireMode \"ssh\")");
511
+ }
512
+ const pin = await resolveCertificatePin(request.host, request.port, request.probeTimeoutMs);
513
+ const transport = new PinnedWebSocketTransport(pin, request.probeTimeoutMs * 2);
514
+ const job = new mapepire.SQLJob({ ...JDBC_OPTIONS }, transport);
515
+ try {
516
+ await job.connect({ host: request.host, port: request.port, user: request.username, password: request.password }, "mcp-for-i");
517
+ }
518
+ catch (err) {
519
+ await Promise.resolve(job.close()).catch(() => undefined);
520
+ throw err;
521
+ }
522
+ return wrapMapepireJob(job, "daemon", () => transport.isConnected());
523
+ }
524
+ async function openSshJob(mapepire, request) {
525
+ if (!request.ssh)
526
+ throw new SqlJobUnavailableError("No SSH session for Mapepire single mode");
527
+ const serverPath = await findRemoteFile(request, request.serverPath ? [request.serverPath] : MAPEPIRE_SERVER_PATHS);
528
+ if (!serverPath) {
529
+ throw new SqlJobUnavailableError(`mapepire-server.jar not found (${(request.serverPath ? [request.serverPath] : MAPEPIRE_SERVER_PATHS).join(", ")}); install it with "yum install mapepire-server"`);
530
+ }
531
+ const javaPath = await findRemoteFile(request, request.javaPath ? [request.javaPath] : JAVA_PATHS);
532
+ if (!javaPath)
533
+ throw new SqlJobUnavailableError("No Java runtime found for Mapepire single mode (set mapepireJavaPath)");
534
+ const start = async () => {
535
+ const job = mapepire.SQLJob.withConfig({
536
+ transport: "ssh-single",
537
+ sshSingle: {
538
+ exec: mapepire.createNodeSSHExec(request.ssh),
539
+ serverPath,
540
+ javaPath,
541
+ startupTimeout: 60000
542
+ }
543
+ }, { ...JDBC_OPTIONS });
544
+ try {
545
+ await job.connect(undefined, "mcp-for-i");
546
+ }
547
+ catch (err) {
548
+ await Promise.resolve(job.close()).catch(() => undefined);
549
+ throw err;
550
+ }
551
+ return job;
552
+ };
553
+ const wrap = (job, technique) => wrapMapepireJob(job, "ssh", () => {
554
+ try {
555
+ return Boolean(job.getTransport()?.isConnected());
556
+ }
557
+ catch {
558
+ return false;
559
+ }
560
+ }, technique);
561
+ // Single mode connects with the in-process CLI technique, which ignores the
562
+ // JDBC properties (so SQL naming, and unqualified names do not use the
563
+ // library list) and runs each SQL request on its own thread, so library
564
+ // list changes do not last. Switch the server to the TCP technique: a
565
+ // database host server job on the same system, signed on as the same user,
566
+ // with system naming and a library list that persists.
567
+ const job = await start();
568
+ try {
569
+ const response = await job.send({
570
+ id: mapepire.SQLJob.getNewUniqueId(),
571
+ type: "connect",
572
+ technique: "tcp",
573
+ application: "mcp-for-i",
574
+ props: jdbcPropertiesString(JDBC_OPTIONS)
575
+ });
576
+ if (response?.success === true) {
577
+ if (typeof response.job === "string")
578
+ job.id = response.job;
579
+ return wrap(job, "tcp");
580
+ }
581
+ throw new Error(response?.error || "connect failed");
582
+ }
583
+ catch {
584
+ // The failed reconnect may have dropped the CLI connection: start again.
585
+ await Promise.resolve(job.close()).catch(() => undefined);
586
+ }
587
+ return wrap(await start(), "cli");
588
+ }
589
+ /** JDBC properties as Mapepire's connect request carries them. */
590
+ export function jdbcPropertiesString(options) {
591
+ return Object.entries(options).map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(",") : value}`).join(";");
592
+ }
593
+ /** Mapepire reports single-mode CLI jobs as number-user-name. */
594
+ export function normalizeJobName(value) {
595
+ if (typeof value !== "string" || !value)
596
+ return undefined;
597
+ const dashed = value.match(/^(\d{6})-([^-/]{1,10})-([^-/]{1,10})$/);
598
+ return dashed ? `${dashed[1]}/${dashed[2]}/${dashed[3]}` : value;
599
+ }
600
+ async function findRemoteFile(request, candidates) {
601
+ const script = candidates
602
+ .map(p => `if [ -f ${Tools.shellQuote(p)} ]; then echo ${Tools.shellQuote(p)}; exit 0; fi`)
603
+ .join("; ");
604
+ try {
605
+ const result = await request.sendCommand({ command: `${script}; exit 1` });
606
+ const found = result.stdout.split(/\r?\n/).map(l => l.trim()).find(Boolean);
607
+ return found && candidates.includes(found) ? found : undefined;
608
+ }
609
+ catch {
610
+ return undefined;
611
+ }
612
+ }
613
+ function wrapMapepireJob(job, mode, connected, technique = "tcp") {
614
+ // SQLJob.close() drops its response listeners, so requests still waiting on
615
+ // the job would never settle. Track them and fail them on close.
616
+ const pending = new Set();
617
+ let closed = false;
618
+ const runQuery = async (sql, options) => {
619
+ const query = job.query(sql, options.parameters ? { parameters: options.parameters } : {});
620
+ let result = await query.execute(FETCH_SIZE);
621
+ const rows = Array.isArray(result.data) ? [...result.data] : [];
622
+ const columns = (result.metadata?.columns || []).map((c) => ({ name: c.name || c.label, type: c.type }));
623
+ while (result.has_results && !result.is_done) {
624
+ if (options.signal?.aborted) {
625
+ await Promise.resolve(query.close()).catch(() => undefined);
626
+ throw new Error("SQL statement cancelled");
627
+ }
628
+ result = await query.fetchMore(FETCH_SIZE);
629
+ if (Array.isArray(result.data))
630
+ rows.push(...result.data);
631
+ }
632
+ const updateCount = result.has_results ? undefined : result.update_count;
633
+ return { rows, columns, updateCount: typeof updateCount === "number" ? updateCount : undefined };
634
+ };
635
+ // A CL request answers with the job log messages the command sent, and
636
+ // success false when it failed; it does not reject.
637
+ const runClCommand = async (command) => {
638
+ const query = job.clcommand(command);
639
+ const result = await query.execute(FETCH_SIZE);
640
+ if (result.has_results && !result.is_done)
641
+ await Promise.resolve(query.close()).catch(() => undefined);
642
+ const rows = Array.isArray(result.data) ? result.data : [];
643
+ const messages = rows.map(row => ({
644
+ id: row.MESSAGE_ID ? String(row.MESSAGE_ID).trim() : undefined,
645
+ type: row.MESSAGE_TYPE ? String(row.MESSAGE_TYPE).trim() : undefined,
646
+ text: String(row.MESSAGE_TEXT ?? row.SUMMARY ?? "").trim()
647
+ }));
648
+ if (result.success === false && messages.length === 0 && result.error) {
649
+ messages.push({ type: "ESCAPE", text: String(result.error) });
650
+ }
651
+ return { success: result.success !== false, messages };
652
+ };
653
+ return {
654
+ mode,
655
+ technique,
656
+ get jobName() {
657
+ return normalizeJobName(job.id);
658
+ },
659
+ isAlive() {
660
+ if (closed)
661
+ return false;
662
+ const status = String(job.getStatus?.() ?? "");
663
+ return status !== "ended" && status !== "notStarted" && connected();
664
+ },
665
+ run(sql, options) {
666
+ if (closed)
667
+ return Promise.reject(new Error("SQL job closed"));
668
+ return new Promise((resolve, reject) => {
669
+ pending.add(reject);
670
+ runQuery(sql, options).then(resolve, reject).finally(() => pending.delete(reject));
671
+ });
672
+ },
673
+ runCl(command) {
674
+ if (closed)
675
+ return Promise.reject(new Error("SQL job closed"));
676
+ return new Promise((resolve, reject) => {
677
+ pending.add(reject);
678
+ runClCommand(command).then(resolve, reject).finally(() => pending.delete(reject));
679
+ });
680
+ },
681
+ async close() {
682
+ closed = true;
683
+ const error = new Error("SQL job closed");
684
+ for (const reject of pending)
685
+ reject(error);
686
+ pending.clear();
687
+ await job.close();
688
+ }
689
+ };
690
+ }
691
+ /**
692
+ * Reads the server certificate chain (no credentials are sent on this
693
+ * connection). A chain that ends in a publicly trusted CA is verified the
694
+ * normal way. Otherwise (self-signed, as Mapepire generates by default, or a
695
+ * private CA) the root is pinned on first use in known_hosts.json and must
696
+ * match on every later connection.
697
+ */
698
+ export async function resolveCertificatePin(host, port, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
699
+ const leaf = await fetchCertificateChain(host, port, timeoutMs);
700
+ const root = findRootCertificate(leaf);
701
+ const pem = derToPem(root.raw);
702
+ const isPublic = tls.rootCertificates.some(ca => ca.replace(/\s+/g, "") === pem.replace(/\s+/g, ""));
703
+ if (isPublic)
704
+ return { host, port };
705
+ const subject = root.subject ? Object.entries(root.subject).map(([k, v]) => `${k}=${v}`).join(", ") : undefined;
706
+ const { fingerprint } = await checkCertificate({ host, port, der: root.raw, subject });
707
+ return { host, port, ca: pem, fingerprint };
708
+ }
709
+ function fetchCertificateChain(host, port, timeoutMs) {
710
+ return new Promise((resolve, reject) => {
711
+ let finished = false;
712
+ const finish = (fn) => {
713
+ if (finished)
714
+ return;
715
+ finished = true;
716
+ clearTimeout(timer);
717
+ socket.destroy();
718
+ fn();
719
+ };
720
+ // Chain verification happens later against the pinned certificate; this
721
+ // connection only reads the certificate and sends nothing.
722
+ const socket = tls.connect({
723
+ host,
724
+ port,
725
+ servername: net.isIP(host) ? undefined : host,
726
+ rejectUnauthorized: false
727
+ });
728
+ const timer = setTimeout(() => {
729
+ finish(() => reject(new SqlJobUnavailableError(`Mapepire server not reachable on ${host}:${port} (no answer in ${timeoutMs}ms)`)));
730
+ }, timeoutMs);
731
+ socket.once("secureConnect", () => {
732
+ const cert = socket.getPeerCertificate(true);
733
+ finish(() => {
734
+ if (!cert || !cert.raw)
735
+ reject(new Error(`Mapepire server on ${host}:${port} presented no certificate`));
736
+ else
737
+ resolve(cert);
738
+ });
739
+ });
740
+ socket.once("error", (err) => {
741
+ finish(() => {
742
+ const code = err?.code ? ` (${err.code})` : "";
743
+ reject(new SqlJobUnavailableError(`Mapepire server not reachable on ${host}:${port}${code}: ${err?.message || String(err)}`));
744
+ });
745
+ });
746
+ });
747
+ }
748
+ function findRootCertificate(leaf) {
749
+ let current = leaf;
750
+ const seen = new Set();
751
+ while (current.issuerCertificate && current.issuerCertificate !== current) {
752
+ const fp = current.fingerprint256;
753
+ if (seen.has(fp))
754
+ break;
755
+ seen.add(fp);
756
+ if (!current.issuerCertificate.raw)
757
+ break;
758
+ current = current.issuerCertificate;
759
+ }
760
+ return current;
761
+ }
762
+ export function derToPem(der) {
763
+ const b64 = der.toString("base64");
764
+ const lines = [];
765
+ for (let i = 0; i < b64.length; i += 64)
766
+ lines.push(b64.slice(i, i + 64));
767
+ return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----\n`;
768
+ }
769
+ /** True when the presented chain contains the pinned certificate. */
770
+ export function chainHasFingerprint(cert, fingerprint) {
771
+ let current = cert;
772
+ const seen = new Set();
773
+ while (current && current.raw && !seen.has(current)) {
774
+ seen.add(current);
775
+ if (certificateFingerprint(current.raw) === fingerprint)
776
+ return true;
777
+ current = current.issuerCertificate;
778
+ }
779
+ return false;
780
+ }
781
+ /**
782
+ * WebSocket transport for Mapepire's SQLJob (same protocol as the library's
783
+ * WebSocketTransport) that verifies the server against the pinned certificate.
784
+ * The chain must validate against the pinned root and contain it; the host
785
+ * name is not compared because Mapepire's self-signed certificate rarely
786
+ * matches the name or address used to connect. Without a pin the normal CA and
787
+ * host name checks apply. Certificate errors are never ignored.
788
+ */
789
+ export class PinnedWebSocketTransport {
790
+ pin;
791
+ timeoutMs;
792
+ socket;
793
+ connected = false;
794
+ responseEmitter = new EventEmitter();
795
+ constructor(pin, timeoutMs = 8000) {
796
+ this.pin = pin;
797
+ this.timeoutMs = timeoutMs;
798
+ }
799
+ async connect(server) {
800
+ const { default: WebSocket } = await import("ws");
801
+ const host = server.host;
802
+ const port = server.port || DEFAULT_MAPEPIRE_PORT;
803
+ const urlHost = net.isIPv6(host) ? `[${host}]` : host;
804
+ const fingerprint = this.pin.fingerprint;
805
+ const options = {
806
+ headers: {
807
+ authorization: `Basic ${Buffer.from(`${server.user}:${server.password}`).toString("base64")}`
808
+ },
809
+ handshakeTimeout: this.timeoutMs,
810
+ rejectUnauthorized: true
811
+ };
812
+ if (this.pin.ca && fingerprint) {
813
+ options.ca = this.pin.ca;
814
+ options.allowPartialTrustChain = true;
815
+ options.checkServerIdentity = (_host, cert) => chainHasFingerprint(cert, fingerprint)
816
+ ? undefined
817
+ : new Error(`Mapepire server certificate does not match the pinned certificate ${fingerprint}`);
818
+ }
819
+ await new Promise((resolve, reject) => {
820
+ let opened = false;
821
+ const ws = new WebSocket(`wss://${urlHost}:${port}/db/`, options);
822
+ ws.on("error", (err) => {
823
+ if (!opened)
824
+ reject(err);
825
+ });
826
+ ws.on("message", (data) => {
827
+ let response;
828
+ try {
829
+ response = JSON.parse(data.toString(), ((_key, value, context) => {
830
+ // Keep the server's exact text for numbers JavaScript would round.
831
+ if (context && typeof value === "number" && context.source !== undefined && String(value) !== context.source) {
832
+ return context.source;
833
+ }
834
+ return value;
835
+ }));
836
+ }
837
+ catch {
838
+ return;
839
+ }
840
+ if (response && typeof response.id === "string")
841
+ this.responseEmitter.emit(response.id, response);
842
+ });
843
+ ws.on("close", (code, reason) => {
844
+ this.connected = false;
845
+ this.notifyConnectionFailure(code, reason?.toString() || "");
846
+ if (!opened)
847
+ reject(new Error(`Mapepire connection closed (${code})`));
848
+ });
849
+ ws.once("open", () => {
850
+ opened = true;
851
+ this.socket = ws;
852
+ this.connected = true;
853
+ resolve();
854
+ });
855
+ });
856
+ }
857
+ async send(request) {
858
+ if (!this.socket || !this.connected) {
859
+ // Fail the waiting request instead of leaving it pending.
860
+ setImmediate(() => this.responseEmitter.emit(`${request.id}_conn_fail`, new Error("Mapepire connection is not open")));
861
+ return;
862
+ }
863
+ this.socket.send(JSON.stringify(request));
864
+ }
865
+ async close() {
866
+ const socket = this.socket;
867
+ this.socket = undefined;
868
+ this.connected = false;
869
+ if (socket)
870
+ socket.close();
871
+ this.notifyConnectionFailure(1000, "closed");
872
+ }
873
+ getResponseEmitter() {
874
+ return this.responseEmitter;
875
+ }
876
+ isConnected() {
877
+ return this.connected;
878
+ }
879
+ enableTrace() { }
880
+ disableTrace() { }
881
+ notifyConnectionFailure(code, reason) {
882
+ const events = this.responseEmitter.eventNames().filter(e => typeof e === "string" && e.endsWith("_conn_fail"));
883
+ for (const event of events) {
884
+ this.responseEmitter.emit(event, new Error(`Connection failed with code ${code}${reason ? `: ${reason}` : ""}`));
885
+ }
886
+ this.responseEmitter.removeAllListeners();
887
+ }
888
+ }