mcp-for-i 0.1.7 → 0.1.8
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 +2 -0
- package/dist/ibmi/CompileTools.js +25 -7
- package/dist/ibmi/IBMiContent.js +56 -34
- package/dist/ibmi/Search.js +9 -1
- package/dist/ibmi/client.js +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp/tools.js +578 -61
- package/docs/tools.md +15 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -148,6 +148,8 @@ npm run release:patch -- --no-push
|
|
|
148
148
|
- Passwords are stored in keychain when available.
|
|
149
149
|
- Control plane is local-first (`127.0.0.1` by default).
|
|
150
150
|
- Guarded policy profile is the default for operational safety.
|
|
151
|
+
- Tool schemas are strict (`additionalProperties: false`) with runtime argument validation, including nested connection/action/filter/profile payloads.
|
|
152
|
+
- QSYS object names and source settings are validated before command execution to reduce interpolation and injection risk.
|
|
151
153
|
|
|
152
154
|
## Links
|
|
153
155
|
|
|
@@ -76,16 +76,34 @@ export var CompileTools;
|
|
|
76
76
|
}
|
|
77
77
|
CompileTools.runCommand = runCommand;
|
|
78
78
|
function buildLibraryList(config) {
|
|
79
|
-
return config.libraryList
|
|
79
|
+
return config.libraryList
|
|
80
|
+
.slice(0)
|
|
81
|
+
.map(lib => String(lib || "").trim().toUpperCase())
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.reverse();
|
|
80
84
|
}
|
|
81
85
|
function buildLiblistCommands(connection, config) {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
const defaultLibNames = (connection.defaultUserLibraries || [])
|
|
87
|
+
.filter(l => l && l !== "QSYS")
|
|
88
|
+
.map(l => assertLibraryName(connection, l, "default library"));
|
|
89
|
+
const userLibNames = buildLibraryList(config).map(l => assertLibraryName(connection, l, "library list"));
|
|
90
|
+
const currentLib = assertLibraryName(connection, config.currentLibrary || "QGPL", "current library");
|
|
91
|
+
const defaultLibs = Tools.sanitizeObjNamesForPase(defaultLibNames);
|
|
92
|
+
const userLibs = Tools.sanitizeObjNamesForPase(userLibNames);
|
|
93
|
+
const commands = [
|
|
86
94
|
`liblist -d ${IBMiClient.escapeForShell(defaultLibs.join(` `))}`,
|
|
87
|
-
`liblist -c ${IBMiClient.escapeForShell(currentLib)}
|
|
88
|
-
`liblist -a ${IBMiClient.escapeForShell(userLibs.join(` `))}`
|
|
95
|
+
`liblist -c ${IBMiClient.escapeForShell(Tools.sanitizeObjNamesForPase([currentLib])[0])}`
|
|
89
96
|
];
|
|
97
|
+
if (userLibs.length > 0) {
|
|
98
|
+
commands.push(`liblist -a ${IBMiClient.escapeForShell(userLibs.join(` `))}`);
|
|
99
|
+
}
|
|
100
|
+
return commands;
|
|
90
101
|
}
|
|
91
102
|
})(CompileTools || (CompileTools = {}));
|
|
103
|
+
function assertLibraryName(connection, value, context) {
|
|
104
|
+
const normalized = String(value || "").trim().toUpperCase();
|
|
105
|
+
if (!normalized || !connection.validQsysName(normalized)) {
|
|
106
|
+
throw new Error(`Invalid ${context}: ${value}`);
|
|
107
|
+
}
|
|
108
|
+
return normalized;
|
|
109
|
+
}
|
package/dist/ibmi/IBMiContent.js
CHANGED
|
@@ -86,11 +86,11 @@ export class IBMiContent {
|
|
|
86
86
|
if (withDates !== undefined)
|
|
87
87
|
return withDates;
|
|
88
88
|
}
|
|
89
|
-
const lib = this.
|
|
90
|
-
const file = this.
|
|
91
|
-
const mbr = this.
|
|
89
|
+
const lib = this.requireQsysName(library, "library");
|
|
90
|
+
const file = this.requireQsysName(sourceFile, "sourceFile");
|
|
91
|
+
const mbr = this.requireQsysName(member, "member");
|
|
92
92
|
const tempRemote = this.ibmi.getTempRemote("mbr");
|
|
93
|
-
const ccsid = this.config.sourceFileCCSID
|
|
93
|
+
const ccsid = this.normalizeSourceFileCcsid(this.config.sourceFileCCSID);
|
|
94
94
|
const cmd = `QSYS/CPYTOSTMF FROMMBR('${Tools.qualifyPath(lib, file, mbr, undefined, true)}') ` +
|
|
95
95
|
`TOSTMF('${tempRemote}') STMFOPT(*REPLACE) STMFCCSID(1208) DBFCCSID(${ccsid})`;
|
|
96
96
|
const result = await this.ibmi.sendQsh({ command: `system \"${IBMiClient.escapeForShell(cmd)}\"` });
|
|
@@ -114,12 +114,12 @@ export class IBMiContent {
|
|
|
114
114
|
}
|
|
115
115
|
async uploadMemberContentRaw(library, sourceFile, member, content) {
|
|
116
116
|
this.assertWritable();
|
|
117
|
-
const lib = this.
|
|
118
|
-
const file = this.
|
|
119
|
-
const mbr = this.
|
|
117
|
+
const lib = this.requireQsysName(library, "library");
|
|
118
|
+
const file = this.requireQsysName(sourceFile, "sourceFile");
|
|
119
|
+
const mbr = this.requireQsysName(member, "member");
|
|
120
120
|
const tempRemote = this.ibmi.getTempRemote("mbr");
|
|
121
121
|
await this.writeStreamfileRaw(tempRemote, content);
|
|
122
|
-
const ccsid = this.config.sourceFileCCSID
|
|
122
|
+
const ccsid = this.normalizeSourceFileCcsid(this.config.sourceFileCCSID);
|
|
123
123
|
const cmd = `QSYS/CPYFRMSTMF FROMSTMF('${tempRemote}') ` +
|
|
124
124
|
`TOMBR('${Tools.qualifyPath(lib, file, mbr, undefined, true)}') MBROPT(*REPLACE) ` +
|
|
125
125
|
`STMFCCSID(1208) DBFCCSID(${ccsid})`;
|
|
@@ -181,7 +181,7 @@ export class IBMiContent {
|
|
|
181
181
|
return badLibs;
|
|
182
182
|
}
|
|
183
183
|
async getObjectList(library, types = ["*ALL"]) {
|
|
184
|
-
const lib = this.
|
|
184
|
+
const lib = this.requireQsysName(library, "library");
|
|
185
185
|
const safeTypes = types.map(t => this.ibmi.upperCaseName(t));
|
|
186
186
|
const hasInvalidType = safeTypes.some(t => !/^[*A-Z0-9]+$/.test(t));
|
|
187
187
|
if (hasInvalidType)
|
|
@@ -197,8 +197,8 @@ export class IBMiContent {
|
|
|
197
197
|
}));
|
|
198
198
|
}
|
|
199
199
|
async getMemberList(options) {
|
|
200
|
-
const lib = this.
|
|
201
|
-
const file = this.
|
|
200
|
+
const lib = this.requireQsysName(options.library, "library");
|
|
201
|
+
const file = this.requireQsysName(options.sourceFile, "sourceFile");
|
|
202
202
|
const rows = await this.ibmi.runSQL(`select SYSTEM_TABLE_MEMBER, SOURCE_TYPE, PARTITION_TEXT from QSYS2.SYSPARTITIONSTAT where SYSTEM_TABLE_SCHEMA=${Tools.sqlString(lib)} and SYSTEM_TABLE_NAME=${Tools.sqlString(file)}`);
|
|
203
203
|
return rows.map(r => ({
|
|
204
204
|
library: lib,
|
|
@@ -209,9 +209,9 @@ export class IBMiContent {
|
|
|
209
209
|
}));
|
|
210
210
|
}
|
|
211
211
|
async getMemberInfo(library, sourceFile, member) {
|
|
212
|
-
const lib = this.
|
|
213
|
-
const file = this.
|
|
214
|
-
const mbr = this.
|
|
212
|
+
const lib = this.requireQsysName(library, "library");
|
|
213
|
+
const file = this.requireQsysName(sourceFile, "sourceFile");
|
|
214
|
+
const mbr = this.requireQsysName(member, "member");
|
|
215
215
|
const rows = await this.ibmi.runSQL(`select SYSTEM_TABLE_MEMBER, SOURCE_TYPE, PARTITION_TEXT from QSYS2.SYSPARTITIONSTAT where SYSTEM_TABLE_SCHEMA=${Tools.sqlString(lib)} and SYSTEM_TABLE_NAME=${Tools.sqlString(file)} and SYSTEM_TABLE_MEMBER=${Tools.sqlString(mbr)}`);
|
|
216
216
|
const row = rows[0];
|
|
217
217
|
if (!row)
|
|
@@ -225,9 +225,9 @@ export class IBMiContent {
|
|
|
225
225
|
};
|
|
226
226
|
}
|
|
227
227
|
async downloadMemberContentWithDates(library, sourceFile, member) {
|
|
228
|
-
const lib = this.
|
|
229
|
-
const file = this.
|
|
230
|
-
const mbr = this.
|
|
228
|
+
const lib = this.requireQsysName(library, "library");
|
|
229
|
+
const file = this.requireQsysName(sourceFile, "sourceFile");
|
|
230
|
+
const mbr = this.requireQsysName(member, "member");
|
|
231
231
|
const tempLib = await this.getTempLibrary();
|
|
232
232
|
if (!tempLib)
|
|
233
233
|
return undefined;
|
|
@@ -251,9 +251,9 @@ export class IBMiContent {
|
|
|
251
251
|
}
|
|
252
252
|
async uploadMemberContentWithDates(library, sourceFile, member, body) {
|
|
253
253
|
this.assertWritable();
|
|
254
|
-
const lib = this.
|
|
255
|
-
const file = this.
|
|
256
|
-
const mbr = this.
|
|
254
|
+
const lib = this.requireQsysName(library, "library");
|
|
255
|
+
const file = this.requireQsysName(sourceFile, "sourceFile");
|
|
256
|
+
const mbr = this.requireQsysName(member, "member");
|
|
257
257
|
const tempLib = await this.getTempLibrary();
|
|
258
258
|
if (!tempLib) {
|
|
259
259
|
throw new Error(`Temp library is not available for source-date updates.`);
|
|
@@ -322,42 +322,64 @@ export class IBMiContent {
|
|
|
322
322
|
}
|
|
323
323
|
async createLibrary(library) {
|
|
324
324
|
this.assertWritable();
|
|
325
|
-
const lib = this.
|
|
325
|
+
const lib = this.requireQsysName(library, "library");
|
|
326
326
|
return this.ibmi.sendQsh({ command: `system \"CRTLIB LIB(${lib})\"` });
|
|
327
327
|
}
|
|
328
328
|
async createSourceFile(library, file, rcdlen = 112) {
|
|
329
329
|
this.assertWritable();
|
|
330
|
-
const lib = this.
|
|
331
|
-
const src = this.
|
|
330
|
+
const lib = this.requireQsysName(library, "library");
|
|
331
|
+
const src = this.requireQsysName(file, "sourceFile");
|
|
332
332
|
return this.ibmi.sendQsh({ command: `system \"CRTSRCPF FILE(${lib}/${src}) RCDLEN(${rcdlen})\"` });
|
|
333
333
|
}
|
|
334
334
|
async createMember(library, file, member, srctype) {
|
|
335
335
|
this.assertWritable();
|
|
336
|
-
const lib = this.
|
|
337
|
-
const src = this.
|
|
338
|
-
const mbr = this.
|
|
339
|
-
const type =
|
|
336
|
+
const lib = this.requireQsysName(library, "library");
|
|
337
|
+
const src = this.requireQsysName(file, "sourceFile");
|
|
338
|
+
const mbr = this.requireQsysName(member, "member");
|
|
339
|
+
const type = this.normalizeSourceType(srctype);
|
|
340
340
|
return this.ibmi.sendQsh({ command: `system \"ADDPFM FILE(${lib}/${src}) MBR(${mbr}) SRCTYPE(${type})\"` });
|
|
341
341
|
}
|
|
342
342
|
async renameMember(library, file, member, newMember) {
|
|
343
343
|
this.assertWritable();
|
|
344
|
-
const lib = this.
|
|
345
|
-
const src = this.
|
|
346
|
-
const mbr = this.
|
|
347
|
-
const newMbr = this.
|
|
344
|
+
const lib = this.requireQsysName(library, "library");
|
|
345
|
+
const src = this.requireQsysName(file, "sourceFile");
|
|
346
|
+
const mbr = this.requireQsysName(member, "member");
|
|
347
|
+
const newMbr = this.requireQsysName(newMember, "newMember");
|
|
348
348
|
return this.ibmi.sendQsh({ command: `system \"RNMM FILE(${lib}/${src}) MBR(${mbr}) NEWMBR(${newMbr})\"` });
|
|
349
349
|
}
|
|
350
350
|
async deleteMember(library, file, member) {
|
|
351
351
|
this.assertWritable();
|
|
352
|
-
const lib = this.
|
|
353
|
-
const src = this.
|
|
354
|
-
const mbr = this.
|
|
352
|
+
const lib = this.requireQsysName(library, "library");
|
|
353
|
+
const src = this.requireQsysName(file, "sourceFile");
|
|
354
|
+
const mbr = this.requireQsysName(member, "member");
|
|
355
355
|
return this.ibmi.sendQsh({ command: `system \"RMVM FILE(${lib}/${src}) MBR(${mbr})\"` });
|
|
356
356
|
}
|
|
357
357
|
async createTempFile() {
|
|
358
358
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "mcpfori-"));
|
|
359
359
|
return path.join(dir, "tmp");
|
|
360
360
|
}
|
|
361
|
+
requireQsysName(value, label) {
|
|
362
|
+
const normalized = this.ibmi.upperCaseName(String(value || "").trim());
|
|
363
|
+
if (!normalized || !this.ibmi.validQsysName(normalized)) {
|
|
364
|
+
throw new Error(`Invalid ${label}: ${value}`);
|
|
365
|
+
}
|
|
366
|
+
return normalized;
|
|
367
|
+
}
|
|
368
|
+
normalizeSourceType(value) {
|
|
369
|
+
const normalized = this.ibmi.upperCaseName(String(value || "*NONE").trim());
|
|
370
|
+
if (!/^\*?[A-Z0-9#@$._]{1,10}$/.test(normalized)) {
|
|
371
|
+
throw new Error(`Invalid source type: ${value}`);
|
|
372
|
+
}
|
|
373
|
+
return normalized;
|
|
374
|
+
}
|
|
375
|
+
normalizeSourceFileCcsid(value) {
|
|
376
|
+
const normalized = String(value || "*FILE").trim().toUpperCase();
|
|
377
|
+
if (/^\d{1,5}$/.test(normalized))
|
|
378
|
+
return normalized;
|
|
379
|
+
if (/^\*[A-Z0-9]{2,10}$/.test(normalized))
|
|
380
|
+
return normalized;
|
|
381
|
+
throw new Error(`Invalid sourceFileCCSID setting: ${value}`);
|
|
382
|
+
}
|
|
361
383
|
assertWritable() {
|
|
362
384
|
if (this.config.readOnlyMode) {
|
|
363
385
|
throw new Error("Connection is in read-only mode");
|
package/dist/ibmi/Search.js
CHANGED
|
@@ -14,6 +14,14 @@ export var Search;
|
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
16
|
async function searchMembers(connection, library, sourceFile, searchTerm, members, readOnly) {
|
|
17
|
+
const lib = String(library || "").trim().toUpperCase();
|
|
18
|
+
const src = String(sourceFile || "").trim().toUpperCase();
|
|
19
|
+
if (!connection.validQsysName(lib)) {
|
|
20
|
+
throw new Error(`Invalid library: ${library}`);
|
|
21
|
+
}
|
|
22
|
+
if (!connection.validQsysName(src)) {
|
|
23
|
+
throw new Error(`Invalid source file: ${sourceFile}`);
|
|
24
|
+
}
|
|
17
25
|
let detailedMembers;
|
|
18
26
|
let memberFilter;
|
|
19
27
|
const pfgrep = connection.remoteFeatures.pfgrep;
|
|
@@ -42,7 +50,7 @@ export var Search;
|
|
|
42
50
|
: `/usr/bin/grep -inHR -F "${sanitizeSearchTerm(searchTerm)}" ${memberFilter}`;
|
|
43
51
|
const result = await connection.sendCommand({
|
|
44
52
|
command,
|
|
45
|
-
directory: `/QSYS.LIB/${
|
|
53
|
+
directory: `/QSYS.LIB/${lib}.LIB/${src}.FILE`
|
|
46
54
|
});
|
|
47
55
|
if (!result.stderr) {
|
|
48
56
|
let hits = parseGrepOutput(result.stdout || '', readOnly);
|
package/dist/ibmi/client.js
CHANGED
|
@@ -39,7 +39,7 @@ export class IBMiClient {
|
|
|
39
39
|
if (name.length > 10)
|
|
40
40
|
return false;
|
|
41
41
|
const upper = this.upperCaseName(name);
|
|
42
|
-
return /^[A-Z][A-Z0-
|
|
42
|
+
return /^[A-Z#@$][A-Z0-9#@$._]{0,9}$/.test(upper);
|
|
43
43
|
}
|
|
44
44
|
async connect(connection) {
|
|
45
45
|
this.currentHost = connection.host;
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { getTools, handleTool } from "./mcp/tools.js";
|
|
|
6
6
|
import { McpContext } from "./mcp/context.js";
|
|
7
7
|
import { log } from "./mcp/logger.js";
|
|
8
8
|
import { validateToolInput } from "./mcp/validation.js";
|
|
9
|
-
const server = new Server({ name: "mcp-for-i", version: "0.1.
|
|
9
|
+
const server = new Server({ name: "mcp-for-i", version: "0.1.7" }, { capabilities: { tools: {} } });
|
|
10
10
|
const ctx = new McpContext();
|
|
11
11
|
const tools = getTools();
|
|
12
12
|
const toolMap = new Map(tools.map(tool => [tool.name, tool]));
|
package/dist/mcp/tools.js
CHANGED
|
@@ -6,6 +6,89 @@ import { ConnectionService } from "../controlplane/connectionService.js";
|
|
|
6
6
|
import { log } from "./logger.js";
|
|
7
7
|
import fs from "fs/promises";
|
|
8
8
|
import path from "path";
|
|
9
|
+
const CUSTOM_VARIABLE_SCHEMA = {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
name: { type: "string" },
|
|
13
|
+
value: { type: "string" }
|
|
14
|
+
},
|
|
15
|
+
required: ["name", "value"]
|
|
16
|
+
};
|
|
17
|
+
const OBJECT_FILTER_SCHEMA = {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
name: { type: "string" },
|
|
21
|
+
library: { type: "string" },
|
|
22
|
+
object: { type: "string" },
|
|
23
|
+
types: { type: "array", items: { type: "string" } },
|
|
24
|
+
member: { type: "string" },
|
|
25
|
+
memberType: { type: "string" },
|
|
26
|
+
protected: { type: "boolean" },
|
|
27
|
+
filterType: { type: "string", enum: ["simple", "regex"] }
|
|
28
|
+
},
|
|
29
|
+
required: ["name", "library", "object", "types", "member"]
|
|
30
|
+
};
|
|
31
|
+
const SETTINGS_SCHEMA = {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: {
|
|
34
|
+
readOnlyMode: { type: "boolean" },
|
|
35
|
+
tempLibrary: { type: "string" },
|
|
36
|
+
tempDir: { type: "string" },
|
|
37
|
+
autoClearTempData: { type: "boolean" },
|
|
38
|
+
sourceFileCCSID: { type: "string" },
|
|
39
|
+
sqlJobCcsid: { type: ["string", "number"] },
|
|
40
|
+
enableSourceDates: { type: "boolean" },
|
|
41
|
+
homeDirectory: { type: "string" },
|
|
42
|
+
libraryList: { type: "array", items: { type: "string" } },
|
|
43
|
+
currentLibrary: { type: "string" },
|
|
44
|
+
customVariables: { type: "array", items: CUSTOM_VARIABLE_SCHEMA },
|
|
45
|
+
objectFilters: { type: "array", items: OBJECT_FILTER_SCHEMA },
|
|
46
|
+
ifsShortcuts: { type: "array", items: { type: "string" } },
|
|
47
|
+
debugPort: { type: "number" },
|
|
48
|
+
debugSepPort: { type: "number" },
|
|
49
|
+
sessionIdleMinutes: { type: "number" },
|
|
50
|
+
sessionPingSeconds: { type: "number" },
|
|
51
|
+
sessionReconnectAttempts: { type: "number" }
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const POLICY_SCHEMA = {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
profile: { type: "string", enum: ["read-only", "guarded", "power-user"] },
|
|
58
|
+
requireApprovalFor: { type: "array", items: { type: "string" } },
|
|
59
|
+
allowCommands: { type: "array", items: { type: "string" } },
|
|
60
|
+
denyCommands: { type: "array", items: { type: "string" } },
|
|
61
|
+
allowLibraries: { type: "array", items: { type: "string" } },
|
|
62
|
+
denyLibraries: { type: "array", items: { type: "string" } },
|
|
63
|
+
allowPaths: { type: "array", items: { type: "string" } },
|
|
64
|
+
denyPaths: { type: "array", items: { type: "string" } }
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const ACTION_SCHEMA = {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
name: { type: "string" },
|
|
71
|
+
command: { type: "string" },
|
|
72
|
+
type: { type: "string", enum: ["member", "streamfile", "object", "file"] },
|
|
73
|
+
environment: { type: "string", enum: ["ile", "qsh", "pase"] },
|
|
74
|
+
extensions: { type: "array", items: { type: "string" } },
|
|
75
|
+
deployFirst: { type: "boolean" },
|
|
76
|
+
postDownload: { type: "array", items: { type: "string" } },
|
|
77
|
+
runOnProtected: { type: "boolean" },
|
|
78
|
+
outputToFile: { type: "string" }
|
|
79
|
+
},
|
|
80
|
+
required: ["name", "command", "environment"]
|
|
81
|
+
};
|
|
82
|
+
const PROFILE_SCHEMA = {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
name: { type: "string" },
|
|
86
|
+
currentLibrary: { type: "string" },
|
|
87
|
+
libraryList: { type: "array", items: { type: "string" } },
|
|
88
|
+
customVariables: { type: "array", items: CUSTOM_VARIABLE_SCHEMA }
|
|
89
|
+
},
|
|
90
|
+
required: ["name"]
|
|
91
|
+
};
|
|
9
92
|
export function getTools() {
|
|
10
93
|
const tools = [
|
|
11
94
|
{
|
|
@@ -61,8 +144,8 @@ export function getTools() {
|
|
|
61
144
|
port: { type: "number", default: 22 },
|
|
62
145
|
username: { type: "string" },
|
|
63
146
|
privateKeyPath: { type: "string" },
|
|
64
|
-
settings:
|
|
65
|
-
policy:
|
|
147
|
+
settings: SETTINGS_SCHEMA,
|
|
148
|
+
policy: POLICY_SCHEMA
|
|
66
149
|
},
|
|
67
150
|
required: ["name", "host", "username"],
|
|
68
151
|
additionalProperties: false
|
|
@@ -79,8 +162,8 @@ export function getTools() {
|
|
|
79
162
|
port: { type: "number" },
|
|
80
163
|
username: { type: "string" },
|
|
81
164
|
privateKeyPath: { type: "string" },
|
|
82
|
-
settings:
|
|
83
|
-
policy:
|
|
165
|
+
settings: SETTINGS_SCHEMA,
|
|
166
|
+
policy: POLICY_SCHEMA
|
|
84
167
|
},
|
|
85
168
|
required: ["name"],
|
|
86
169
|
additionalProperties: false
|
|
@@ -260,7 +343,7 @@ export function getTools() {
|
|
|
260
343
|
type: "object",
|
|
261
344
|
properties: {
|
|
262
345
|
actionName: { type: "string" },
|
|
263
|
-
action:
|
|
346
|
+
action: ACTION_SCHEMA,
|
|
264
347
|
targetType: { type: "string", enum: ["member", "streamfile", "file"] },
|
|
265
348
|
targetPath: { type: "string" },
|
|
266
349
|
library: { type: "string" },
|
|
@@ -274,7 +357,7 @@ export function getTools() {
|
|
|
274
357
|
{
|
|
275
358
|
name: "ibmi.actions.save",
|
|
276
359
|
description: "Create or update a custom action.",
|
|
277
|
-
inputSchema: { type: "object", properties: { action:
|
|
360
|
+
inputSchema: { type: "object", properties: { action: ACTION_SCHEMA }, required: ["action"] }
|
|
278
361
|
},
|
|
279
362
|
{
|
|
280
363
|
name: "ibmi.actions.delete",
|
|
@@ -345,7 +428,7 @@ export function getTools() {
|
|
|
345
428
|
description: "Create or update a connection profile.",
|
|
346
429
|
inputSchema: {
|
|
347
430
|
type: "object",
|
|
348
|
-
properties: { connectionName: { type: "string" }, profile:
|
|
431
|
+
properties: { connectionName: { type: "string" }, profile: PROFILE_SCHEMA },
|
|
349
432
|
required: ["profile"]
|
|
350
433
|
}
|
|
351
434
|
},
|
|
@@ -407,7 +490,7 @@ export function getTools() {
|
|
|
407
490
|
{
|
|
408
491
|
name: "ibmi.filters.save",
|
|
409
492
|
description: "Create or update an object filter.",
|
|
410
|
-
inputSchema: { type: "object", properties: { connectionName: { type: "string" }, filter:
|
|
493
|
+
inputSchema: { type: "object", properties: { connectionName: { type: "string" }, filter: OBJECT_FILTER_SCHEMA }, required: ["filter"] }
|
|
411
494
|
},
|
|
412
495
|
{
|
|
413
496
|
name: "ibmi.filters.delete",
|
|
@@ -452,7 +535,17 @@ export function getTools() {
|
|
|
452
535
|
{
|
|
453
536
|
name: "ibmi.deploy.sync",
|
|
454
537
|
description: "Sync local directory to remote (upload missing or overwrite).",
|
|
455
|
-
inputSchema: {
|
|
538
|
+
inputSchema: {
|
|
539
|
+
type: "object",
|
|
540
|
+
properties: {
|
|
541
|
+
localPath: { type: "string" },
|
|
542
|
+
remotePath: { type: "string" },
|
|
543
|
+
overwrite: { type: "boolean", default: false },
|
|
544
|
+
dryRun: { type: "boolean", default: false },
|
|
545
|
+
deleteExtraRemote: { type: "boolean", default: false }
|
|
546
|
+
},
|
|
547
|
+
required: ["localPath", "remotePath"]
|
|
548
|
+
}
|
|
456
549
|
},
|
|
457
550
|
{
|
|
458
551
|
name: "ibmi.sql.query",
|
|
@@ -464,6 +557,8 @@ export function getTools() {
|
|
|
464
557
|
cursor: { type: "string" },
|
|
465
558
|
pageSize: { type: "number" },
|
|
466
559
|
maxRows: { type: "number" },
|
|
560
|
+
timeoutMs: { type: "number" },
|
|
561
|
+
includeMetadata: { type: "boolean" },
|
|
467
562
|
connectionName: { type: "string" }
|
|
468
563
|
}
|
|
469
564
|
}
|
|
@@ -475,6 +570,7 @@ export function getTools() {
|
|
|
475
570
|
type: "object",
|
|
476
571
|
properties: {
|
|
477
572
|
sql: { type: "string" },
|
|
573
|
+
timeoutMs: { type: "number" },
|
|
478
574
|
connectionName: { type: "string" },
|
|
479
575
|
approve: { type: "boolean" }
|
|
480
576
|
},
|
|
@@ -490,6 +586,7 @@ export function getTools() {
|
|
|
490
586
|
command: { type: "string" },
|
|
491
587
|
environment: { type: "string", enum: ["ile", "qsh", "pase"] },
|
|
492
588
|
cwd: { type: "string" },
|
|
589
|
+
timeoutMs: { type: "number" },
|
|
493
590
|
connectionName: { type: "string" },
|
|
494
591
|
approve: { type: "boolean" }
|
|
495
592
|
},
|
|
@@ -562,7 +659,9 @@ export function getTools() {
|
|
|
562
659
|
inputSchema: { type: "object", properties: {} }
|
|
563
660
|
}
|
|
564
661
|
];
|
|
565
|
-
return tools
|
|
662
|
+
return tools
|
|
663
|
+
.map(withGuardedApprovalHint)
|
|
664
|
+
.map(withStrictInputSchema);
|
|
566
665
|
}
|
|
567
666
|
export async function handleTool(ctx, name, args) {
|
|
568
667
|
const connections = new ConnectionService(ctx.store);
|
|
@@ -764,9 +863,9 @@ export async function handleTool(ctx, name, args) {
|
|
|
764
863
|
envVars["&RELATIVEPATH"] = targetPath;
|
|
765
864
|
}
|
|
766
865
|
if (targetType === "member") {
|
|
767
|
-
const lib =
|
|
768
|
-
const src =
|
|
769
|
-
const mbr =
|
|
866
|
+
const lib = requireQsysName(conn, args.library, "library");
|
|
867
|
+
const src = requireQsysName(conn, args.sourceFile, "sourceFile");
|
|
868
|
+
const mbr = requireQsysName(conn, args.member || targetPath.split("/").pop(), "member");
|
|
770
869
|
const ext = String(args.extension || "");
|
|
771
870
|
envVars["&LIBRARY"] = lib;
|
|
772
871
|
envVars["&SRCFILE"] = src;
|
|
@@ -786,7 +885,7 @@ export async function handleTool(ctx, name, args) {
|
|
|
786
885
|
environment: action.environment,
|
|
787
886
|
env: envVars
|
|
788
887
|
});
|
|
789
|
-
return json(resultCmd);
|
|
888
|
+
return json(mapActionExecution(action, targetType, targetPath, resultCmd));
|
|
790
889
|
}
|
|
791
890
|
case "ibmi.actions.save": {
|
|
792
891
|
const action = args.action;
|
|
@@ -1023,7 +1122,7 @@ export async function handleTool(ctx, name, args) {
|
|
|
1023
1122
|
}
|
|
1024
1123
|
case "ibmi.debug.status": {
|
|
1025
1124
|
const conn = await ctx.ensureActive(args?.connectionName);
|
|
1026
|
-
const port = conn.getConfig().debugPort
|
|
1125
|
+
const port = clampNumber(conn.getConfig().debugPort, 8005, 1, 65535);
|
|
1027
1126
|
const rows = await conn.runSQL(`select job_name, local_port from qsys2.netstat_job_info where local_port = ${port} and remote_address = '0.0.0.0' fetch first 1 row only`);
|
|
1028
1127
|
if (rows.length === 0)
|
|
1029
1128
|
return json({ running: false, port });
|
|
@@ -1047,14 +1146,8 @@ export async function handleTool(ctx, name, args) {
|
|
|
1047
1146
|
const conn = await ctx.ensureActive(args?.connectionName);
|
|
1048
1147
|
const localPath = String(args.localPath);
|
|
1049
1148
|
const remotePath = String(args.remotePath);
|
|
1050
|
-
const
|
|
1051
|
-
|
|
1052
|
-
const localSet = new Set(localFiles);
|
|
1053
|
-
const remoteSet = new Set(remoteFiles);
|
|
1054
|
-
const onlyLocal = localFiles.filter(f => !remoteSet.has(f));
|
|
1055
|
-
const onlyRemote = remoteFiles.filter(f => !localSet.has(f));
|
|
1056
|
-
const common = localFiles.filter(f => remoteSet.has(f));
|
|
1057
|
-
return json({ onlyLocal, onlyRemote, common });
|
|
1149
|
+
const comparison = await compareDeployTrees(conn, localPath, remotePath);
|
|
1150
|
+
return json(comparison);
|
|
1058
1151
|
}
|
|
1059
1152
|
case "ibmi.deploy.sync": {
|
|
1060
1153
|
const conn = await ctx.ensureActive(args?.connectionName);
|
|
@@ -1062,55 +1155,151 @@ export async function handleTool(ctx, name, args) {
|
|
|
1062
1155
|
const localPath = String(args.localPath);
|
|
1063
1156
|
const remotePath = String(args.remotePath);
|
|
1064
1157
|
const overwrite = Boolean(args.overwrite);
|
|
1065
|
-
const
|
|
1066
|
-
const
|
|
1067
|
-
const
|
|
1068
|
-
const
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1158
|
+
const dryRun = Boolean(args.dryRun);
|
|
1159
|
+
const deleteExtraRemote = Boolean(args.deleteExtraRemote);
|
|
1160
|
+
const comparison = await compareDeployTrees(conn, localPath, remotePath);
|
|
1161
|
+
const uploadSet = new Set(comparison.onlyLocal);
|
|
1162
|
+
if (overwrite) {
|
|
1163
|
+
comparison.changed.forEach(entry => uploadSet.add(entry.path));
|
|
1164
|
+
comparison.unresolved.forEach(entry => uploadSet.add(entry.path));
|
|
1165
|
+
}
|
|
1166
|
+
const uploadPlan = Array.from(uploadSet).sort((a, b) => a.localeCompare(b));
|
|
1167
|
+
const skipChanged = overwrite ? [] : comparison.changed.map(entry => entry.path).sort((a, b) => a.localeCompare(b));
|
|
1168
|
+
const unresolved = overwrite ? [] : comparison.unresolved.map(entry => entry.path).sort((a, b) => a.localeCompare(b));
|
|
1169
|
+
const deletePlan = deleteExtraRemote ? [...comparison.onlyRemote] : [];
|
|
1170
|
+
const uploaded = [];
|
|
1171
|
+
const deleted = [];
|
|
1172
|
+
const errors = [];
|
|
1173
|
+
if (!dryRun) {
|
|
1174
|
+
const createdDirs = new Set();
|
|
1175
|
+
for (const rel of uploadPlan) {
|
|
1176
|
+
const localFile = path.join(localPath, rel);
|
|
1177
|
+
const remoteFile = joinRemotePath(remotePath, rel);
|
|
1178
|
+
const remoteDir = remoteFile.split("/").slice(0, -1).join("/");
|
|
1179
|
+
try {
|
|
1180
|
+
if (!createdDirs.has(remoteDir)) {
|
|
1181
|
+
await conn.sendCommand({ command: `mkdir -p ${Tools.escapePath(remoteDir)}` });
|
|
1182
|
+
createdDirs.add(remoteDir);
|
|
1183
|
+
}
|
|
1184
|
+
await conn.client.putFile(localFile, remoteFile);
|
|
1185
|
+
uploaded.push(rel);
|
|
1186
|
+
}
|
|
1187
|
+
catch (err) {
|
|
1188
|
+
errors.push({ operation: "upload", path: rel, message: err?.message || String(err) });
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
for (const rel of deletePlan) {
|
|
1192
|
+
const remoteFile = joinRemotePath(remotePath, rel);
|
|
1193
|
+
try {
|
|
1194
|
+
await conn.sendCommand({ command: `rm -f ${Tools.escapePath(remoteFile)}` });
|
|
1195
|
+
deleted.push(rel);
|
|
1196
|
+
}
|
|
1197
|
+
catch (err) {
|
|
1198
|
+
errors.push({ operation: "delete", path: rel, message: err?.message || String(err) });
|
|
1199
|
+
}
|
|
1078
1200
|
}
|
|
1079
|
-
await conn.client.putFile(localFile, remoteFile);
|
|
1080
1201
|
}
|
|
1081
|
-
return
|
|
1202
|
+
return json({
|
|
1203
|
+
ok: errors.length === 0,
|
|
1204
|
+
mode: { overwrite, deleteExtraRemote, dryRun },
|
|
1205
|
+
comparison,
|
|
1206
|
+
planned: {
|
|
1207
|
+
upload: uploadPlan,
|
|
1208
|
+
delete: deletePlan,
|
|
1209
|
+
skippedChanged: skipChanged,
|
|
1210
|
+
unresolved
|
|
1211
|
+
},
|
|
1212
|
+
applied: {
|
|
1213
|
+
uploaded: dryRun ? [] : uploaded,
|
|
1214
|
+
deleted: dryRun ? [] : deleted
|
|
1215
|
+
},
|
|
1216
|
+
errors
|
|
1217
|
+
});
|
|
1082
1218
|
}
|
|
1083
1219
|
case "ibmi.sql.query": {
|
|
1084
1220
|
const conn = await ctx.ensureActive(args?.connectionName);
|
|
1085
1221
|
const pageSize = clampNumber(args?.pageSize, 200, 1, 5000);
|
|
1086
1222
|
const maxRows = clampNumber(args?.maxRows, 1000, 1, 20000);
|
|
1223
|
+
const timeoutMs = clampNumber(args?.timeoutMs, 45000, 1000, 300000);
|
|
1224
|
+
const includeMetadata = Boolean(args?.includeMetadata);
|
|
1087
1225
|
if (args?.cursor) {
|
|
1088
1226
|
const next = ctx.consumeSqlCursor(String(args.cursor), pageSize);
|
|
1089
|
-
return json({
|
|
1227
|
+
return json({
|
|
1228
|
+
rows: next.rows,
|
|
1229
|
+
cursor: next.cursor,
|
|
1230
|
+
...(includeMetadata
|
|
1231
|
+
? { metadata: { source: "cursor", pageSize, timeoutMs } }
|
|
1232
|
+
: {})
|
|
1233
|
+
});
|
|
1090
1234
|
}
|
|
1091
1235
|
if (!args?.sql)
|
|
1092
1236
|
throw new Error("sql is required when cursor is not provided");
|
|
1093
1237
|
const sql = String(args.sql);
|
|
1094
1238
|
if (!isReadOnlySql(sql))
|
|
1095
1239
|
throw new Error("ibmi.sql.query only supports read-only SQL");
|
|
1096
|
-
const
|
|
1240
|
+
const startedAt = Date.now();
|
|
1241
|
+
const rows = await withTimeout(conn.runSQL(sql), timeoutMs, `SQL query timed out after ${timeoutMs}ms`);
|
|
1097
1242
|
const limited = rows.slice(0, maxRows);
|
|
1098
1243
|
const firstPage = limited.slice(0, pageSize);
|
|
1099
1244
|
const cursorInfo = ctx.createSqlCursor(limited, pageSize);
|
|
1245
|
+
const executionMs = Date.now() - startedAt;
|
|
1100
1246
|
return json({
|
|
1101
1247
|
rows: firstPage,
|
|
1102
1248
|
cursor: cursorInfo.cursor,
|
|
1103
1249
|
totalRows: limited.length,
|
|
1104
|
-
truncated: rows.length > limited.length
|
|
1250
|
+
truncated: rows.length > limited.length,
|
|
1251
|
+
...(includeMetadata
|
|
1252
|
+
? {
|
|
1253
|
+
metadata: {
|
|
1254
|
+
statementType: getSqlStatementType(sql),
|
|
1255
|
+
columns: getRowColumns(firstPage),
|
|
1256
|
+
pageSize,
|
|
1257
|
+
maxRows,
|
|
1258
|
+
timeoutMs,
|
|
1259
|
+
executionMs
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
: {})
|
|
1105
1263
|
});
|
|
1106
1264
|
}
|
|
1107
1265
|
case "ibmi.sql.execute": {
|
|
1108
1266
|
const conn = await ctx.ensureActive(args?.connectionName);
|
|
1109
1267
|
enforcePolicyOperation(ctx, "sql.write", args);
|
|
1110
1268
|
const sql = String(args.sql);
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1113
|
-
|
|
1269
|
+
const timeoutMs = clampNumber(args?.timeoutMs, 45000, 1000, 300000);
|
|
1270
|
+
const statementType = getSqlStatementType(sql);
|
|
1271
|
+
const startedAt = Date.now();
|
|
1272
|
+
try {
|
|
1273
|
+
const rows = await withTimeout(conn.runSQL(sql), timeoutMs, `SQL execute timed out after ${timeoutMs}ms`);
|
|
1274
|
+
const executionMs = Date.now() - startedAt;
|
|
1275
|
+
const affectedRows = inferAffectedRows(rows);
|
|
1276
|
+
log("info", "audit.sql.execute", {
|
|
1277
|
+
connectionName: args?.connectionName || ctx.activeName,
|
|
1278
|
+
readOnly: isReadOnlySql(sql),
|
|
1279
|
+
statementType,
|
|
1280
|
+
timeoutMs,
|
|
1281
|
+
affectedRows
|
|
1282
|
+
});
|
|
1283
|
+
return json({
|
|
1284
|
+
ok: true,
|
|
1285
|
+
statementType,
|
|
1286
|
+
timeoutMs,
|
|
1287
|
+
executionMs,
|
|
1288
|
+
affectedRows,
|
|
1289
|
+
returnedRows: rows.length,
|
|
1290
|
+
rows
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
catch (err) {
|
|
1294
|
+
const safe = toSafeSqlError(err, sql, timeoutMs);
|
|
1295
|
+
log("error", "audit.sql.execute.failed", {
|
|
1296
|
+
connectionName: args?.connectionName || ctx.activeName,
|
|
1297
|
+
statementType,
|
|
1298
|
+
errorType: safe.type,
|
|
1299
|
+
code: safe.code
|
|
1300
|
+
});
|
|
1301
|
+
return toolError(`SQL execute failed (${safe.type})`, safe);
|
|
1302
|
+
}
|
|
1114
1303
|
}
|
|
1115
1304
|
case "ibmi.cl.run": {
|
|
1116
1305
|
const conn = await ctx.ensureActive(args?.connectionName);
|
|
@@ -1120,16 +1309,17 @@ export async function handleTool(ctx, name, args) {
|
|
|
1120
1309
|
throw new Error("command is required");
|
|
1121
1310
|
const environment = String(args.environment || "ile");
|
|
1122
1311
|
const cwd = args.cwd ? String(args.cwd) : undefined;
|
|
1312
|
+
const timeoutMs = clampNumber(args?.timeoutMs, 45000, 1000, 300000);
|
|
1123
1313
|
let cmdResult;
|
|
1124
1314
|
if (environment === "pase") {
|
|
1125
|
-
cmdResult = await conn.sendCommand({ command, directory: cwd });
|
|
1315
|
+
cmdResult = await withTimeout(conn.sendCommand({ command, directory: cwd }), timeoutMs, `CL command timed out after ${timeoutMs}ms`);
|
|
1126
1316
|
}
|
|
1127
1317
|
else if (environment === "qsh") {
|
|
1128
|
-
cmdResult = await conn.sendQsh({ command, directory: cwd });
|
|
1318
|
+
cmdResult = await withTimeout(conn.sendQsh({ command, directory: cwd }), timeoutMs, `CL command timed out after ${timeoutMs}ms`);
|
|
1129
1319
|
}
|
|
1130
1320
|
else {
|
|
1131
1321
|
try {
|
|
1132
|
-
await conn.runSQL(`CALL QSYS2.QCMDEXC(${Tools.sqlString(command)})`);
|
|
1322
|
+
await withTimeout(conn.runSQL(`CALL QSYS2.QCMDEXC(${Tools.sqlString(command)})`), timeoutMs, `CL command timed out after ${timeoutMs}ms`);
|
|
1133
1323
|
cmdResult = { code: 0, signal: null, stdout: "", stderr: "", command };
|
|
1134
1324
|
}
|
|
1135
1325
|
catch (err) {
|
|
@@ -1137,7 +1327,12 @@ export async function handleTool(ctx, name, args) {
|
|
|
1137
1327
|
}
|
|
1138
1328
|
}
|
|
1139
1329
|
log("info", "audit.cl.run", { connectionName: args?.connectionName || ctx.activeName, environment });
|
|
1140
|
-
return json(
|
|
1330
|
+
return json({
|
|
1331
|
+
ok: cmdResult?.code === 0,
|
|
1332
|
+
timeoutMs,
|
|
1333
|
+
environment,
|
|
1334
|
+
...cmdResult
|
|
1335
|
+
});
|
|
1141
1336
|
}
|
|
1142
1337
|
case "ibmi.diagnostics.parseEvfevent": {
|
|
1143
1338
|
return json(parseEvfevent(String(args.content)));
|
|
@@ -1146,13 +1341,13 @@ export async function handleTool(ctx, name, args) {
|
|
|
1146
1341
|
const conn = await ctx.ensureActive(args?.connectionName);
|
|
1147
1342
|
const limit = clampNumber(args?.limit, 200, 1, 5000);
|
|
1148
1343
|
const rows = await conn.runSQL(`select message_id, message_text, severity, message_timestamp from table(qsys2.joblog_info('*')) order by message_timestamp desc fetch first ${limit} rows only`);
|
|
1149
|
-
return json(rows);
|
|
1344
|
+
return json(rows.map(normalizeJoblogRow));
|
|
1150
1345
|
}
|
|
1151
1346
|
case "ibmi.spool.list": {
|
|
1152
1347
|
const conn = await ctx.ensureActive(args?.connectionName);
|
|
1153
1348
|
const limit = clampNumber(args?.limit, 200, 1, 5000);
|
|
1154
1349
|
const rows = await conn.runSQL(`select job_name, spooled_file_name, spooled_file_number, output_queue_name, total_pages, file_status from qsys2.output_queue_entries fetch first ${limit} rows only`);
|
|
1155
|
-
return json(rows);
|
|
1350
|
+
return json(rows.map(normalizeSpoolEntry));
|
|
1156
1351
|
}
|
|
1157
1352
|
case "ibmi.spool.read": {
|
|
1158
1353
|
const conn = await ctx.ensureActive(args?.connectionName);
|
|
@@ -1161,7 +1356,7 @@ export async function handleTool(ctx, name, args) {
|
|
|
1161
1356
|
const fileName = Tools.sqlString(String(args.spooledFileName));
|
|
1162
1357
|
const fileNbr = clampNumber(args.spooledFileNumber, 1, 1, 999999999);
|
|
1163
1358
|
const rows = await conn.runSQL(`select line_number, spooled_data from table(qsys2.display_spooled_file_data(${jobName}, ${fileName}, ${fileNbr})) fetch first ${limit} rows only`);
|
|
1164
|
-
return json(rows);
|
|
1359
|
+
return json(rows.map(normalizeSpoolLine));
|
|
1165
1360
|
}
|
|
1166
1361
|
case "ibmi.tn5250.connect":
|
|
1167
1362
|
case "ibmi.tn5250.readScreen":
|
|
@@ -1199,10 +1394,29 @@ function rejectCredentialArgs(args, toolName) {
|
|
|
1199
1394
|
}
|
|
1200
1395
|
}
|
|
1201
1396
|
function result(text) {
|
|
1202
|
-
return {
|
|
1397
|
+
return {
|
|
1398
|
+
content: [{ type: "text", text }],
|
|
1399
|
+
structuredContent: { ok: true, text }
|
|
1400
|
+
};
|
|
1203
1401
|
}
|
|
1204
1402
|
function json(obj) {
|
|
1205
|
-
return {
|
|
1403
|
+
return {
|
|
1404
|
+
content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
|
|
1405
|
+
structuredContent: { ok: true, data: obj }
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
function toolError(message, error) {
|
|
1409
|
+
return {
|
|
1410
|
+
isError: true,
|
|
1411
|
+
content: [{ type: "text", text: message }],
|
|
1412
|
+
structuredContent: {
|
|
1413
|
+
ok: false,
|
|
1414
|
+
error: {
|
|
1415
|
+
message,
|
|
1416
|
+
...(error || {})
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1206
1420
|
}
|
|
1207
1421
|
function getConnectionName(ctx, args) {
|
|
1208
1422
|
const name = args?.connectionName || ctx.activeName;
|
|
@@ -1210,6 +1424,14 @@ function getConnectionName(ctx, args) {
|
|
|
1210
1424
|
throw new Error("connectionName is required (no active connection)");
|
|
1211
1425
|
return name;
|
|
1212
1426
|
}
|
|
1427
|
+
function requireQsysName(conn, value, label) {
|
|
1428
|
+
const raw = String(value || "").trim().toUpperCase();
|
|
1429
|
+
if (!raw)
|
|
1430
|
+
throw new Error(`${label} is required`);
|
|
1431
|
+
if (!conn.validQsysName(raw))
|
|
1432
|
+
throw new Error(`Invalid ${label}: ${raw}`);
|
|
1433
|
+
return raw;
|
|
1434
|
+
}
|
|
1213
1435
|
async function updateConnectionSettings(ctx, name, update) {
|
|
1214
1436
|
const conn = await ctx.store.getConnection(name);
|
|
1215
1437
|
if (!conn)
|
|
@@ -1248,29 +1470,221 @@ function stripUndefined(obj) {
|
|
|
1248
1470
|
}
|
|
1249
1471
|
return out;
|
|
1250
1472
|
}
|
|
1473
|
+
async function compareDeployTrees(conn, localRoot, remoteRoot) {
|
|
1474
|
+
const local = await listLocalFiles(localRoot);
|
|
1475
|
+
const remote = await listRemoteFiles(conn, remoteRoot);
|
|
1476
|
+
const onlyLocal = [];
|
|
1477
|
+
const onlyRemote = [];
|
|
1478
|
+
const identical = [];
|
|
1479
|
+
const changed = [];
|
|
1480
|
+
const unresolved = [];
|
|
1481
|
+
const localKeys = new Set(local.keys());
|
|
1482
|
+
const remoteKeys = new Set(remote.keys());
|
|
1483
|
+
for (const rel of localKeys) {
|
|
1484
|
+
if (!remoteKeys.has(rel)) {
|
|
1485
|
+
onlyLocal.push(rel);
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
const l = local.get(rel);
|
|
1489
|
+
const r = remote.get(rel);
|
|
1490
|
+
if (typeof l.size === "number" && typeof r.size === "number") {
|
|
1491
|
+
if (l.size === r.size) {
|
|
1492
|
+
identical.push(rel);
|
|
1493
|
+
}
|
|
1494
|
+
else {
|
|
1495
|
+
changed.push({ path: rel, localSize: l.size, remoteSize: r.size, reason: "size_mismatch" });
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
else {
|
|
1499
|
+
unresolved.push({ path: rel, localSize: l.size, remoteSize: r.size, reason: "unknown_size" });
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
for (const rel of remoteKeys) {
|
|
1503
|
+
if (!localKeys.has(rel)) {
|
|
1504
|
+
onlyRemote.push(rel);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
onlyLocal.sort((a, b) => a.localeCompare(b));
|
|
1508
|
+
onlyRemote.sort((a, b) => a.localeCompare(b));
|
|
1509
|
+
identical.sort((a, b) => a.localeCompare(b));
|
|
1510
|
+
changed.sort((a, b) => a.path.localeCompare(b.path));
|
|
1511
|
+
unresolved.sort((a, b) => a.path.localeCompare(b.path));
|
|
1512
|
+
return {
|
|
1513
|
+
localRoot: path.resolve(localRoot),
|
|
1514
|
+
remoteRoot: remoteRoot.replace(/\/$/, ""),
|
|
1515
|
+
onlyLocal,
|
|
1516
|
+
onlyRemote,
|
|
1517
|
+
identical,
|
|
1518
|
+
changed,
|
|
1519
|
+
unresolved,
|
|
1520
|
+
summary: {
|
|
1521
|
+
localFiles: local.size,
|
|
1522
|
+
remoteFiles: remote.size,
|
|
1523
|
+
onlyLocal: onlyLocal.length,
|
|
1524
|
+
onlyRemote: onlyRemote.length,
|
|
1525
|
+
identical: identical.length,
|
|
1526
|
+
changed: changed.length,
|
|
1527
|
+
unresolved: unresolved.length
|
|
1528
|
+
}
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1251
1531
|
async function listLocalFiles(root) {
|
|
1252
|
-
const
|
|
1532
|
+
const resolvedRoot = path.resolve(root);
|
|
1533
|
+
const results = new Map();
|
|
1253
1534
|
async function walk(dir) {
|
|
1254
1535
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1255
1536
|
for (const entry of entries) {
|
|
1256
1537
|
const full = path.join(dir, entry.name);
|
|
1257
|
-
const rel = path.relative(
|
|
1538
|
+
const rel = path.relative(resolvedRoot, full).replace(/\\/g, "/");
|
|
1258
1539
|
if (entry.isDirectory()) {
|
|
1259
1540
|
await walk(full);
|
|
1260
1541
|
}
|
|
1261
1542
|
else {
|
|
1262
|
-
|
|
1543
|
+
const stat = await fs.stat(full);
|
|
1544
|
+
results.set(rel, { path: rel, size: stat.size });
|
|
1263
1545
|
}
|
|
1264
1546
|
}
|
|
1265
1547
|
}
|
|
1266
|
-
await walk(
|
|
1548
|
+
await walk(resolvedRoot);
|
|
1267
1549
|
return results;
|
|
1268
1550
|
}
|
|
1269
1551
|
async function listRemoteFiles(conn, remoteRoot) {
|
|
1552
|
+
const normalizedRoot = remoteRoot.replace(/\/$/, "");
|
|
1270
1553
|
const find = conn.remoteFeatures.find || "/QOpenSys/pkgs/bin/find";
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
|
|
1554
|
+
const stat = conn.remoteFeatures.stat;
|
|
1555
|
+
if (stat) {
|
|
1556
|
+
const statCmd = `${find} ${Tools.escapePath(normalizedRoot)} -type f -exec ${stat} -c '%n|%s' {} \\;`;
|
|
1557
|
+
const statRes = await conn.sendCommand({ command: statCmd });
|
|
1558
|
+
if (statRes.code === 0) {
|
|
1559
|
+
const mapped = parseRemoteStatLines(statRes.stdout || "", normalizedRoot);
|
|
1560
|
+
if (mapped.size > 0)
|
|
1561
|
+
return mapped;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
const res = await conn.sendCommand({ command: `${find} ${Tools.escapePath(normalizedRoot)} -type f -print` });
|
|
1565
|
+
if (res.code !== 0) {
|
|
1566
|
+
throw new Error(res.stderr || `Failed to list remote files under ${normalizedRoot}`);
|
|
1567
|
+
}
|
|
1568
|
+
const mapped = new Map();
|
|
1569
|
+
const lines = String(res.stdout || "").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1570
|
+
for (const line of lines) {
|
|
1571
|
+
const rel = toRelativeRemotePath(line, normalizedRoot);
|
|
1572
|
+
mapped.set(rel, { path: rel });
|
|
1573
|
+
}
|
|
1574
|
+
return mapped;
|
|
1575
|
+
}
|
|
1576
|
+
function parseRemoteStatLines(output, remoteRoot) {
|
|
1577
|
+
const mapped = new Map();
|
|
1578
|
+
const lines = output.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
1579
|
+
for (const line of lines) {
|
|
1580
|
+
const split = line.lastIndexOf("|");
|
|
1581
|
+
if (split < 0)
|
|
1582
|
+
continue;
|
|
1583
|
+
const absolute = line.substring(0, split);
|
|
1584
|
+
const sizeText = line.substring(split + 1);
|
|
1585
|
+
const size = Number(sizeText);
|
|
1586
|
+
const rel = toRelativeRemotePath(absolute, remoteRoot);
|
|
1587
|
+
mapped.set(rel, { path: rel, size: Number.isFinite(size) ? size : undefined });
|
|
1588
|
+
}
|
|
1589
|
+
return mapped;
|
|
1590
|
+
}
|
|
1591
|
+
function toRelativeRemotePath(absolutePath, remoteRoot) {
|
|
1592
|
+
const trimmedRoot = remoteRoot.replace(/\/$/, "");
|
|
1593
|
+
const normalizedPath = absolutePath.replace(/\\/g, "/");
|
|
1594
|
+
if (normalizedPath === trimmedRoot)
|
|
1595
|
+
return "";
|
|
1596
|
+
if (normalizedPath.startsWith(`${trimmedRoot}/`)) {
|
|
1597
|
+
return normalizedPath.substring(trimmedRoot.length + 1);
|
|
1598
|
+
}
|
|
1599
|
+
return normalizedPath.replace(/^[\\/]/, "");
|
|
1600
|
+
}
|
|
1601
|
+
function joinRemotePath(root, rel) {
|
|
1602
|
+
const base = root.replace(/\/$/, "");
|
|
1603
|
+
return `${base}/${rel}`.replace(/\\/g, "/");
|
|
1604
|
+
}
|
|
1605
|
+
function mapActionExecution(action, targetType, targetPath, result) {
|
|
1606
|
+
const diagnostics = normalizeCommandDiagnostics(result);
|
|
1607
|
+
const code = Number(result?.code);
|
|
1608
|
+
const ok = Number.isFinite(code) ? code === 0 : false;
|
|
1609
|
+
const status = ok ? (diagnostics.some(d => d.severity === "ERROR") ? "warning" : "success") : "error";
|
|
1610
|
+
return {
|
|
1611
|
+
...result,
|
|
1612
|
+
ok,
|
|
1613
|
+
status,
|
|
1614
|
+
action: {
|
|
1615
|
+
name: action.name,
|
|
1616
|
+
type: action.type || "member",
|
|
1617
|
+
environment: action.environment
|
|
1618
|
+
},
|
|
1619
|
+
target: {
|
|
1620
|
+
type: targetType,
|
|
1621
|
+
path: targetPath
|
|
1622
|
+
},
|
|
1623
|
+
diagnostics
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
function normalizeCommandDiagnostics(result) {
|
|
1627
|
+
const stdout = String(result?.stdout || "");
|
|
1628
|
+
const stderr = String(result?.stderr || "");
|
|
1629
|
+
const diagnostics = [
|
|
1630
|
+
...extractDiagnostics(stdout, "stdout"),
|
|
1631
|
+
...extractDiagnostics(stderr, "stderr")
|
|
1632
|
+
];
|
|
1633
|
+
return diagnostics.slice(0, 500);
|
|
1634
|
+
}
|
|
1635
|
+
function extractDiagnostics(text, stream) {
|
|
1636
|
+
const diagnostics = [];
|
|
1637
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
1638
|
+
const line = rawLine.trim();
|
|
1639
|
+
if (!line)
|
|
1640
|
+
continue;
|
|
1641
|
+
const codeMatch = line.match(/\b(CPF\d{4}|SQL\d{4}|RNX\d{4}|MCH\d{4}|RNQ\d{4})\b/i);
|
|
1642
|
+
diagnostics.push({
|
|
1643
|
+
stream,
|
|
1644
|
+
severity: inferSeverity(line),
|
|
1645
|
+
code: codeMatch ? codeMatch[1].toUpperCase() : undefined,
|
|
1646
|
+
message: line
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
return diagnostics;
|
|
1650
|
+
}
|
|
1651
|
+
function inferSeverity(line) {
|
|
1652
|
+
const text = line.toUpperCase();
|
|
1653
|
+
if (/(ERROR|FAILED|EXCEPTION|SEV\s*[4-9]|SEVERITY\s*[4-9])/.test(text))
|
|
1654
|
+
return "ERROR";
|
|
1655
|
+
if (/(WARN|WARNING|SEV\s*[1-3]|SEVERITY\s*[1-3])/.test(text))
|
|
1656
|
+
return "WARN";
|
|
1657
|
+
return "INFO";
|
|
1658
|
+
}
|
|
1659
|
+
function normalizeJoblogRow(row) {
|
|
1660
|
+
return {
|
|
1661
|
+
...row,
|
|
1662
|
+
source: "joblog",
|
|
1663
|
+
id: String(row.MESSAGE_ID || ""),
|
|
1664
|
+
text: String(row.MESSAGE_TEXT || ""),
|
|
1665
|
+
severity: Number.isFinite(Number(row.SEVERITY)) ? Number(row.SEVERITY) : null,
|
|
1666
|
+
timestamp: row.MESSAGE_TIMESTAMP ? String(row.MESSAGE_TIMESTAMP) : undefined
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
function normalizeSpoolEntry(row) {
|
|
1670
|
+
return {
|
|
1671
|
+
...row,
|
|
1672
|
+
source: "spool",
|
|
1673
|
+
jobName: String(row.JOB_NAME || ""),
|
|
1674
|
+
spooledFileName: String(row.SPOOLED_FILE_NAME || ""),
|
|
1675
|
+
spooledFileNumber: Number(row.SPOOLED_FILE_NUMBER || 0),
|
|
1676
|
+
outputQueue: String(row.OUTPUT_QUEUE_NAME || ""),
|
|
1677
|
+
totalPages: Number.isFinite(Number(row.TOTAL_PAGES)) ? Number(row.TOTAL_PAGES) : null,
|
|
1678
|
+
status: String(row.FILE_STATUS || "")
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
function normalizeSpoolLine(row) {
|
|
1682
|
+
return {
|
|
1683
|
+
...row,
|
|
1684
|
+
source: "spool",
|
|
1685
|
+
lineNumber: Number.isFinite(Number(row.LINE_NUMBER)) ? Number(row.LINE_NUMBER) : null,
|
|
1686
|
+
text: String(row.SPOOLED_DATA || "")
|
|
1687
|
+
};
|
|
1274
1688
|
}
|
|
1275
1689
|
function clampNumber(value, fallback, min, max) {
|
|
1276
1690
|
const n = Number(value);
|
|
@@ -1278,6 +1692,80 @@ function clampNumber(value, fallback, min, max) {
|
|
|
1278
1692
|
return fallback;
|
|
1279
1693
|
return Math.max(min, Math.min(max, Math.floor(n)));
|
|
1280
1694
|
}
|
|
1695
|
+
async function withTimeout(promise, timeoutMs, timeoutMessage) {
|
|
1696
|
+
let timeoutHandle;
|
|
1697
|
+
try {
|
|
1698
|
+
return await Promise.race([
|
|
1699
|
+
promise,
|
|
1700
|
+
new Promise((_, reject) => {
|
|
1701
|
+
timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
|
1702
|
+
})
|
|
1703
|
+
]);
|
|
1704
|
+
}
|
|
1705
|
+
finally {
|
|
1706
|
+
if (timeoutHandle)
|
|
1707
|
+
clearTimeout(timeoutHandle);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
function getSqlStatementType(sql) {
|
|
1711
|
+
const stripped = sql
|
|
1712
|
+
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
1713
|
+
.split("\n")
|
|
1714
|
+
.filter(line => !line.trim().startsWith("--"))
|
|
1715
|
+
.join(" ")
|
|
1716
|
+
.trim();
|
|
1717
|
+
const token = stripped.split(/\s+/)[0] || "UNKNOWN";
|
|
1718
|
+
return token.toUpperCase();
|
|
1719
|
+
}
|
|
1720
|
+
function getRowColumns(rows) {
|
|
1721
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
1722
|
+
return [];
|
|
1723
|
+
return Object.keys(rows[0] || {});
|
|
1724
|
+
}
|
|
1725
|
+
function inferAffectedRows(rows) {
|
|
1726
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
1727
|
+
return null;
|
|
1728
|
+
const candidateKeys = [
|
|
1729
|
+
"ROWS_AFFECTED",
|
|
1730
|
+
"ROW_COUNT",
|
|
1731
|
+
"AFFECTED_ROWS",
|
|
1732
|
+
"NUM_ROWS",
|
|
1733
|
+
"NUMBER_ROWS"
|
|
1734
|
+
];
|
|
1735
|
+
const first = rows[0] || {};
|
|
1736
|
+
for (const key of candidateKeys) {
|
|
1737
|
+
const value = first[key];
|
|
1738
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
1739
|
+
return value;
|
|
1740
|
+
const parsed = Number(value);
|
|
1741
|
+
if (Number.isFinite(parsed))
|
|
1742
|
+
return parsed;
|
|
1743
|
+
}
|
|
1744
|
+
return null;
|
|
1745
|
+
}
|
|
1746
|
+
function toSafeSqlError(err, sql, timeoutMs) {
|
|
1747
|
+
const message = err?.message || String(err);
|
|
1748
|
+
const lower = String(message).toLowerCase();
|
|
1749
|
+
let type = "unknown_error";
|
|
1750
|
+
if (lower.includes("timed out"))
|
|
1751
|
+
type = "timeout";
|
|
1752
|
+
else if (lower.includes("blocked by policy"))
|
|
1753
|
+
type = "policy_error";
|
|
1754
|
+
else if (lower.includes("transport closed") || lower.includes("not connected") || lower.includes("socket"))
|
|
1755
|
+
type = "connection_error";
|
|
1756
|
+
else if (err?.name === "SqlError" || /sql\d{4}/i.test(message) || /sqlstate/i.test(message))
|
|
1757
|
+
type = "sql_error";
|
|
1758
|
+
const sqlStateMatch = String(message).match(/sqlstate[^a-z0-9]*([0-9a-z]{5})/i);
|
|
1759
|
+
const codeMatch = String(message).match(/\b([A-Z]{2,}\d{3,5})\b/);
|
|
1760
|
+
return {
|
|
1761
|
+
type,
|
|
1762
|
+
message,
|
|
1763
|
+
statementType: getSqlStatementType(sql),
|
|
1764
|
+
timeoutMs,
|
|
1765
|
+
sqlState: sqlStateMatch ? sqlStateMatch[1].toUpperCase() : undefined,
|
|
1766
|
+
code: codeMatch ? codeMatch[1].toUpperCase() : undefined
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1281
1769
|
function isReadOnlySql(sql) {
|
|
1282
1770
|
const stripped = sql
|
|
1283
1771
|
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
@@ -1354,6 +1842,34 @@ function withGuardedApprovalHint(tool) {
|
|
|
1354
1842
|
}
|
|
1355
1843
|
return { ...tool, inputSchema: schema };
|
|
1356
1844
|
}
|
|
1845
|
+
function withStrictInputSchema(tool) {
|
|
1846
|
+
const schema = strictifySchema(tool.inputSchema || { type: "object", properties: {} });
|
|
1847
|
+
return { ...tool, inputSchema: schema };
|
|
1848
|
+
}
|
|
1849
|
+
function strictifySchema(schema) {
|
|
1850
|
+
const next = { ...schema };
|
|
1851
|
+
if (isObjectSchema(next) && typeof next.additionalProperties === "undefined") {
|
|
1852
|
+
next.additionalProperties = false;
|
|
1853
|
+
}
|
|
1854
|
+
if (next.properties) {
|
|
1855
|
+
const strictProps = {};
|
|
1856
|
+
for (const [key, child] of Object.entries(next.properties)) {
|
|
1857
|
+
strictProps[key] = strictifySchema(child);
|
|
1858
|
+
}
|
|
1859
|
+
next.properties = strictProps;
|
|
1860
|
+
}
|
|
1861
|
+
if (next.items) {
|
|
1862
|
+
next.items = strictifySchema(next.items);
|
|
1863
|
+
}
|
|
1864
|
+
return next;
|
|
1865
|
+
}
|
|
1866
|
+
function isObjectSchema(schema) {
|
|
1867
|
+
if (!schema.type)
|
|
1868
|
+
return false;
|
|
1869
|
+
if (Array.isArray(schema.type))
|
|
1870
|
+
return schema.type.includes("object");
|
|
1871
|
+
return schema.type === "object";
|
|
1872
|
+
}
|
|
1357
1873
|
function resolveIfsWriteOperation(pathValue) {
|
|
1358
1874
|
const target = String(pathValue || "");
|
|
1359
1875
|
if (/^\/qsys\.lib\//i.test(target)) {
|
|
@@ -1370,6 +1886,7 @@ function parseEvfevent(content) {
|
|
|
1370
1886
|
const match = line.match(/^\s*(\d+)\s+(\d+)\s+([A-Z0-9]+)\s+(INFO|WARN|ERROR)?\s*(.*)$/i);
|
|
1371
1887
|
if (match) {
|
|
1372
1888
|
diagnostics.push({
|
|
1889
|
+
source: "evfevent",
|
|
1373
1890
|
line: Number(match[1]),
|
|
1374
1891
|
column: Number(match[2]),
|
|
1375
1892
|
code: match[3],
|
|
@@ -1378,7 +1895,7 @@ function parseEvfevent(content) {
|
|
|
1378
1895
|
});
|
|
1379
1896
|
}
|
|
1380
1897
|
else {
|
|
1381
|
-
diagnostics.push({ message: line });
|
|
1898
|
+
diagnostics.push({ source: "evfevent", severity: inferSeverity(line), message: line });
|
|
1382
1899
|
}
|
|
1383
1900
|
}
|
|
1384
1901
|
return diagnostics;
|
package/docs/tools.md
CHANGED
|
@@ -69,6 +69,11 @@
|
|
|
69
69
|
- `ibmi.deploy.compare`
|
|
70
70
|
- `ibmi.deploy.sync`
|
|
71
71
|
|
|
72
|
+
### Deploy Notes
|
|
73
|
+
- `ibmi.deploy.compare` returns richer drift data: `onlyLocal`, `onlyRemote`, `identical`, `changed`, `unresolved`, and a `summary`.
|
|
74
|
+
- `ibmi.deploy.sync` supports `overwrite`, `dryRun`, and `deleteExtraRemote`.
|
|
75
|
+
- `ibmi.deploy.sync` returns execution mapping (`planned`, `applied`, `errors`) for safer automation.
|
|
76
|
+
|
|
72
77
|
## Filters
|
|
73
78
|
- `ibmi.filters.list`
|
|
74
79
|
- `ibmi.filters.save`
|
|
@@ -89,12 +94,22 @@
|
|
|
89
94
|
- `ibmi.sql.execute`
|
|
90
95
|
- `ibmi.cl.run`
|
|
91
96
|
|
|
97
|
+
### SQL / CL Notes
|
|
98
|
+
- `ibmi.sql.query` supports cursor paging (`cursor`), `pageSize`, `maxRows`, `timeoutMs`, and optional `includeMetadata`.
|
|
99
|
+
- `ibmi.sql.execute` supports guarded writes with `approve=true` and optional `timeoutMs`.
|
|
100
|
+
- `ibmi.cl.run` supports `ile`, `qsh`, or `pase` execution with optional `timeoutMs`.
|
|
101
|
+
- Tool responses include structured envelopes (`structuredContent`) for machine-safe agent parsing.
|
|
102
|
+
|
|
92
103
|
## Diagnostics / Ops
|
|
93
104
|
- `ibmi.diagnostics.parseEvfevent`
|
|
94
105
|
- `ibmi.joblog.get`
|
|
95
106
|
- `ibmi.spool.list`
|
|
96
107
|
- `ibmi.spool.read`
|
|
97
108
|
|
|
109
|
+
### Diagnostics Notes
|
|
110
|
+
- Diagnostics tools return normalized records suitable for downstream agents (`source`, normalized severity/status fields, and preserved raw fields).
|
|
111
|
+
- `ibmi.actions.run` now returns mapped execution status (`ok`, `status`) and parsed command diagnostics alongside raw command output.
|
|
112
|
+
|
|
98
113
|
## TN5250 (Scaffold)
|
|
99
114
|
- `ibmi.tn5250.connect`
|
|
100
115
|
- `ibmi.tn5250.readScreen`
|