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 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.slice(0).reverse();
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 defaultLibs = Tools.sanitizeObjNamesForPase((connection.defaultUserLibraries || []).filter(l => l && l !== "QSYS"));
83
- const currentLib = Tools.sanitizeObjNamesForPase([config.currentLibrary || "QGPL"])[0];
84
- const userLibs = Tools.sanitizeObjNamesForPase(buildLibraryList(config));
85
- return [
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
+ }
@@ -86,11 +86,11 @@ export class IBMiContent {
86
86
  if (withDates !== undefined)
87
87
  return withDates;
88
88
  }
89
- const lib = this.ibmi.upperCaseName(library);
90
- const file = this.ibmi.upperCaseName(sourceFile);
91
- const mbr = this.ibmi.upperCaseName(member);
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 || "*FILE";
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.ibmi.upperCaseName(library);
118
- const file = this.ibmi.upperCaseName(sourceFile);
119
- const mbr = this.ibmi.upperCaseName(member);
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 || "*FILE";
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.ibmi.upperCaseName(library);
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.ibmi.upperCaseName(options.library);
201
- const file = this.ibmi.upperCaseName(options.sourceFile);
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.ibmi.upperCaseName(library);
213
- const file = this.ibmi.upperCaseName(sourceFile);
214
- const mbr = this.ibmi.upperCaseName(member);
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.ibmi.upperCaseName(library);
229
- const file = this.ibmi.upperCaseName(sourceFile);
230
- const mbr = this.ibmi.upperCaseName(member);
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.ibmi.upperCaseName(library);
255
- const file = this.ibmi.upperCaseName(sourceFile);
256
- const mbr = this.ibmi.upperCaseName(member);
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.ibmi.upperCaseName(library);
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.ibmi.upperCaseName(library);
331
- const src = this.ibmi.upperCaseName(file);
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.ibmi.upperCaseName(library);
337
- const src = this.ibmi.upperCaseName(file);
338
- const mbr = this.ibmi.upperCaseName(member);
339
- const type = srctype ? this.ibmi.upperCaseName(srctype) : "*NONE";
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.ibmi.upperCaseName(library);
345
- const src = this.ibmi.upperCaseName(file);
346
- const mbr = this.ibmi.upperCaseName(member);
347
- const newMbr = this.ibmi.upperCaseName(newMember);
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.ibmi.upperCaseName(library);
353
- const src = this.ibmi.upperCaseName(file);
354
- const mbr = this.ibmi.upperCaseName(member);
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");
@@ -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/${library}.LIB/${sourceFile}.FILE`
53
+ directory: `/QSYS.LIB/${lib}.LIB/${src}.FILE`
46
54
  });
47
55
  if (!result.stderr) {
48
56
  let hits = parseGrepOutput(result.stdout || '', readOnly);
@@ -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-9_.]{0,9}$/.test(upper);
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.4" }, { capabilities: { tools: {} } });
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: { type: "object" },
65
- policy: { type: "object" }
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: { type: "object" },
83
- policy: { type: "object" }
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: { type: "object" },
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: { type: "object" } }, required: ["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: { type: "object" } },
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: { type: "object" } }, required: ["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: { type: "object", properties: { localPath: { type: "string" }, remotePath: { type: "string" }, overwrite: { type: "boolean", default: false } }, required: ["localPath", "remotePath"] }
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.map(withGuardedApprovalHint);
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 = String(args.library || "").toUpperCase();
768
- const src = String(args.sourceFile || "").toUpperCase();
769
- const mbr = String(args.member || targetPath.split("/").pop() || "").toUpperCase();
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 || 8005;
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 localFiles = await listLocalFiles(localPath);
1051
- const remoteFiles = await listRemoteFiles(conn, remotePath);
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 localFiles = await listLocalFiles(localPath);
1066
- const remoteFiles = await listRemoteFiles(conn, remotePath);
1067
- const remoteSet = new Set(remoteFiles);
1068
- const createdDirs = new Set();
1069
- for (const rel of localFiles) {
1070
- if (!overwrite && remoteSet.has(rel))
1071
- continue;
1072
- const localFile = path.join(localPath, rel);
1073
- const remoteFile = `${remotePath}/${rel}`.replace(/\\/g, "/");
1074
- const remoteDir = remoteFile.split("/").slice(0, -1).join("/");
1075
- if (!createdDirs.has(remoteDir)) {
1076
- await conn.sendCommand({ command: `mkdir -p ${Tools.escapePath(remoteDir)}` });
1077
- createdDirs.add(remoteDir);
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 result("OK");
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({ rows: next.rows, cursor: next.cursor });
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 rows = await conn.runSQL(sql);
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 rows = await conn.runSQL(sql);
1112
- log("info", "audit.sql.execute", { connectionName: args?.connectionName || ctx.activeName, readOnly: isReadOnlySql(sql) });
1113
- return json({ ok: true, rows });
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(cmdResult);
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 { content: [{ type: "text", text }] };
1397
+ return {
1398
+ content: [{ type: "text", text }],
1399
+ structuredContent: { ok: true, text }
1400
+ };
1203
1401
  }
1204
1402
  function json(obj) {
1205
- return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] };
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 results = [];
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(root, full).replace(/\\/g, "/");
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
- results.push(rel);
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(root);
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 res = await conn.sendCommand({ command: `${find} ${Tools.escapePath(remoteRoot)} -type f -print` });
1272
- const lines = res.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
1273
- return lines.map((line) => line.replace(remoteRoot.replace(/\/$/, ""), "").replace(/^[\\/]/, ""));
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`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-for-i",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "description": "Local MCP server and control plane for IBM i workflows",
6
6
  "type": "module",