mcp-for-i 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +17 -0
  2. package/dist/config/store.js +229 -109
  3. package/dist/controlplane/auth.js +128 -0
  4. package/dist/controlplane/clientIntegrationService.js +83 -4
  5. package/dist/controlplane/commands.js +14 -9
  6. package/dist/controlplane/connectionService.js +110 -5
  7. package/dist/controlplane/connectionTest.js +329 -0
  8. package/dist/controlplane/errors.js +12 -0
  9. package/dist/controlplane/runtimeService.js +98 -26
  10. package/dist/controlplane/server.js +122 -11
  11. package/dist/controlplane/serverShared.js +25 -6
  12. package/dist/controlplane/serviceManager.js +113 -100
  13. package/dist/controlplane/ui.js +1742 -920
  14. package/dist/ibmi/CompileTools.js +29 -1
  15. package/dist/ibmi/IBMiContent.js +435 -63
  16. package/dist/ibmi/IbmDocs.js +685 -0
  17. package/dist/ibmi/LocalLanguageActions.js +129 -3
  18. package/dist/ibmi/Search.js +158 -52
  19. package/dist/ibmi/Tools.js +7 -10
  20. package/dist/ibmi/client.js +220 -31
  21. package/dist/ibmi/evfevent.js +256 -0
  22. package/dist/ibmi/knownHosts.js +190 -0
  23. package/dist/ibmi/libraryList.js +93 -0
  24. package/dist/ibmi/sourceInspect.js +815 -0
  25. package/dist/ibmi/sqlJob.js +888 -0
  26. package/dist/ibmi/sqlParse.js +317 -0
  27. package/dist/ibmi/tn5250/certTrust.js +59 -0
  28. package/dist/ibmi/tn5250/client.js +310 -0
  29. package/dist/ibmi/tn5250/datastream.js +109 -0
  30. package/dist/ibmi/tn5250/ebcdic.js +74 -0
  31. package/dist/ibmi/tn5250/index.js +6 -0
  32. package/dist/ibmi/tn5250/screen.js +827 -0
  33. package/dist/ibmi/tn5250/telnet.js +186 -0
  34. package/dist/ibmi/variables.js +18 -7
  35. package/dist/index.js +43 -22
  36. package/dist/mcp/annotations.js +155 -0
  37. package/dist/mcp/audit.js +490 -133
  38. package/dist/mcp/confirm.js +48 -0
  39. package/dist/mcp/context.js +43 -5
  40. package/dist/mcp/deployMethods.js +192 -0
  41. package/dist/mcp/logger.js +5 -10
  42. package/dist/mcp/modules/bulkMembers.js +476 -0
  43. package/dist/mcp/modules/changedLines.js +189 -0
  44. package/dist/mcp/modules/compare.js +198 -0
  45. package/dist/mcp/modules/compile.js +515 -0
  46. package/dist/mcp/modules/ifsOps.js +123 -0
  47. package/dist/mcp/modules/index.js +38 -0
  48. package/dist/mcp/modules/inspect.js +811 -0
  49. package/dist/mcp/modules/libl.js +246 -0
  50. package/dist/mcp/modules/members.js +171 -0
  51. package/dist/mcp/modules/objects.js +339 -0
  52. package/dist/mcp/modules/system.js +266 -0
  53. package/dist/mcp/modules/tn5250.js +515 -0
  54. package/dist/mcp/policy.js +412 -0
  55. package/dist/mcp/tools.js +1700 -698
  56. package/dist/security/credentialStore.js +62 -13
  57. package/dist/security/localPaths.js +138 -0
  58. package/dist/security/redact.js +79 -0
  59. package/dist/util/atomicWrite.js +101 -0
  60. package/dist/util/fileLock.js +206 -0
  61. package/docs/capabilities.md +50 -7
  62. package/docs/configuration.md +189 -60
  63. package/docs/examples.md +109 -73
  64. package/docs/overview.md +15 -15
  65. package/docs/security.md +101 -16
  66. package/docs/tools.md +363 -188
  67. package/package.json +56 -53
package/dist/mcp/tools.js CHANGED
@@ -1,10 +1,20 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+ import { findToolModule, notifyModulesOfDisconnect, toolModules } from "./modules/index.js";
3
+ import { withAnnotations } from "./annotations.js";
4
+ import { runActionTool } from "./modules/compile.js";
5
+ import { parseEvfevent } from "../ibmi/evfevent.js";
1
6
  import { LocalLanguageActions } from "../ibmi/LocalLanguageActions.js";
2
- import { CompileTools } from "../ibmi/CompileTools.js";
3
7
  import { Search } from "../ibmi/Search.js";
8
+ import { ibmDocs } from "../ibmi/IbmDocs.js";
9
+ import { detectSourceLanguage, inspectSource } from "../ibmi/sourceInspect.js";
4
10
  import { Tools } from "../ibmi/Tools.js";
11
+ import { checkReadOnlySql, getSqlStatementType as parseSqlStatementType } from "../ibmi/sqlParse.js";
5
12
  import { ConnectionService } from "../controlplane/connectionService.js";
6
13
  import { log } from "./logger.js";
14
+ import { assertAgentConnectionChangeAllowed, assertSafeIfsDeletePath, checkClCommand, checkClCommands, checkResourcePolicy, checkShellCommand, checkSqlEmbeddedCl, clStartsShell, isShellOperation } from "./policy.js";
15
+ import { configuredLocalRoots, resolveAllowedLocalPath } from "../security/localPaths.js";
7
16
  import { exportAuditRecords, listAuditRecords, purgeAuditRecords, verifyAuditChain } from "./audit.js";
17
+ import { gitChangedFiles, normalizeDeployMethod, normalizeDeploySelection, sha256LocalFile, sha256RemoteFiles } from "./deployMethods.js";
8
18
  import fs from "fs/promises";
9
19
  import path from "path";
10
20
  import crypto from "crypto";
@@ -39,7 +49,14 @@ const SETTINGS_SCHEMA = {
39
49
  autoClearTempData: { type: "boolean" },
40
50
  sourceFileCCSID: { type: "string" },
41
51
  sqlJobCcsid: { type: ["string", "number"] },
52
+ sqlTransport: { type: "string", enum: ["auto", "mapepire", "qzdfmdb2"] },
53
+ mapepireMode: { type: "string", enum: ["auto", "daemon", "ssh"] },
54
+ mapepirePort: { type: "number" },
55
+ mapepireServerPath: { type: "string" },
56
+ mapepireJavaPath: { type: "string" },
42
57
  enableSourceDates: { type: "boolean" },
58
+ sourceReadMaxBytes: { type: "number" },
59
+ sourceReadMaxLines: { type: "number" },
43
60
  homeDirectory: { type: "string" },
44
61
  libraryList: { type: "array", items: { type: "string" } },
45
62
  currentLibrary: { type: "string" },
@@ -50,20 +67,10 @@ const SETTINGS_SCHEMA = {
50
67
  debugSepPort: { type: "number" },
51
68
  sessionIdleMinutes: { type: "number" },
52
69
  sessionPingSeconds: { type: "number" },
53
- sessionReconnectAttempts: { type: "number" }
54
- }
55
- };
56
- const POLICY_SCHEMA = {
57
- type: "object",
58
- properties: {
59
- profile: { type: "string", enum: ["read-only", "guarded", "power-user"] },
60
- requireApprovalFor: { type: "array", items: { type: "string" } },
61
- allowCommands: { type: "array", items: { type: "string" } },
62
- denyCommands: { type: "array", items: { type: "string" } },
63
- allowLibraries: { type: "array", items: { type: "string" } },
64
- denyLibraries: { type: "array", items: { type: "string" } },
65
- allowPaths: { type: "array", items: { type: "string" } },
66
- denyPaths: { type: "array", items: { type: "string" } }
70
+ sessionReconnectAttempts: { type: "number" },
71
+ tn5250Port: { type: "number", description: "TN5250 telnet port (default 23, or 992 when tn5250Tls is true)." },
72
+ tn5250Tls: { type: "boolean", description: "Use TLS for TN5250 sessions (default true when the port is 992)." },
73
+ tn5250DeviceName: { type: "string", description: "Virtual device name requested for TN5250 sessions." }
67
74
  }
68
75
  };
69
76
  const ACTION_SCHEMA = {
@@ -87,13 +94,32 @@ const PROFILE_SCHEMA = {
87
94
  name: { type: "string" },
88
95
  currentLibrary: { type: "string" },
89
96
  libraryList: { type: "array", items: { type: "string" } },
90
- customVariables: { type: "array", items: CUSTOM_VARIABLE_SCHEMA }
97
+ customVariables: { type: "array", items: CUSTOM_VARIABLE_SCHEMA },
98
+ setLibraryListCommand: { type: "string", description: "CL command run by ibmi.libl.runProfileCommand to set the library list." }
91
99
  },
92
100
  required: ["name"]
93
101
  };
94
- const tn5250Sessions = new Map();
95
- const TN5250_MAX_LINES = 30;
96
- const TN5250_MAX_HISTORY = 20;
102
+ const LINE_RANGE_SCHEMA = {
103
+ type: "object",
104
+ properties: {
105
+ start: { type: "number" },
106
+ end: { type: "number" }
107
+ },
108
+ required: ["start", "end"]
109
+ };
110
+ const SOURCE_INSPECTION_OPERATION_SCHEMA = {
111
+ type: "object",
112
+ properties: {
113
+ operation: { type: "string", enum: ["metadata", "outline", "search", "read_ranges", "dependencies"] },
114
+ query: { type: "string" },
115
+ terms: { type: "array", items: { type: "string" } },
116
+ ranges: { type: "array", items: LINE_RANGE_SCHEMA },
117
+ before: { type: "number" },
118
+ after: { type: "number" },
119
+ maxMatches: { type: "number" }
120
+ },
121
+ required: ["operation"]
122
+ };
97
123
  export function getTools() {
98
124
  const tools = [
99
125
  {
@@ -133,6 +159,15 @@ export function getTools() {
133
159
  description: "Terminate a session.",
134
160
  inputSchema: { type: "object", properties: { connectionName: { type: "string" } } }
135
161
  },
162
+ {
163
+ name: "ibmi.session.bootstrap",
164
+ description: "Return connection, IBM i release, library-list, policy, and transport capabilities in one call.",
165
+ inputSchema: {
166
+ type: "object",
167
+ properties: { connectionName: { type: "string" } },
168
+ required: ["connectionName"]
169
+ }
170
+ },
136
171
  {
137
172
  name: "ibmi.connections.list",
138
173
  description: "List saved IBM i connections.",
@@ -140,7 +175,7 @@ export function getTools() {
140
175
  },
141
176
  {
142
177
  name: "ibmi.connections.add",
143
- description: "Add a saved IBM i connection (credentials are managed via local control-plane UI).",
178
+ description: "Add a saved IBM i connection (credentials are managed via local control-plane UI). The policy and the safety settings (readOnlyMode, tempDir, tempLibrary, autoClearTempData, mapepire*) can only be set in the web UI or config.json.",
144
179
  inputSchema: {
145
180
  type: "object",
146
181
  properties: {
@@ -149,8 +184,7 @@ export function getTools() {
149
184
  port: { type: "number", default: 22 },
150
185
  username: { type: "string" },
151
186
  privateKeyPath: { type: "string" },
152
- settings: SETTINGS_SCHEMA,
153
- policy: POLICY_SCHEMA
187
+ settings: SETTINGS_SCHEMA
154
188
  },
155
189
  required: ["name", "host", "username"],
156
190
  additionalProperties: false
@@ -158,7 +192,7 @@ export function getTools() {
158
192
  },
159
193
  {
160
194
  name: "ibmi.connections.update",
161
- description: "Update a saved IBM i connection (credentials are managed via local control-plane UI).",
195
+ description: "Update a saved IBM i connection (credentials are managed via local control-plane UI). The policy and the safety settings (readOnlyMode, tempDir, tempLibrary, autoClearTempData, mapepire*) can only be changed in the web UI or config.json.",
162
196
  inputSchema: {
163
197
  type: "object",
164
198
  properties: {
@@ -167,8 +201,7 @@ export function getTools() {
167
201
  port: { type: "number" },
168
202
  username: { type: "string" },
169
203
  privateKeyPath: { type: "string" },
170
- settings: SETTINGS_SCHEMA,
171
- policy: POLICY_SCHEMA
204
+ settings: SETTINGS_SCHEMA
172
205
  },
173
206
  required: ["name"],
174
207
  additionalProperties: false
@@ -214,25 +247,56 @@ export function getTools() {
214
247
  description: "List members in a source file.",
215
248
  inputSchema: {
216
249
  type: "object",
217
- properties: { library: { type: "string" }, sourceFile: { type: "string" } },
250
+ properties: {
251
+ connectionName: { type: "string" },
252
+ library: { type: "string" },
253
+ sourceFile: { type: "string" },
254
+ member: { type: "string" },
255
+ memberPrefix: { type: "string" },
256
+ memberContains: { type: "string" },
257
+ sourceTypes: { type: "array", items: { type: "string" } },
258
+ changedSince: { type: "string" },
259
+ limit: { type: "number" },
260
+ cursor: { type: "string" },
261
+ responseFormat: { type: "string", enum: ["legacy-array", "page"] }
262
+ },
218
263
  required: ["library", "sourceFile"]
219
264
  }
220
265
  },
221
266
  {
222
267
  name: "ibmi.qsys.members.read",
223
- description: "Read a member.",
268
+ description: "Read a member. Full reads larger than the sourceReadMaxBytes/sourceReadMaxLines settings fail with SOURCE_TOO_LARGE; use startLine/endLine, ranges, ibmi.search.members or ibmi.source.inspect instead. startLine without endLine reads to the end of the member.",
224
269
  inputSchema: {
225
270
  type: "object",
226
- properties: { library: { type: "string" }, sourceFile: { type: "string" }, member: { type: "string" } },
271
+ properties: {
272
+ connectionName: { type: "string" },
273
+ library: { type: "string" },
274
+ sourceFile: { type: "string" },
275
+ member: { type: "string" },
276
+ startLine: { type: "number" },
277
+ endLine: { type: "number" },
278
+ ranges: { type: "array", items: LINE_RANGE_SCHEMA },
279
+ before: { type: "number" },
280
+ after: { type: "number" },
281
+ metadataOnly: { type: "boolean" },
282
+ expectedVersion: { type: "string" },
283
+ expectedSha256: { type: "string" },
284
+ forceFresh: { type: "boolean" },
285
+ responseFormat: { type: "string", enum: ["text", "structured"] }
286
+ },
227
287
  required: ["library", "sourceFile", "member"]
228
288
  }
229
289
  },
230
290
  {
231
291
  name: "ibmi.qsys.members.write",
232
- description: "Write a member.",
292
+ description: "Write a member. Pass expectedVersion or expectedSha256 from a structured read to reject the write (VERSION_CONFLICT error) if the member changed. With a version check or responseFormat \"structured\" the result carries the new version and sha256 for chaining edits.",
233
293
  inputSchema: {
234
294
  type: "object",
235
- properties: { library: { type: "string" }, sourceFile: { type: "string" }, member: { type: "string" }, content: { type: "string" } },
295
+ properties: {
296
+ connectionName: { type: "string" }, library: { type: "string" }, sourceFile: { type: "string" }, member: { type: "string" }, content: { type: "string" },
297
+ expectedVersion: { type: "string" }, expectedSha256: { type: "string" },
298
+ responseFormat: { type: "string", enum: ["text", "structured"] }
299
+ },
236
300
  required: ["library", "sourceFile", "member", "content"]
237
301
  }
238
302
  },
@@ -284,8 +348,16 @@ export function getTools() {
284
348
  },
285
349
  {
286
350
  name: "ibmi.ifs.read",
287
- description: "Read an IFS file.",
288
- inputSchema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }
351
+ description: "Read an IFS file. Text is returned as-is; files that are not valid UTF-8 text are returned base64-encoded. Files larger than maxBytes are refused (use ibmi.ifs.download).",
352
+ inputSchema: {
353
+ type: "object",
354
+ properties: {
355
+ path: { type: "string" },
356
+ maxBytes: { type: "number", description: "Maximum file size to read (default 1048576, max 16777216)." },
357
+ encoding: { type: "string", enum: ["auto", "utf8", "base64"], default: "auto" }
358
+ },
359
+ required: ["path"]
360
+ }
289
361
  },
290
362
  {
291
363
  name: "ibmi.ifs.write",
@@ -299,8 +371,16 @@ export function getTools() {
299
371
  },
300
372
  {
301
373
  name: "ibmi.ifs.delete",
302
- description: "Delete an IFS file or directory.",
303
- inputSchema: { type: "object", properties: { path: { type: "string" }, recursive: { type: "boolean" } }, required: ["path"] }
374
+ description: "Delete an IFS file or directory. A recursive delete needs confirmPath equal to path and is refused for paths less than three levels deep (for example /home/USER).",
375
+ inputSchema: {
376
+ type: "object",
377
+ properties: {
378
+ path: { type: "string" },
379
+ recursive: { type: "boolean" },
380
+ confirmPath: { type: "string", description: "Required with recursive=true: repeat the exact path to confirm the delete." }
381
+ },
382
+ required: ["path"]
383
+ }
304
384
  },
305
385
  {
306
386
  name: "ibmi.ifs.upload",
@@ -314,16 +394,93 @@ export function getTools() {
314
394
  },
315
395
  {
316
396
  name: "ibmi.search.members",
317
- description: "Search in source members.",
397
+ description: "Search in source members. Returns at most maxMatches matching lines (default 100, maximum 1000) and reports truncated: true when more exist. Lines are marked kind \"match\" or \"context\".",
318
398
  inputSchema: {
319
399
  type: "object",
320
400
  properties: {
401
+ connectionName: { type: "string" },
321
402
  library: { type: "string" },
322
403
  sourceFile: { type: "string" },
323
404
  term: { type: "string" },
324
- members: { type: ["string", "array"], items: { type: "string" } }
405
+ terms: { type: "array", items: { type: "string" } },
406
+ members: { type: ["string", "array"], items: { type: "string" } },
407
+ caseSensitive: { type: "boolean" },
408
+ before: { type: "number" },
409
+ after: { type: "number" },
410
+ maxMatches: { type: "number" }
411
+ },
412
+ required: ["library", "sourceFile"]
413
+ }
414
+ },
415
+ {
416
+ name: "ibmi.source.resolve",
417
+ description: "Resolve an exact source member across candidate source files without listing every member.",
418
+ inputSchema: {
419
+ type: "object",
420
+ properties: {
421
+ connectionName: { type: "string" },
422
+ library: { type: "string" },
423
+ member: { type: "string" },
424
+ candidateSourceFiles: { type: "array", items: { type: "string" } },
425
+ sourceTypes: { type: "array", items: { type: "string" } },
426
+ limit: { type: "number" }
427
+ },
428
+ required: ["connectionName", "library", "member"]
429
+ }
430
+ },
431
+ {
432
+ name: "ibmi.source.inspect",
433
+ description: "Resolve and inspect one source member with batched metadata, outline, search, ranges, and dependency operations.",
434
+ inputSchema: {
435
+ type: "object",
436
+ properties: {
437
+ connectionName: { type: "string" },
438
+ library: { type: "string" },
439
+ sourceFile: { type: "string" },
440
+ member: { type: "string" },
441
+ candidateSourceFiles: { type: "array", items: { type: "string" } },
442
+ sourceTypes: { type: "array", items: { type: "string" } },
443
+ operations: { type: "array", items: SOURCE_INSPECTION_OPERATION_SCHEMA },
444
+ expectedVersion: { type: "string" },
445
+ expectedSha256: { type: "string" },
446
+ forceFresh: { type: "boolean" }
447
+ },
448
+ required: ["connectionName", "library", "member", "operations"]
449
+ }
450
+ },
451
+ {
452
+ name: "ibmi.docs.search",
453
+ description: "Search the release-aware catalog and cache of official IBM i documentation.",
454
+ inputSchema: {
455
+ type: "object",
456
+ properties: {
457
+ query: { type: "string" },
458
+ connectionName: { type: "string" },
459
+ releases: { type: "array", items: { type: "string" } },
460
+ categories: { type: "array", items: { type: "string" } },
461
+ limit: { type: "number" },
462
+ cursor: { type: "string" },
463
+ freshness: { type: "string", enum: ["indexed", "prefer-live"] }
325
464
  },
326
- required: ["library", "sourceFile", "term"]
465
+ required: ["query"]
466
+ }
467
+ },
468
+ {
469
+ name: "ibmi.docs.open",
470
+ description: "Open and cache an official IBM documentation page: a ref from ibmi.docs.search or any https://www.ibm.com/docs/ URL. Cached copies expire after 7 days by default.",
471
+ inputSchema: {
472
+ type: "object",
473
+ properties: {
474
+ ref: { type: "string" },
475
+ url: { type: "string" },
476
+ expectedVersion: { type: "string" },
477
+ requireLive: { type: "boolean" },
478
+ freshness: { type: "string", enum: ["allow-cached", "require-live"] },
479
+ section: { type: "string" },
480
+ startLine: { type: "number" },
481
+ endLine: { type: "number" },
482
+ maxCharacters: { type: "number" }
483
+ }
327
484
  }
328
485
  },
329
486
  {
@@ -343,20 +500,32 @@ export function getTools() {
343
500
  },
344
501
  {
345
502
  name: "ibmi.actions.run",
346
- description: "Run an action.",
503
+ description: "Run an action (for example a compile) against a source member or an IFS streamfile. " +
504
+ "Member targets set &OPENLIB/&OPENSPF/&OPENMBR/&EXT/&NAME; streamfile targets set &FULLPATH/&RELATIVEPATH/&NAME/&EXT. " +
505
+ "When the command uses OPTION(*EVENTF) the EVFEVENT file is read and errors are returned grouped by file with line, column, severity and message id. " +
506
+ "Honours the action's deployFirst (with localWorkspace + remoteWorkspace), postDownload (to downloadTo or localWorkspace) and outputToFile. " +
507
+ "Set includeListing to also return the compile listing spooled file.",
347
508
  inputSchema: {
348
509
  type: "object",
349
510
  properties: {
350
511
  actionName: { type: "string" },
351
512
  action: ACTION_SCHEMA,
352
513
  targetType: { type: "string", enum: ["member", "streamfile", "file"] },
353
- targetPath: { type: "string" },
514
+ targetPath: { type: "string", description: "Streamfile: absolute IFS path. Member: LIB/FILE/MEMBER[.TYPE] (or use library/sourceFile/member)." },
354
515
  library: { type: "string" },
355
516
  sourceFile: { type: "string" },
356
517
  member: { type: "string" },
357
- extension: { type: "string" }
518
+ extension: { type: "string", description: "Member type (looked up when omitted) or streamfile extension." },
519
+ cwd: { type: "string", description: "Remote working directory for streamfile actions (default remoteWorkspace, then the home directory)." },
520
+ localWorkspace: { type: "string", description: "Local project folder, used by deployFirst, postDownload and relative outputToFile paths." },
521
+ remoteWorkspace: { type: "string", description: "IFS folder the local workspace deploys to." },
522
+ downloadTo: { type: "string", description: "Local folder for postDownload outputs (default localWorkspace)." },
523
+ evfeventLibrary: { type: "string", description: "Override the EVFEVENT library (default: the compiled object's library)." },
524
+ evfeventMember: { type: "string", description: "Override the EVFEVENT member (default: the compiled object's name)." },
525
+ includeListing: { type: "boolean", description: "Also return the compile listing spooled file." },
526
+ listingMaxLines: { type: "number" }
358
527
  },
359
- required: ["targetType", "targetPath"]
528
+ required: ["targetType"]
360
529
  }
361
530
  },
362
531
  {
@@ -534,20 +703,30 @@ export function getTools() {
534
703
  },
535
704
  {
536
705
  name: "ibmi.deploy.compare",
537
- description: "Compare local and remote directories (by relative paths).",
538
- inputSchema: { type: "object", properties: { localPath: { type: "string" }, remotePath: { type: "string" } }, required: ["localPath", "remotePath"] }
706
+ description: "Compare local and remote directories (by relative paths). method \"all\" compares sizes; method \"changed\" also compares SHA-256 content hashes of files present on both sides.",
707
+ inputSchema: {
708
+ type: "object",
709
+ properties: {
710
+ localPath: { type: "string" },
711
+ remotePath: { type: "string" },
712
+ method: { type: "string", enum: ["all", "changed"], default: "all" }
713
+ },
714
+ required: ["localPath", "remotePath"]
715
+ }
539
716
  },
540
717
  {
541
718
  name: "ibmi.deploy.sync",
542
- description: "Sync local directory to remote (upload missing or overwrite).",
719
+ description: "Sync a local directory to the IFS. method: \"all\" (default) uploads missing files and, with overwrite, size-changed ones; \"changed\" uploads only files whose SHA-256 differs or that are missing remotely; \"gitChanged\" uploads staged, unstaged and untracked files from git status; \"selection\" uploads the listed files (paths relative to localPath). Reports uploaded, unchanged and skipped counts.",
543
720
  inputSchema: {
544
721
  type: "object",
545
722
  properties: {
546
723
  localPath: { type: "string" },
547
724
  remotePath: { type: "string" },
548
- overwrite: { type: "boolean", default: false },
725
+ method: { type: "string", enum: ["all", "changed", "gitChanged", "selection"], default: "all" },
726
+ files: { type: "array", items: { type: "string" }, description: "Relative file paths for method \"selection\"." },
727
+ overwrite: { type: "boolean", default: false, description: "method \"all\" only: also upload files whose size differs." },
549
728
  dryRun: { type: "boolean", default: false },
550
- deleteExtraRemote: { type: "boolean", default: false }
729
+ deleteExtraRemote: { type: "boolean", default: false, description: "methods \"all\" and \"changed\" only." }
551
730
  },
552
731
  required: ["localPath", "remotePath"]
553
732
  }
@@ -600,7 +779,7 @@ export function getTools() {
600
779
  },
601
780
  {
602
781
  name: "ibmi.diagnostics.parseEvfevent",
603
- description: "Parse EVFEVENT text into structured diagnostics.",
782
+ description: "Parse EVFEVENT (compiler events file) records into errors grouped by source file, with line, column, severity and message id. Errors in SQL precompiler temp files are mapped back to the original source.",
604
783
  inputSchema: { type: "object", properties: { content: { type: "string" } }, required: ["content"] }
605
784
  },
606
785
  {
@@ -610,8 +789,18 @@ export function getTools() {
610
789
  },
611
790
  {
612
791
  name: "ibmi.spool.list",
613
- description: "List spool file entries.",
614
- inputSchema: { type: "object", properties: { connectionName: { type: "string" }, limit: { type: "number" } } }
792
+ description: "List spool file entries, newest first, optionally filtered by user, output queue or job.",
793
+ inputSchema: {
794
+ type: "object",
795
+ properties: {
796
+ connectionName: { type: "string" },
797
+ limit: { type: "number" },
798
+ userName: { type: "string", description: "User profile that owns the spooled files." },
799
+ outputQueue: { type: "string", description: "Output queue name." },
800
+ outputQueueLibrary: { type: "string", description: "Output queue library." },
801
+ jobName: { type: "string", description: "Qualified job name (number/user/name) or a simple job name." }
802
+ }
803
+ }
615
804
  },
616
805
  {
617
806
  name: "ibmi.spool.read",
@@ -878,14 +1067,15 @@ export function getTools() {
878
1067
  },
879
1068
  {
880
1069
  name: "ibmi.dataqueue.receive",
881
- description: "Receive (or peek) an entry from a data queue.",
1070
+ description: "Peek at (default) or receive an entry from a data queue. remove=true takes the entry off the queue and needs a writable connection.",
882
1071
  inputSchema: {
883
1072
  type: "object",
884
1073
  properties: {
885
1074
  library: { type: "string" },
886
1075
  queue: { type: "string" },
887
- waitSeconds: { type: "number", default: 0 },
888
- remove: { type: "boolean", default: true },
1076
+ waitSeconds: { type: "number", default: 0, description: "Seconds to wait for an entry (0 to 60)." },
1077
+ remove: { type: "boolean", default: false },
1078
+ approve: { type: "boolean", description: "Only used when the connection's approvalMode is \"agent\" (remove=true). By default the user is asked to confirm." },
889
1079
  connectionName: { type: "string" }
890
1080
  },
891
1081
  required: ["library", "queue"]
@@ -985,7 +1175,7 @@ export function getTools() {
985
1175
  },
986
1176
  {
987
1177
  name: "ibmi.journal.entries.query",
988
- description: "Read normalized entries from a journal.",
1178
+ description: "Read normalized journal entries, newest first. Filters are applied on the IBM i before the row limit.",
989
1179
  inputSchema: {
990
1180
  type: "object",
991
1181
  properties: {
@@ -993,6 +1183,7 @@ export function getTools() {
993
1183
  journal: { type: "string" },
994
1184
  objectLibrary: { type: "string" },
995
1185
  objectName: { type: "string" },
1186
+ objectType: { type: "string", description: "Object type for the object filter (default *FILE)." },
996
1187
  journalCode: { type: "string" },
997
1188
  entryType: { type: "string" },
998
1189
  userName: { type: "string" },
@@ -1158,57 +1349,25 @@ export function getTools() {
1158
1349
  connectionName: { type: "string" }
1159
1350
  }
1160
1351
  }
1161
- },
1162
- {
1163
- name: "ibmi.tn5250.connect",
1164
- description: "Connect to a TN5250 command session.",
1165
- inputSchema: { type: "object", properties: { connectionName: { type: "string" } } }
1166
- },
1167
- {
1168
- name: "ibmi.tn5250.readScreen",
1169
- description: "Read TN5250 screen model.",
1170
- inputSchema: { type: "object", properties: { connectionName: { type: "string" } } }
1171
- },
1172
- {
1173
- name: "ibmi.tn5250.setField",
1174
- description: "Set TN5250 field value.",
1175
- inputSchema: {
1176
- type: "object",
1177
- properties: { fieldId: { type: "string" }, value: { type: "string" }, connectionName: { type: "string" } },
1178
- required: ["fieldId", "value"]
1179
- }
1180
- },
1181
- {
1182
- name: "ibmi.tn5250.sendKeys",
1183
- description: "Send TN5250 keys.",
1184
- inputSchema: {
1185
- type: "object",
1186
- properties: { keys: { type: "string" }, timeoutMs: { type: "number" }, connectionName: { type: "string" }, approve: { type: "boolean" } },
1187
- required: ["keys"]
1188
- }
1189
- },
1190
- {
1191
- name: "ibmi.tn5250.waitFor",
1192
- description: "Wait for TN5250 text condition.",
1193
- inputSchema: { type: "object", properties: { text: { type: "string" }, timeoutMs: { type: "number" }, connectionName: { type: "string" } } }
1194
- },
1195
- {
1196
- name: "ibmi.tn5250.snapshot",
1197
- description: "Capture TN5250 snapshot.",
1198
- inputSchema: { type: "object", properties: { connectionName: { type: "string" } } }
1199
- },
1200
- {
1201
- name: "ibmi.tn5250.disconnect",
1202
- description: "Disconnect TN5250 session.",
1203
- inputSchema: { type: "object", properties: { connectionName: { type: "string" } } }
1204
1352
  }
1205
1353
  ];
1206
- return tools
1354
+ return [...tools, ...toolModules.flatMap(module => module.tools)]
1207
1355
  .map(withGuardedApprovalHint)
1208
- .map(withStrictInputSchema);
1356
+ .map(withSourceConnectionName)
1357
+ .map(withStrictInputSchema)
1358
+ .map(withAnnotations);
1209
1359
  }
1360
+ /** The outermost tool call being handled, so confirmation prompts can name it. */
1361
+ const toolCallStore = new AsyncLocalStorage();
1210
1362
  export async function handleTool(ctx, name, args) {
1363
+ if (!toolCallStore.getStore()) {
1364
+ return toolCallStore.run({ toolName: name }, () => handleTool(ctx, name, args));
1365
+ }
1211
1366
  const connections = new ConnectionService(ctx.store);
1367
+ await enforceResourcePolicy(ctx, name, args);
1368
+ const toolModule = findToolModule(name);
1369
+ if (toolModule)
1370
+ return toolModule.handle(ctx, name, args);
1212
1371
  switch (name) {
1213
1372
  case "ibmi.connect": {
1214
1373
  if (!args?.name)
@@ -1222,12 +1381,7 @@ export async function handleTool(ctx, name, args) {
1222
1381
  case "ibmi.disconnect": {
1223
1382
  const name = args?.connectionName || ctx.activeName;
1224
1383
  await ctx.disconnect(args?.connectionName);
1225
- if (name) {
1226
- tn5250Sessions.delete(name);
1227
- }
1228
- else {
1229
- tn5250Sessions.clear();
1230
- }
1384
+ notifyModulesOfDisconnect(name);
1231
1385
  return result("Disconnected");
1232
1386
  }
1233
1387
  case "ibmi.session.list": {
@@ -1242,21 +1396,57 @@ export async function handleTool(ctx, name, args) {
1242
1396
  case "ibmi.session.terminate": {
1243
1397
  const name = args?.connectionName || ctx.activeName;
1244
1398
  await ctx.terminateSession(args?.connectionName);
1245
- if (name)
1246
- tn5250Sessions.delete(name);
1399
+ notifyModulesOfDisconnect(name);
1247
1400
  return result("Terminated");
1248
1401
  }
1402
+ case "ibmi.session.bootstrap": {
1403
+ const conn = await ctx.ensureActive(args.connectionName);
1404
+ const system = await readSystemFacts(conn);
1405
+ const config = conn.getConfig();
1406
+ return json({
1407
+ connectionName: conn.currentConnectionName,
1408
+ host: conn.currentHost,
1409
+ systemName: system.systemName,
1410
+ effectiveUser: system.effectiveUser || conn.currentUser,
1411
+ release: system.release,
1412
+ currentLibrary: config.currentLibrary || "QGPL",
1413
+ libraryList: config.libraryList || [],
1414
+ defaultUserLibraries: conn.defaultUserLibraries,
1415
+ ccsid: config.sqlJobCcsid ?? 1208,
1416
+ sourceFileCcsid: config.sourceFileCCSID || "*FILE",
1417
+ policy: ctx.getPolicy(args.connectionName),
1418
+ transports: {
1419
+ sql: conn.sqlJob?.isOpen?.() ? "MAPEPIRE" : "QZDFMDB2",
1420
+ sqlJob: conn.sqlJob?.status?.(),
1421
+ sourceRead: ["sql-member", "cpytostmf"]
1422
+ },
1423
+ remoteFeatures: conn.remoteFeatures,
1424
+ capabilities: {
1425
+ version: "1",
1426
+ memberPaging: true,
1427
+ rangedMemberRead: true,
1428
+ multiTermSearch: true,
1429
+ sourceResolve: true,
1430
+ sourceInspect: true,
1431
+ optimisticConcurrency: true,
1432
+ ibmDocs: true
1433
+ },
1434
+ validatedAt: new Date().toISOString()
1435
+ });
1436
+ }
1249
1437
  case "ibmi.connections.list": {
1250
1438
  const list = await connections.list();
1251
1439
  return json(list);
1252
1440
  }
1253
1441
  case "ibmi.connections.add": {
1254
1442
  rejectCredentialArgs(args, "ibmi.connections.add");
1443
+ assertAgentConnectionChangeAllowed("ibmi.connections.add", args);
1255
1444
  await connections.add(args);
1256
1445
  return result(`Connection ${args.name} saved`);
1257
1446
  }
1258
1447
  case "ibmi.connections.update": {
1259
1448
  rejectCredentialArgs(args, "ibmi.connections.update");
1449
+ assertAgentConnectionChangeAllowed("ibmi.connections.update", args);
1260
1450
  await connections.update(args.name, args);
1261
1451
  return result(`Connection ${args.name} updated`);
1262
1452
  }
@@ -1308,47 +1498,100 @@ export async function handleTool(ctx, name, args) {
1308
1498
  }
1309
1499
  case "ibmi.qsys.members.list": {
1310
1500
  const conn = await ctx.ensureActive(args?.connectionName);
1311
- const data = await conn.content.getMemberList({ library: args.library, sourceFile: args.sourceFile });
1501
+ const data = hasMemberListExtensions(args)
1502
+ ? await conn.content.getMemberListPage({
1503
+ library: args.library,
1504
+ sourceFile: args.sourceFile,
1505
+ member: args.member,
1506
+ memberPrefix: args.memberPrefix,
1507
+ memberContains: args.memberContains,
1508
+ sourceTypes: args.sourceTypes,
1509
+ changedSince: args.changedSince,
1510
+ limit: args.limit,
1511
+ cursor: args.cursor
1512
+ })
1513
+ : await conn.content.getMemberList({ library: args.library, sourceFile: args.sourceFile });
1312
1514
  return json(data);
1313
1515
  }
1314
1516
  case "ibmi.qsys.members.read": {
1315
1517
  const conn = await ctx.ensureActive(args?.connectionName);
1316
- const content = await conn.content.downloadMemberContent(args.library, args.sourceFile, args.member);
1317
- return result(content);
1518
+ const limits = sourceReadLimits(conn);
1519
+ if (!hasStructuredMemberReadOptions(args)) {
1520
+ const content = await conn.content.downloadMemberContent(args.library, args.sourceFile, args.member);
1521
+ const tooLarge = sourceTooLarge(content, undefined, limits, args);
1522
+ if (tooLarge)
1523
+ return tooLarge;
1524
+ return result(content);
1525
+ }
1526
+ const wantsVersionCheck = Boolean(args.expectedVersion || args.expectedSha256);
1527
+ if (args.metadataOnly && !wantsVersionCheck && !args.forceFresh && typeof conn.content.getMemberVersionInfo === "function") {
1528
+ // Metadata-only reads never download content; a validated cached
1529
+ // snapshot supplies sha256/version when one is available.
1530
+ const info = await conn.content.getMemberVersionInfo(args.library, args.sourceFile, args.member);
1531
+ if (info.snapshot)
1532
+ return json(formatMemberRead(info.snapshot, args, limits));
1533
+ return json(memberMetadataEnvelope(args, info.metadata, info.observedAt));
1534
+ }
1535
+ if (isFullMemberRead(args) && typeof conn.content.getMemberMetadata === "function") {
1536
+ const metadata = await conn.content.getMemberMetadata(args.library, args.sourceFile, args.member);
1537
+ const rows = Number(metadata?.rowCount || 0);
1538
+ if (limits.maxLines > 0 && rows > limits.maxLines) {
1539
+ return sourceTooLargeError(args, { lineCount: rows, metadata }, limits);
1540
+ }
1541
+ }
1542
+ const snapshot = await conn.content.getMemberSnapshot(args.library, args.sourceFile, args.member, Boolean(args.forceFresh));
1543
+ const conflict = memberVersionConflict(snapshot, args);
1544
+ if (conflict)
1545
+ return conflict;
1546
+ return formatMemberReadResult(snapshot, args, limits);
1318
1547
  }
1319
1548
  case "ibmi.qsys.members.write": {
1320
1549
  const conn = await ctx.ensureActive(args?.connectionName);
1321
- enforceWritable(ctx, conn, "qsys.write", args);
1550
+ await enforceWritable(ctx, conn, "qsys.write", args);
1551
+ const versionChecked = Boolean(args.expectedVersion || args.expectedSha256);
1552
+ let previous;
1553
+ if (versionChecked) {
1554
+ // Re-read the member fresh immediately before writing. A change made
1555
+ // between this check and the write itself can still slip through;
1556
+ // the host offers no compare-and-swap for source members.
1557
+ previous = await conn.content.getMemberSnapshot(args.library, args.sourceFile, args.member, true);
1558
+ const conflict = memberVersionConflict(previous, args);
1559
+ if (conflict)
1560
+ return conflict;
1561
+ }
1322
1562
  await conn.content.uploadMemberContent(args.library, args.sourceFile, args.member, args.content);
1323
- return result("OK");
1563
+ const written = await describeWrittenMember(conn, args, previous);
1564
+ if (versionChecked || args.responseFormat === "structured")
1565
+ return json(written);
1566
+ return { content: [{ type: "text", text: "OK" }], structuredContent: { ok: true, text: "OK", data: written } };
1324
1567
  }
1325
1568
  case "ibmi.qsys.members.create": {
1326
1569
  const conn = await ctx.ensureActive(args?.connectionName);
1327
- enforceWritable(ctx, conn, "qsys.write", args);
1570
+ await enforceWritable(ctx, conn, "qsys.write", args);
1328
1571
  await conn.content.createMember(args.library, args.sourceFile, args.member, args.srctype);
1329
1572
  return result("OK");
1330
1573
  }
1331
1574
  case "ibmi.qsys.members.rename": {
1332
1575
  const conn = await ctx.ensureActive(args?.connectionName);
1333
- enforceWritable(ctx, conn, "qsys.write", args);
1576
+ await enforceWritable(ctx, conn, "qsys.write", args);
1334
1577
  await conn.content.renameMember(args.library, args.sourceFile, args.member, args.newMember);
1335
1578
  return result("OK");
1336
1579
  }
1337
1580
  case "ibmi.qsys.members.delete": {
1338
1581
  const conn = await ctx.ensureActive(args?.connectionName);
1339
- enforceWritable(ctx, conn, "qsys.write", args);
1582
+ await enforceWritable(ctx, conn, "qsys.write", args);
1340
1583
  await conn.content.deleteMember(args.library, args.sourceFile, args.member);
1341
1584
  return result("OK");
1342
1585
  }
1343
1586
  case "ibmi.qsys.sourcefiles.create": {
1344
1587
  const conn = await ctx.ensureActive(args?.connectionName);
1345
- enforceWritable(ctx, conn, "qsys.write", args);
1588
+ await enforceWritable(ctx, conn, "qsys.write", args);
1346
1589
  await conn.content.createSourceFile(args.library, args.sourceFile, args.rcdlen || 112);
1347
1590
  return result("OK");
1348
1591
  }
1349
1592
  case "ibmi.qsys.libraries.create": {
1350
1593
  const conn = await ctx.ensureActive(args?.connectionName);
1351
- enforceWritable(ctx, conn, "qsys.write", args);
1594
+ await enforceWritable(ctx, conn, "qsys.write", args);
1352
1595
  await conn.content.createLibrary(args.library);
1353
1596
  return result("OK");
1354
1597
  }
@@ -1359,48 +1602,159 @@ export async function handleTool(ctx, name, args) {
1359
1602
  }
1360
1603
  case "ibmi.ifs.read": {
1361
1604
  const conn = await ctx.ensureActive(args?.connectionName);
1362
- const data = await conn.content.downloadStreamfileRaw(args.path);
1363
- return result(Buffer.from(data).toString("utf8"));
1605
+ const maxBytes = clampNumber(args?.maxBytes, IFS_READ_DEFAULT_MAX_BYTES, 1, IFS_READ_HARD_MAX_BYTES);
1606
+ const encoding = String(args?.encoding || "auto").toLowerCase();
1607
+ if (!["auto", "utf8", "base64"].includes(encoding))
1608
+ throw new Error("encoding must be auto, utf8 or base64");
1609
+ const knownSize = await statIfsFileSize(conn, String(args.path));
1610
+ if (knownSize !== undefined && knownSize > maxBytes) {
1611
+ return toolError(`File is ${knownSize} bytes, which is more than maxBytes (${maxBytes}). Raise maxBytes or use ibmi.ifs.download.`, { path: args.path, size: knownSize, maxBytes });
1612
+ }
1613
+ const data = Buffer.from(await conn.content.downloadStreamfileRaw(args.path));
1614
+ if (data.length > maxBytes) {
1615
+ return toolError(`File is ${data.length} bytes, which is more than maxBytes (${maxBytes}). Raise maxBytes or use ibmi.ifs.download.`, { path: args.path, size: data.length, maxBytes });
1616
+ }
1617
+ const text = encoding === "base64" ? undefined : decodeUtf8Text(data);
1618
+ if (text !== undefined)
1619
+ return result(text);
1620
+ if (encoding === "utf8") {
1621
+ return toolError("File is not valid UTF-8 text; read it with encoding=base64.", { path: args.path, size: data.length });
1622
+ }
1623
+ return json({ path: args.path, size: data.length, encoding: "base64", content: data.toString("base64") });
1364
1624
  }
1365
1625
  case "ibmi.ifs.write": {
1366
1626
  const conn = await ctx.ensureActive(args?.connectionName);
1367
1627
  const op = resolveIfsWriteOperation(args.path);
1368
- enforceWritable(ctx, conn, op, args);
1628
+ await enforceWritable(ctx, conn, op, args);
1369
1629
  await conn.content.writeStreamfileRaw(args.path, args.content);
1370
1630
  return result("OK");
1371
1631
  }
1372
1632
  case "ibmi.ifs.mkdir": {
1373
1633
  const conn = await ctx.ensureActive(args?.connectionName);
1374
1634
  const op = resolveIfsWriteOperation(args.path);
1375
- enforceWritable(ctx, conn, op, args);
1376
- await conn.sendCommand({ command: `mkdir -p ${Tools.escapePath(args.path)}` });
1635
+ await enforceWritable(ctx, conn, op, args);
1636
+ requireCommandSuccess(await conn.sendCommand({ command: `mkdir -p ${Tools.escapePath(args.path)}` }), "mkdir");
1377
1637
  return result("OK");
1378
1638
  }
1379
1639
  case "ibmi.ifs.delete": {
1380
1640
  const conn = await ctx.ensureActive(args?.connectionName);
1381
- enforceWritable(ctx, conn, "ifs.delete", args);
1641
+ await enforceWritable(ctx, conn, "ifs.delete", args);
1642
+ const deletePath = assertSafeIfsDeletePath(args.path);
1643
+ if (args.recursive) {
1644
+ // A recursive delete removes a whole tree, so it must be deep enough and confirmed.
1645
+ if (deletePath.split("/").filter(Boolean).length < 3) {
1646
+ throw new Error(`ifs.delete refused: recursive deletes need a path at least three levels deep (${deletePath})`);
1647
+ }
1648
+ if (typeof args.confirmPath !== "string" || args.confirmPath.trim().replace(/\/+$/, "") !== deletePath) {
1649
+ throw new Error("ifs.delete refused: recursive deletes need confirmPath set to the exact path being deleted");
1650
+ }
1651
+ }
1382
1652
  const flag = args.recursive ? "-rf" : "-f";
1383
- await conn.sendCommand({ command: `rm ${flag} ${Tools.escapePath(args.path)}` });
1653
+ requireCommandSuccess(await conn.sendCommand({ command: `rm ${flag} ${Tools.escapePath(deletePath)}` }), "rm");
1384
1654
  return result("OK");
1385
1655
  }
1386
1656
  case "ibmi.ifs.upload": {
1387
1657
  const conn = await ctx.ensureActive(args?.connectionName);
1388
1658
  const op = resolveIfsWriteOperation(args.remotePath);
1389
- enforceWritable(ctx, conn, op, args);
1390
- await conn.client.putFile(args.localPath, args.remotePath);
1659
+ await enforceWritable(ctx, conn, op, args);
1660
+ const localPath = await guardLocalPath(ctx, args.localPath, "read");
1661
+ await conn.client.putFile(localPath, args.remotePath);
1391
1662
  return result("OK");
1392
1663
  }
1393
1664
  case "ibmi.ifs.download": {
1394
1665
  const conn = await ctx.ensureActive(args?.connectionName);
1395
- await conn.client.getFile(args.localPath, args.remotePath);
1666
+ const localPath = await guardLocalPath(ctx, args.localPath, "write");
1667
+ await conn.client.getFile(localPath, args.remotePath);
1396
1668
  return result("OK");
1397
1669
  }
1398
1670
  case "ibmi.search.members": {
1399
1671
  const conn = await ctx.ensureActive(args?.connectionName);
1400
1672
  const members = args.members || "*";
1401
- const data = await Search.searchMembers(conn, args.library, args.sourceFile, args.term, members, false);
1673
+ const terms = normalizeSearchTerms(args);
1674
+ const data = await Search.searchMembersTerms(conn, args.library, args.sourceFile, terms, members, false, {
1675
+ caseSensitive: Boolean(args.caseSensitive),
1676
+ before: args.before,
1677
+ after: args.after,
1678
+ maxMatches: args.maxMatches
1679
+ });
1402
1680
  return json(data);
1403
1681
  }
1682
+ case "ibmi.source.resolve": {
1683
+ const conn = await ctx.ensureActive(args.connectionName);
1684
+ const candidates = await conn.content.resolveMembers({
1685
+ library: args.library,
1686
+ member: args.member,
1687
+ candidateSourceFiles: args.candidateSourceFiles,
1688
+ sourceTypes: args.sourceTypes,
1689
+ limit: args.limit
1690
+ });
1691
+ rankMemberCandidates(candidates, args.candidateSourceFiles);
1692
+ return json({
1693
+ status: candidates.length === 0 ? "not-found" : candidates.length === 1 ? "found" : "ambiguous",
1694
+ library: String(args.library).toUpperCase(),
1695
+ member: String(args.member).toUpperCase(),
1696
+ candidates,
1697
+ returned: candidates.length,
1698
+ observedAt: new Date().toISOString()
1699
+ });
1700
+ }
1701
+ case "ibmi.source.inspect": {
1702
+ const conn = await ctx.ensureActive(args.connectionName);
1703
+ const operations = Array.isArray(args.operations) ? args.operations : [];
1704
+ if (operations.length < 1 || operations.length > 8)
1705
+ throw new Error("Provide between 1 and 8 inspection operations");
1706
+ let sourceFile = args.sourceFile ? String(args.sourceFile).toUpperCase() : "";
1707
+ let resolution;
1708
+ if (!sourceFile) {
1709
+ const candidates = await conn.content.resolveMembers({
1710
+ library: args.library,
1711
+ member: args.member,
1712
+ candidateSourceFiles: args.candidateSourceFiles,
1713
+ sourceTypes: args.sourceTypes,
1714
+ limit: 20
1715
+ });
1716
+ rankMemberCandidates(candidates, args.candidateSourceFiles);
1717
+ resolution = {
1718
+ status: candidates.length === 0 ? "not-found" : candidates.length === 1 ? "found" : "ambiguous",
1719
+ candidates
1720
+ };
1721
+ if (candidates.length !== 1)
1722
+ return json(resolution);
1723
+ sourceFile = candidates[0].file;
1724
+ }
1725
+ const snapshot = await conn.content.getMemberSnapshot(args.library, sourceFile, args.member, Boolean(args.forceFresh));
1726
+ const conflict = memberVersionConflict(snapshot, args);
1727
+ if (conflict)
1728
+ return conflict;
1729
+ return json({
1730
+ status: "found",
1731
+ identity: snapshot.identity,
1732
+ version: snapshot.version,
1733
+ sha256: snapshot.sha256,
1734
+ observedAt: snapshot.observedAt,
1735
+ complete: snapshot.complete,
1736
+ transport: snapshot.transport,
1737
+ cacheStatus: snapshot.cacheStatus,
1738
+ warnings: snapshot.warnings,
1739
+ ...(resolution ? { resolution } : {}),
1740
+ results: operations.map((operation) => inspectMemberOperation(snapshot, operation, sourceReadLimits(conn)))
1741
+ });
1742
+ }
1743
+ case "ibmi.docs.search": {
1744
+ let releases = args.releases;
1745
+ if ((!releases || releases.length === 0) && args.connectionName) {
1746
+ const conn = await ctx.ensureActive(args.connectionName);
1747
+ const detected = await readSystemFacts(conn);
1748
+ if (detected.release)
1749
+ releases = [detected.release];
1750
+ }
1751
+ return json(await ibmDocs.search({ ...args, releases }));
1752
+ }
1753
+ case "ibmi.docs.open": {
1754
+ if (!args.ref && !args.url)
1755
+ throw new Error("ref or url is required");
1756
+ return json(await ibmDocs.open(args));
1757
+ }
1404
1758
  case "ibmi.search.ifs": {
1405
1759
  const conn = await ctx.ensureActive(args?.connectionName);
1406
1760
  const data = await Search.searchIFS(conn, args.path, args.term);
@@ -1423,49 +1777,7 @@ export async function handleTool(ctx, name, args) {
1423
1777
  return json(actions);
1424
1778
  }
1425
1779
  case "ibmi.actions.run": {
1426
- const conn = await ctx.ensureActive(args?.connectionName);
1427
- enforceWritable(ctx, conn, "qsys.write", args);
1428
- const action = await resolveAction(ctx, args);
1429
- if (!action)
1430
- throw new Error("Action not found");
1431
- const envVars = {};
1432
- const targetPath = args.targetPath;
1433
- const targetType = args.targetType;
1434
- if (targetType === "file") {
1435
- throw new Error("Local file actions are not supported in this MCP server (use streamfile or member).");
1436
- }
1437
- if (targetType === "streamfile") {
1438
- envVars["&FULLPATH"] = targetPath;
1439
- const base = targetPath.split("/").pop() || targetPath;
1440
- envVars["&BASENAME"] = base;
1441
- envVars["&NAME"] = base.includes(".") ? base.substring(0, base.lastIndexOf(".")) : base;
1442
- envVars["&EXT"] = base.includes(".") ? base.substring(base.lastIndexOf(".") + 1) : "";
1443
- envVars["&RELATIVEPATH"] = targetPath;
1444
- }
1445
- if (targetType === "member") {
1446
- const lib = requireQsysName(conn, args.library, "library");
1447
- const src = requireQsysName(conn, args.sourceFile, "sourceFile");
1448
- const mbr = requireQsysName(conn, args.member || targetPath.split("/").pop(), "member");
1449
- const ext = String(args.extension || "");
1450
- envVars["&LIBRARY"] = lib;
1451
- envVars["&SRCFILE"] = src;
1452
- envVars["&NAME"] = mbr;
1453
- envVars["&EXT"] = ext;
1454
- envVars["&EXTL"] = ext.toLowerCase();
1455
- envVars["&FULLPATH"] = `${lib}/${src}/${mbr}`;
1456
- envVars["&OPENLIB"] = lib;
1457
- envVars["&OPENLIBL"] = lib.toLowerCase();
1458
- envVars["&OPENSPF"] = src;
1459
- envVars["&OPENSPFL"] = src.toLowerCase();
1460
- envVars["&OPENMBR"] = mbr;
1461
- envVars["&OPENMBRL"] = mbr.toLowerCase();
1462
- }
1463
- const resultCmd = await CompileTools.runCommand(conn, {
1464
- command: action.command,
1465
- environment: action.environment,
1466
- env: envVars
1467
- });
1468
- return json(mapActionExecution(action, targetType, targetPath, resultCmd));
1780
+ return runActionTool(ctx, args);
1469
1781
  }
1470
1782
  case "ibmi.actions.save": {
1471
1783
  const action = args.action;
@@ -1499,15 +1811,13 @@ export async function handleTool(ctx, name, args) {
1499
1811
  case "ibmi.libl.set": {
1500
1812
  const name = getConnectionName(ctx, args);
1501
1813
  const active = await ctx.ensureActive(name);
1502
- enforceWritable(ctx, active, "qsys.write", args);
1814
+ await enforceWritable(ctx, active, "qsys.write", args);
1503
1815
  await updateConnectionSettings(ctx, name, {
1504
1816
  currentLibrary: args.currentLibrary,
1505
1817
  libraryList: args.libraryList
1506
1818
  });
1507
- if (args.applyToJob) {
1508
- await applyLibraryList(active, args.libraryList, args.currentLibrary);
1509
- }
1510
- return result("OK");
1819
+ const applied = args.applyToJob ? await applyLibraryList(active, args.libraryList, args.currentLibrary) : undefined;
1820
+ return okApplied(applied);
1511
1821
  }
1512
1822
  case "ibmi.libl.add": {
1513
1823
  const name = getConnectionName(ctx, args);
@@ -1519,12 +1829,10 @@ export async function handleTool(ctx, name, args) {
1519
1829
  if (!list.includes(lib))
1520
1830
  list.push(lib);
1521
1831
  const active = await ctx.ensureActive(name);
1522
- enforceWritable(ctx, active, "qsys.write", args);
1832
+ await enforceWritable(ctx, active, "qsys.write", args);
1523
1833
  await updateConnectionSettings(ctx, name, { libraryList: list });
1524
- if (args.applyToJob) {
1525
- await applyLibraryList(active, list, conn.settings?.currentLibrary);
1526
- }
1527
- return result("OK");
1834
+ const applied = args.applyToJob ? await applyLibraryList(active, list, conn.settings?.currentLibrary) : undefined;
1835
+ return okApplied(applied);
1528
1836
  }
1529
1837
  case "ibmi.libl.remove": {
1530
1838
  const name = getConnectionName(ctx, args);
@@ -1533,23 +1841,19 @@ export async function handleTool(ctx, name, args) {
1533
1841
  throw new Error(`Connection ${name} not found`);
1534
1842
  const list = (conn.settings?.libraryList || []).filter(l => l !== String(args.library).toUpperCase());
1535
1843
  const active = await ctx.ensureActive(name);
1536
- enforceWritable(ctx, active, "qsys.write", args);
1844
+ await enforceWritable(ctx, active, "qsys.write", args);
1537
1845
  await updateConnectionSettings(ctx, name, { libraryList: list });
1538
- if (args.applyToJob) {
1539
- await applyLibraryList(active, list, conn.settings?.currentLibrary);
1540
- }
1541
- return result("OK");
1846
+ const applied = args.applyToJob ? await applyLibraryList(active, list, conn.settings?.currentLibrary) : undefined;
1847
+ return okApplied(applied);
1542
1848
  }
1543
1849
  case "ibmi.libl.setCurrent": {
1544
1850
  const name = getConnectionName(ctx, args);
1545
1851
  const lib = String(args.library).toUpperCase();
1546
1852
  const active = await ctx.ensureActive(name);
1547
- enforceWritable(ctx, active, "qsys.write", args);
1853
+ await enforceWritable(ctx, active, "qsys.write", args);
1548
1854
  await updateConnectionSettings(ctx, name, { currentLibrary: lib });
1549
- if (args.applyToJob) {
1550
- await applyLibraryList(active, undefined, lib);
1551
- }
1552
- return result("OK");
1855
+ const applied = args.applyToJob ? await applyLibraryList(active, undefined, lib) : undefined;
1856
+ return okApplied(applied);
1553
1857
  }
1554
1858
  case "ibmi.libl.validate": {
1555
1859
  const conn = await ctx.ensureActive(args?.connectionName);
@@ -1583,17 +1887,15 @@ export async function handleTool(ctx, name, args) {
1583
1887
  if (!profile)
1584
1888
  throw new Error(`Profile ${args.name} not found`);
1585
1889
  const active = await ctx.ensureActive(name);
1586
- enforceWritable(ctx, active, "qsys.write", args);
1890
+ await enforceWritable(ctx, active, "qsys.write", args);
1587
1891
  await ctx.store.setCurrentProfile(name, args.name);
1588
1892
  await updateConnectionSettings(ctx, name, {
1589
1893
  currentLibrary: profile.currentLibrary,
1590
1894
  libraryList: profile.libraryList,
1591
1895
  customVariables: profile.customVariables
1592
1896
  });
1593
- if (args.applyToJob) {
1594
- await applyLibraryList(active, profile.libraryList, profile.currentLibrary);
1595
- }
1596
- return result(`Profile ${args.name} activated`);
1897
+ const applied = args.applyToJob ? await applyLibraryList(active, profile.libraryList, profile.currentLibrary) : undefined;
1898
+ return okApplied(applied, `Profile ${args.name} activated`);
1597
1899
  }
1598
1900
  case "ibmi.resolve.path": {
1599
1901
  const conn = await ctx.ensureActive(args?.connectionName);
@@ -1615,15 +1917,20 @@ export async function handleTool(ctx, name, args) {
1615
1917
  }
1616
1918
  case "ibmi.deploy.uploadDirectory": {
1617
1919
  const conn = await ctx.ensureActive(args?.connectionName);
1618
- enforceWritable(ctx, conn, "deploy.sync", args);
1619
- await conn.client.putDirectory(args.localPath, args.remotePath, { recursive: true, concurrency: 5 });
1920
+ await enforceWritable(ctx, conn, "deploy.sync", args);
1921
+ const localPath = await guardLocalPath(ctx, args.localPath, "read");
1922
+ await conn.client.putDirectory(localPath, args.remotePath, { recursive: true, concurrency: 5 });
1620
1923
  return result("OK");
1621
1924
  }
1622
1925
  case "ibmi.deploy.uploadFiles": {
1623
1926
  const conn = await ctx.ensureActive(args?.connectionName);
1624
- enforceWritable(ctx, conn, "deploy.sync", args);
1927
+ await enforceWritable(ctx, conn, "deploy.sync", args);
1625
1928
  const remoteDir = String(args.remoteDirectory);
1929
+ const localFiles = [];
1626
1930
  for (const file of args.localFiles || []) {
1931
+ localFiles.push(await guardLocalPath(ctx, file, "read"));
1932
+ }
1933
+ for (const file of localFiles) {
1627
1934
  const base = String(file).split(/[\\/]/).pop();
1628
1935
  const remotePath = `${remoteDir}/${base}`;
1629
1936
  await conn.client.putFile(String(file), remotePath);
@@ -1632,12 +1939,12 @@ export async function handleTool(ctx, name, args) {
1632
1939
  }
1633
1940
  case "ibmi.deploy.setCcsid": {
1634
1941
  const conn = await ctx.ensureActive(args?.connectionName);
1635
- enforceWritable(ctx, conn, "deploy.sync", args);
1942
+ await enforceWritable(ctx, conn, "deploy.sync", args);
1636
1943
  const setccsid = conn.remoteFeatures.setccsid;
1637
1944
  if (!setccsid)
1638
1945
  throw new Error("setccsid not available on remote system");
1639
1946
  const ccsid = args.ccsid || "1208";
1640
- await conn.sendCommand({ command: `${setccsid} -R ${Tools.shellQuote(ccsid)} ${Tools.escapePath(args.remotePath)}` });
1947
+ requireCommandSuccess(await conn.sendCommand({ command: `${setccsid} -R ${Tools.shellQuote(ccsid)} ${Tools.escapePath(args.remotePath)}` }), "setccsid");
1641
1948
  return result("OK");
1642
1949
  }
1643
1950
  case "ibmi.filters.list": {
@@ -1710,46 +2017,90 @@ export async function handleTool(ctx, name, args) {
1710
2017
  }
1711
2018
  case "ibmi.debug.startService": {
1712
2019
  const conn = await ctx.ensureActive(args?.connectionName);
1713
- enforceWritable(ctx, conn, "qsys.write", args);
2020
+ await enforceWritable(ctx, conn, "qsys.write", args);
1714
2021
  const submitOptions = `JOBQ(QSYS/QUSRNOMAX) JOBD(QSYS/QSYSJOBD) OUTQ(QUSRSYS/QDBGSRV) USER(QDBGSRV)`;
1715
2022
  const cmd = `QSYS/SBMJOB JOB(QDBGSRV) SYSLIBL(*SYSVAL) CURLIB(*USRPRF) INLLIBL(*JOBD) ${submitOptions} CMD(QSH CMD('/QIBM/ProdData/IBMiDebugService/bin/startDebugService.sh'))`;
1716
2023
  const res = await conn.sendQsh({ command: `system \"${cmd}\"` });
1717
- return json(res);
2024
+ return commandOutcome({ ok: res.code === 0, ...res });
1718
2025
  }
1719
2026
  case "ibmi.debug.stopService": {
1720
2027
  const conn = await ctx.ensureActive(args?.connectionName);
1721
- enforceWritable(ctx, conn, "qsys.write", args);
2028
+ await enforceWritable(ctx, conn, "qsys.write", args);
1722
2029
  const res = await conn.sendCommand({ command: `/QIBM/ProdData/IBMiDebugService/bin/stopDebugService.sh` });
1723
- return json(res);
2030
+ return commandOutcome({ ok: res.code === 0, ...res });
1724
2031
  }
1725
2032
  case "ibmi.deploy.compare": {
1726
2033
  const conn = await ctx.ensureActive(args?.connectionName);
1727
- const localPath = String(args.localPath);
2034
+ const localPath = await guardLocalPath(ctx, args.localPath, "read");
1728
2035
  const remotePath = String(args.remotePath);
2036
+ const method = normalizeDeployMethod(args.method);
2037
+ if (method !== "all" && method !== "changed")
2038
+ throw new Error(`deploy.compare supports method "all" or "changed", not "${method}"`);
1729
2039
  const comparison = await compareDeployTrees(conn, localPath, remotePath);
1730
- return json(comparison);
2040
+ return json(method === "changed" ? await refineComparisonByHash(conn, comparison) : comparison);
1731
2041
  }
1732
2042
  case "ibmi.deploy.sync": {
1733
2043
  const conn = await ctx.ensureActive(args?.connectionName);
1734
- enforceWritable(ctx, conn, "deploy.sync", args);
1735
- const localPath = String(args.localPath);
2044
+ await enforceWritable(ctx, conn, "deploy.sync", args);
2045
+ const localPath = await guardLocalPath(ctx, args.localPath, "read");
1736
2046
  const remotePath = String(args.remotePath);
2047
+ const method = normalizeDeployMethod(args.method);
1737
2048
  const overwrite = Boolean(args.overwrite);
1738
2049
  const dryRun = Boolean(args.dryRun);
1739
2050
  const deleteExtraRemote = Boolean(args.deleteExtraRemote);
1740
- const comparison = await compareDeployTrees(conn, localPath, remotePath);
1741
- const uploadSet = new Set(comparison.onlyLocal);
1742
- if (overwrite) {
1743
- comparison.changed.forEach(entry => uploadSet.add(entry.path));
1744
- comparison.unresolved.forEach(entry => uploadSet.add(entry.path));
2051
+ if (deleteExtraRemote && (method === "gitChanged" || method === "selection")) {
2052
+ throw new Error(`deleteExtraRemote is only supported with method "all" or "changed"`);
2053
+ }
2054
+ const errors = [];
2055
+ let comparison;
2056
+ let uploadSet;
2057
+ let skipChanged = [];
2058
+ let unresolved = [];
2059
+ let unchanged = [];
2060
+ let gitDeleted = [];
2061
+ if (method === "all" || method === "changed") {
2062
+ comparison = await compareDeployTrees(conn, localPath, remotePath);
2063
+ if (method === "changed")
2064
+ comparison = await refineComparisonByHash(conn, comparison);
2065
+ uploadSet = new Set(comparison.onlyLocal);
2066
+ // "changed" compared content, so every differing file is uploaded.
2067
+ const includeChanged = overwrite || method === "changed";
2068
+ if (includeChanged) {
2069
+ comparison.changed.forEach(entry => uploadSet.add(entry.path));
2070
+ comparison.unresolved.forEach(entry => uploadSet.add(entry.path));
2071
+ }
2072
+ skipChanged = includeChanged ? [] : comparison.changed.map(entry => entry.path).sort((a, b) => a.localeCompare(b));
2073
+ unresolved = includeChanged ? [] : comparison.unresolved.map(entry => entry.path).sort((a, b) => a.localeCompare(b));
2074
+ unchanged = [...comparison.identical];
2075
+ }
2076
+ else if (method === "gitChanged") {
2077
+ const git = await gitChangedFiles(localPath);
2078
+ uploadSet = new Set(git.files);
2079
+ gitDeleted = git.deleted;
2080
+ }
2081
+ else {
2082
+ uploadSet = new Set();
2083
+ for (const rel of normalizeDeploySelection(args.files)) {
2084
+ try {
2085
+ const resolved = await guardLocalPath(ctx, path.join(localPath, rel), "read");
2086
+ const relative = path.relative(localPath, resolved);
2087
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
2088
+ throw new Error("file is outside localPath");
2089
+ }
2090
+ const stat = await fs.stat(resolved).catch(() => undefined);
2091
+ if (!stat?.isFile())
2092
+ throw new Error("local file not found");
2093
+ uploadSet.add(rel);
2094
+ }
2095
+ catch (err) {
2096
+ errors.push({ operation: "upload", path: rel, message: err?.message || String(err) });
2097
+ }
2098
+ }
1745
2099
  }
1746
2100
  const uploadPlan = Array.from(uploadSet).sort((a, b) => a.localeCompare(b));
1747
- const skipChanged = overwrite ? [] : comparison.changed.map(entry => entry.path).sort((a, b) => a.localeCompare(b));
1748
- const unresolved = overwrite ? [] : comparison.unresolved.map(entry => entry.path).sort((a, b) => a.localeCompare(b));
1749
- const deletePlan = deleteExtraRemote ? [...comparison.onlyRemote] : [];
2101
+ const deletePlan = deleteExtraRemote && comparison ? [...comparison.onlyRemote] : [];
1750
2102
  const uploaded = [];
1751
2103
  const deleted = [];
1752
- const errors = [];
1753
2104
  if (!dryRun) {
1754
2105
  const createdDirs = new Set();
1755
2106
  for (const rel of uploadPlan) {
@@ -1758,7 +2109,7 @@ export async function handleTool(ctx, name, args) {
1758
2109
  const remoteDir = remoteFile.split("/").slice(0, -1).join("/");
1759
2110
  try {
1760
2111
  if (!createdDirs.has(remoteDir)) {
1761
- await conn.sendCommand({ command: `mkdir -p ${Tools.escapePath(remoteDir)}` });
2112
+ requireCommandSuccess(await conn.sendCommand({ command: `mkdir -p ${Tools.escapePath(remoteDir)}` }), "mkdir");
1762
2113
  createdDirs.add(remoteDir);
1763
2114
  }
1764
2115
  await conn.client.putFile(localFile, remoteFile);
@@ -1771,7 +2122,7 @@ export async function handleTool(ctx, name, args) {
1771
2122
  for (const rel of deletePlan) {
1772
2123
  const remoteFile = joinRemotePath(remotePath, rel);
1773
2124
  try {
1774
- await conn.sendCommand({ command: `rm -f ${Tools.escapePath(remoteFile)}` });
2125
+ requireCommandSuccess(await conn.sendCommand({ command: `rm -f ${Tools.escapePath(remoteFile)}` }), "rm");
1775
2126
  deleted.push(rel);
1776
2127
  }
1777
2128
  catch (err) {
@@ -1779,22 +2130,35 @@ export async function handleTool(ctx, name, args) {
1779
2130
  }
1780
2131
  }
1781
2132
  }
1782
- return json({
2133
+ const syncResult = {
1783
2134
  ok: errors.length === 0,
1784
- mode: { overwrite, deleteExtraRemote, dryRun },
1785
- comparison,
2135
+ mode: { method, overwrite, deleteExtraRemote, dryRun },
2136
+ comparison: comparison ?? null,
1786
2137
  planned: {
1787
2138
  upload: uploadPlan,
1788
2139
  delete: deletePlan,
1789
2140
  skippedChanged: skipChanged,
1790
- unresolved
2141
+ unresolved,
2142
+ ...(method === "gitChanged" ? { gitDeleted } : {})
1791
2143
  },
1792
2144
  applied: {
1793
2145
  uploaded: dryRun ? [] : uploaded,
1794
2146
  deleted: dryRun ? [] : deleted
1795
2147
  },
2148
+ summary: {
2149
+ planned: uploadPlan.length,
2150
+ uploaded: dryRun ? 0 : uploaded.length,
2151
+ unchanged: unchanged.length,
2152
+ skipped: skipChanged.length + unresolved.length + gitDeleted.length,
2153
+ deleted: dryRun ? 0 : deleted.length,
2154
+ failed: errors.length
2155
+ },
1796
2156
  errors
1797
- });
2157
+ };
2158
+ if (errors.length > 0) {
2159
+ return jsonError(`deploy.sync finished with ${errors.length} failed operation(s)`, syncResult);
2160
+ }
2161
+ return json(syncResult);
1798
2162
  }
1799
2163
  case "ibmi.sql.query": {
1800
2164
  const conn = await ctx.ensureActive(args?.connectionName);
@@ -1815,10 +2179,14 @@ export async function handleTool(ctx, name, args) {
1815
2179
  if (!args?.sql)
1816
2180
  throw new Error("sql is required when cursor is not provided");
1817
2181
  const sql = String(args.sql);
1818
- if (!isReadOnlySql(sql))
1819
- throw new Error("ibmi.sql.query only supports read-only SQL");
2182
+ const readOnlyCheck = checkReadOnlySql(sql);
2183
+ if (!readOnlyCheck.readOnly)
2184
+ throw new Error(`ibmi.sql.query only supports read-only SQL: ${readOnlyCheck.reason}`);
1820
2185
  const startedAt = Date.now();
1821
- const rows = await withTimeout(conn.runSQL(sql), timeoutMs, `SQL query timed out after ${timeoutMs}ms`);
2186
+ // Ask for one row more than maxRows so truncation can be reported without
2187
+ // pulling the whole result set over the wire.
2188
+ const capped = applySqlRowCap(sql, maxRows + 1);
2189
+ const rows = await runSqlWithTimeout(conn, capped.sql, timeoutMs, `SQL query timed out after ${timeoutMs}ms`);
1822
2190
  const limited = rows.slice(0, maxRows);
1823
2191
  const firstPage = limited.slice(0, pageSize);
1824
2192
  const cursorInfo = ctx.createSqlCursor(limited, pageSize);
@@ -1828,6 +2196,9 @@ export async function handleTool(ctx, name, args) {
1828
2196
  cursor: cursorInfo.cursor,
1829
2197
  totalRows: limited.length,
1830
2198
  truncated: rows.length > limited.length,
2199
+ ...(rows.length > limited.length
2200
+ ? { truncationNote: `Result has more than ${maxRows} rows; only the first ${maxRows} are returned. Raise maxRows (up to 20000) or narrow the query.` }
2201
+ : {}),
1831
2202
  ...(includeMetadata
1832
2203
  ? {
1833
2204
  metadata: {
@@ -1835,6 +2206,7 @@ export async function handleTool(ctx, name, args) {
1835
2206
  columns: getRowColumns(firstPage),
1836
2207
  pageSize,
1837
2208
  maxRows,
2209
+ rowCapAppliedInSql: capped.applied,
1838
2210
  timeoutMs,
1839
2211
  executionMs
1840
2212
  }
@@ -1844,13 +2216,13 @@ export async function handleTool(ctx, name, args) {
1844
2216
  }
1845
2217
  case "ibmi.sql.execute": {
1846
2218
  const conn = await ctx.ensureActive(args?.connectionName);
1847
- enforcePolicyOperation(ctx, "sql.write", args);
2219
+ await enforceWritable(ctx, conn, "sql.write", args);
1848
2220
  const sql = String(args.sql);
1849
2221
  const timeoutMs = clampNumber(args?.timeoutMs, 45000, 1000, 300000);
1850
2222
  const statementType = getSqlStatementType(sql);
1851
2223
  const startedAt = Date.now();
1852
2224
  try {
1853
- const rows = await withTimeout(conn.runSQL(sql), timeoutMs, `SQL execute timed out after ${timeoutMs}ms`);
2225
+ const rows = await runSqlWithTimeout(conn, sql, timeoutMs, `SQL execute timed out after ${timeoutMs}ms`);
1854
2226
  const executionMs = Date.now() - startedAt;
1855
2227
  const affectedRows = inferAffectedRows(rows);
1856
2228
  log("info", "audit.sql.execute", {
@@ -1883,7 +2255,7 @@ export async function handleTool(ctx, name, args) {
1883
2255
  }
1884
2256
  case "ibmi.cl.run": {
1885
2257
  const conn = await ctx.ensureActive(args?.connectionName);
1886
- enforcePolicyOperation(ctx, "cl.run", args);
2258
+ await enforceWritable(ctx, conn, "cl.run", args);
1887
2259
  const command = String(args.command || "");
1888
2260
  if (!command.trim())
1889
2261
  throw new Error("command is required");
@@ -1905,13 +2277,24 @@ export async function handleTool(ctx, name, args) {
1905
2277
  case "ibmi.joblog.get": {
1906
2278
  const conn = await ctx.ensureActive(args?.connectionName);
1907
2279
  const limit = clampNumber(args?.limit, 200, 1, 5000);
1908
- 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`);
2280
+ // MCH3401 "Cannot resolve to object PPTT" is logged by the database
2281
+ // machinery on every statement on some systems; it would crowd out the
2282
+ // messages from the user's own SQL and CL.
2283
+ const rows = await conn.runSQL(`select message_id, message_text, severity, message_timestamp from table(qsys2.joblog_info('*')) x ` +
2284
+ `where not (coalesce(message_id, '') = 'MCH3401' and coalesce(message_text, '') like '%PPTT%') ` +
2285
+ `order by message_timestamp desc fetch first ${limit} rows only`);
1909
2286
  return json(rows.map(normalizeJoblogRow));
1910
2287
  }
1911
2288
  case "ibmi.spool.list": {
1912
2289
  const conn = await ctx.ensureActive(args?.connectionName);
1913
2290
  const limit = clampNumber(args?.limit, 200, 1, 5000);
1914
- const rows = await querySpoolEntries(conn, limit);
2291
+ const rows = await querySpoolEntries(conn, {
2292
+ limit,
2293
+ userName: args?.userName ? requireIbmObjectName(args.userName, "userName") : undefined,
2294
+ outputQueue: args?.outputQueue ? requireIbmObjectName(args.outputQueue, "outputQueue") : undefined,
2295
+ outputQueueLibrary: args?.outputQueueLibrary ? requireIbmObjectName(args.outputQueueLibrary, "outputQueueLibrary") : undefined,
2296
+ jobName: args?.jobName ? requireSpoolJobFilter(args.jobName) : undefined
2297
+ });
1915
2298
  return json(rows.map(normalizeSpoolEntry));
1916
2299
  }
1917
2300
  case "ibmi.spool.read": {
@@ -1920,39 +2303,41 @@ export async function handleTool(ctx, name, args) {
1920
2303
  const jobName = Tools.sqlString(String(args.jobName));
1921
2304
  const fileName = Tools.sqlString(String(args.spooledFileName));
1922
2305
  const fileNbr = clampNumber(args.spooledFileNumber, 1, 1, 999999999);
1923
- 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`);
2306
+ // DISPLAY_SPOOLED_FILE_DATA returns ORDINAL_POSITION and SPOOLED_DATA.
2307
+ const rows = await conn.runSQL(`select ordinal_position, spooled_data from table(qsys2.display_spooled_file_data(${jobName}, ${fileName}, ${fileNbr})) ` +
2308
+ `order by ordinal_position fetch first ${limit} rows only`);
1924
2309
  return json(rows.map(normalizeSpoolLine));
1925
2310
  }
1926
2311
  case "ibmi.spool.hold": {
1927
2312
  const conn = await ctx.ensureActive(args?.connectionName);
1928
- enforceWritable(ctx, conn, "qsys.write", args);
1929
- const command = buildSpoolCommand("HLDSPFL", args);
2313
+ await enforceWritable(ctx, conn, "qsys.write", args);
2314
+ const command = buildSpoolCommand("HLDSPLF", args);
1930
2315
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
1931
- return json({ ok: cmdResult.code === 0, command, ...cmdResult });
2316
+ return commandOutcome({ ok: cmdResult.code === 0, command, ...cmdResult });
1932
2317
  }
1933
2318
  case "ibmi.spool.release": {
1934
2319
  const conn = await ctx.ensureActive(args?.connectionName);
1935
- enforceWritable(ctx, conn, "qsys.write", args);
2320
+ await enforceWritable(ctx, conn, "qsys.write", args);
1936
2321
  const command = buildSpoolCommand("RLSSPLF", args);
1937
2322
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
1938
- return json({ ok: cmdResult.code === 0, command, ...cmdResult });
2323
+ return commandOutcome({ ok: cmdResult.code === 0, command, ...cmdResult });
1939
2324
  }
1940
2325
  case "ibmi.spool.delete": {
1941
2326
  const conn = await ctx.ensureActive(args?.connectionName);
1942
- enforceWritable(ctx, conn, "qsys.write", args);
2327
+ await enforceWritable(ctx, conn, "qsys.write", args);
1943
2328
  const command = buildSpoolCommand("DLTSPLF", args);
1944
2329
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
1945
- return json({ ok: cmdResult.code === 0, command, ...cmdResult });
2330
+ return commandOutcome({ ok: cmdResult.code === 0, command, ...cmdResult });
1946
2331
  }
1947
2332
  case "ibmi.spool.move": {
1948
2333
  const conn = await ctx.ensureActive(args?.connectionName);
1949
- enforceWritable(ctx, conn, "qsys.write", args);
2334
+ await enforceWritable(ctx, conn, "qsys.write", args);
1950
2335
  const baseCommand = buildSpoolCommand("CHGSPLFA", args);
1951
2336
  const outQueueLibrary = requireIbmObjectName(args.outQueueLibrary, "outQueueLibrary");
1952
2337
  const outQueue = requireIbmObjectName(args.outQueue, "outQueue");
1953
2338
  const command = `${baseCommand} OUTQ(${outQueueLibrary}/${outQueue})`;
1954
2339
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
1955
- return json({ ok: cmdResult.code === 0, command, ...cmdResult });
2340
+ return commandOutcome({ ok: cmdResult.code === 0, command, ...cmdResult });
1956
2341
  }
1957
2342
  case "ibmi.jobs.list": {
1958
2343
  const conn = await ctx.ensureActive(args?.connectionName);
@@ -1967,24 +2352,24 @@ export async function handleTool(ctx, name, args) {
1967
2352
  }
1968
2353
  case "ibmi.jobs.hold": {
1969
2354
  const conn = await ctx.ensureActive(args?.connectionName);
1970
- enforceWritable(ctx, conn, "qsys.write", args);
2355
+ await enforceWritable(ctx, conn, "qsys.write", args);
1971
2356
  const jobName = requireJobName(args.jobName);
1972
2357
  const hold = Boolean(args?.holdOnJobQueue) ? "*JOBQ" : "*NO";
1973
2358
  const command = `HLDJOB JOB(${jobName}) HOLD(${hold})`;
1974
2359
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
1975
- return json({ ok: cmdResult.code === 0, command, ...cmdResult });
2360
+ return commandOutcome({ ok: cmdResult.code === 0, command, ...cmdResult });
1976
2361
  }
1977
2362
  case "ibmi.jobs.release": {
1978
2363
  const conn = await ctx.ensureActive(args?.connectionName);
1979
- enforceWritable(ctx, conn, "qsys.write", args);
2364
+ await enforceWritable(ctx, conn, "qsys.write", args);
1980
2365
  const jobName = requireJobName(args.jobName);
1981
2366
  const command = `RLSJOB JOB(${jobName})`;
1982
2367
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
1983
- return json({ ok: cmdResult.code === 0, command, ...cmdResult });
2368
+ return commandOutcome({ ok: cmdResult.code === 0, command, ...cmdResult });
1984
2369
  }
1985
2370
  case "ibmi.jobs.end": {
1986
2371
  const conn = await ctx.ensureActive(args?.connectionName);
1987
- enforceWritable(ctx, conn, "qsys.write", args);
2372
+ await enforceWritable(ctx, conn, "qsys.write", args);
1988
2373
  const jobName = requireJobName(args.jobName);
1989
2374
  const option = String(args?.option || "*CNTRLD").toUpperCase();
1990
2375
  if (option !== "*CNTRLD" && option !== "*IMMED") {
@@ -1993,7 +2378,7 @@ export async function handleTool(ctx, name, args) {
1993
2378
  const delaySeconds = clampNumber(args?.delaySeconds, 30, 0, 99999);
1994
2379
  const command = `ENDJOB JOB(${jobName}) OPTION(${option}) DELAY(${delaySeconds})`;
1995
2380
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
1996
- return json({ ok: cmdResult.code === 0, command, ...cmdResult });
2381
+ return commandOutcome({ ok: cmdResult.code === 0, command, ...cmdResult });
1997
2382
  }
1998
2383
  case "ibmi.subsystems.list": {
1999
2384
  const conn = await ctx.ensureActive(args?.connectionName);
@@ -2014,15 +2399,15 @@ export async function handleTool(ctx, name, args) {
2014
2399
  }
2015
2400
  case "ibmi.subsystems.start": {
2016
2401
  const conn = await ctx.ensureActive(args?.connectionName);
2017
- enforceWritable(ctx, conn, "qsys.write", args);
2402
+ await enforceWritable(ctx, conn, "qsys.write", args);
2018
2403
  const subsystemDescription = requireQualifiedObject(args.subsystemDescription, "subsystemDescription");
2019
2404
  const command = `STRSBS SBSD(${subsystemDescription.library}/${subsystemDescription.object})`;
2020
2405
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2021
- return json({ ok: cmdResult.code === 0, command, ...cmdResult });
2406
+ return commandOutcome({ ok: cmdResult.code === 0, command, ...cmdResult });
2022
2407
  }
2023
2408
  case "ibmi.subsystems.end": {
2024
2409
  const conn = await ctx.ensureActive(args?.connectionName);
2025
- enforceWritable(ctx, conn, "qsys.write", args);
2410
+ await enforceWritable(ctx, conn, "qsys.write", args);
2026
2411
  const subsystem = requireIbmObjectName(args.subsystem, "subsystem");
2027
2412
  const option = String(args?.option || "*CNTRLD").toUpperCase();
2028
2413
  if (option !== "*CNTRLD" && option !== "*IMMED") {
@@ -2031,7 +2416,7 @@ export async function handleTool(ctx, name, args) {
2031
2416
  const delaySeconds = clampNumber(args?.delaySeconds, 30, 0, 99999);
2032
2417
  const command = `ENDSBS SBS(${subsystem}) OPTION(${option}) DELAY(${delaySeconds})`;
2033
2418
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2034
- return json({ ok: cmdResult.code === 0, command, ...cmdResult });
2419
+ return commandOutcome({ ok: cmdResult.code === 0, command, ...cmdResult });
2035
2420
  }
2036
2421
  case "ibmi.msgq.read": {
2037
2422
  const conn = await ctx.ensureActive(args?.connectionName);
@@ -2043,7 +2428,7 @@ export async function handleTool(ctx, name, args) {
2043
2428
  }
2044
2429
  case "ibmi.msgq.send": {
2045
2430
  const conn = await ctx.ensureActive(args?.connectionName);
2046
- enforceWritable(ctx, conn, "qsys.write", args);
2431
+ await enforceWritable(ctx, conn, "qsys.write", args);
2047
2432
  const library = requireIbmObjectName(args?.library || "QSYS", "library");
2048
2433
  const messageQueue = requireIbmObjectName(args?.messageQueue || "QSYSOPR", "messageQueue");
2049
2434
  const messageType = String(args?.messageType || "*INFO").toUpperCase();
@@ -2053,16 +2438,19 @@ export async function handleTool(ctx, name, args) {
2053
2438
  const message = String(args.message || "");
2054
2439
  if (!message.trim())
2055
2440
  throw new Error("message is required");
2056
- const mappedMessageType = messageType === "*INQ" ? "*INQ" : "*INFO";
2057
- const command = mappedMessageType === "*INQ"
2058
- ? `SNDMSG MSG(${quoteClString(message)}) TOMSGQ(${library}/${messageQueue}) MSGTYPE(*INQ) RPYMSGQ(${library}/${messageQueue})`
2059
- : `SNDMSG MSG(${quoteClString(message)}) TOMSGQ(${library}/${messageQueue}) MSGTYPE(*INFO)`;
2441
+ const command = buildSendMessageCommand(message, library, messageQueue, messageType);
2060
2442
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2061
- return json({ ok: cmdResult.code === 0, command, requestedMessageType: messageType, mappedMessageType, ...cmdResult });
2443
+ return commandOutcome({
2444
+ ok: cmdResult.code === 0,
2445
+ command,
2446
+ requestedMessageType: messageType,
2447
+ mappedMessageType: messageType,
2448
+ ...cmdResult
2449
+ });
2062
2450
  }
2063
2451
  case "ibmi.msgq.reply": {
2064
2452
  const conn = await ctx.ensureActive(args?.connectionName);
2065
- enforceWritable(ctx, conn, "qsys.write", args);
2453
+ await enforceWritable(ctx, conn, "qsys.write", args);
2066
2454
  const library = requireIbmObjectName(args?.library || "QSYS", "library");
2067
2455
  const messageQueue = requireIbmObjectName(args?.messageQueue || "QSYSOPR", "messageQueue");
2068
2456
  const messageKey = requireMessageKey(args.messageKey);
@@ -2071,16 +2459,16 @@ export async function handleTool(ctx, name, args) {
2071
2459
  throw new Error("reply is required");
2072
2460
  const command = `SNDRPY MSGKEY(${messageKey}) MSGQ(${library}/${messageQueue}) RPY(${quoteClString(reply)})`;
2073
2461
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2074
- return json({ ok: cmdResult.code === 0, command, ...cmdResult });
2462
+ return commandOutcome({ ok: cmdResult.code === 0, command, ...cmdResult });
2075
2463
  }
2076
2464
  case "ibmi.locks.list": {
2077
2465
  const conn = await ctx.ensureActive(args?.connectionName);
2078
2466
  const limit = clampNumber(args?.limit, 200, 1, 5000);
2079
2467
  const rows = await queryLockEntries(conn, {
2080
- objectLibrary: args?.objectLibrary,
2081
- objectName: args?.objectName,
2082
- objectType: args?.objectType,
2083
- member: args?.member,
2468
+ objectLibrary: args?.objectLibrary ? requireIbmObjectName(args.objectLibrary, "objectLibrary") : undefined,
2469
+ objectName: args?.objectName ? requireIbmObjectName(args.objectName, "objectName") : undefined,
2470
+ objectType: args?.objectType ? requireObjectTypeValue(args.objectType, "objectType") : undefined,
2471
+ member: args?.member ? requireIbmObjectName(args.member, "member") : undefined,
2084
2472
  limit
2085
2473
  });
2086
2474
  return json(rows);
@@ -2096,14 +2484,16 @@ export async function handleTool(ctx, name, args) {
2096
2484
  }
2097
2485
  case "ibmi.dataqueue.send": {
2098
2486
  const conn = await ctx.ensureActive(args?.connectionName);
2099
- enforceWritable(ctx, conn, "qsys.write", args);
2487
+ await enforceWritable(ctx, conn, "qsys.write", args);
2100
2488
  const library = requireIbmObjectName(args.library, "library");
2101
2489
  const queue = requireIbmObjectName(args.queue, "queue");
2102
2490
  const message = String(args.message || "");
2103
2491
  if (!message)
2104
2492
  throw new Error("message is required");
2493
+ let primaryError;
2105
2494
  try {
2106
- await conn.runSQL(`call qsys2.send_data_queue(${Tools.sqlString(message)}, ${Tools.sqlString(queue)}, ${Tools.sqlString(library)})`);
2495
+ await conn.runSQL(`call qsys2.send_data_queue(message_data => ${Tools.sqlString(message)}, ` +
2496
+ `data_queue => ${Tools.sqlString(queue)}, data_queue_library => ${Tools.sqlString(library)})`);
2107
2497
  return json({
2108
2498
  ok: true,
2109
2499
  method: "qsys2.send_data_queue",
@@ -2112,18 +2502,31 @@ export async function handleTool(ctx, name, args) {
2112
2502
  messageLength: message.length
2113
2503
  });
2114
2504
  }
2115
- catch {
2116
- const command = `SNDDTAQ DTAQ(${library}/${queue}) DTA(${quoteClString(message)})`;
2117
- const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2118
- return json({ ok: cmdResult.code === 0, method: "cl.snddtaq", command, ...cmdResult });
2505
+ catch (err) {
2506
+ primaryError = err?.message || String(err);
2119
2507
  }
2508
+ // Older releases without QSYS2.SEND_DATA_QUEUE: call the QSNDDTAQ API.
2509
+ // There is no SNDDTAQ CL command.
2510
+ const command = buildQsnddtaqCall(library, queue, message);
2511
+ const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2512
+ return commandOutcome({
2513
+ ok: cmdResult.code === 0,
2514
+ method: "api.qsnddtaq",
2515
+ command,
2516
+ primaryError,
2517
+ ...cmdResult
2518
+ });
2120
2519
  }
2121
2520
  case "ibmi.dataqueue.receive": {
2122
2521
  const conn = await ctx.ensureActive(args?.connectionName);
2123
2522
  const library = requireIbmObjectName(args.library, "library");
2124
2523
  const queue = requireIbmObjectName(args.queue, "queue");
2125
- const waitSeconds = clampNumber(args?.waitSeconds, 0, 0, 999999);
2126
- const remove = Boolean(args?.remove ?? true);
2524
+ const waitSeconds = clampNumber(args?.waitSeconds, 0, 0, DATA_QUEUE_MAX_WAIT_SECONDS);
2525
+ // Peek by default. Removing an entry changes the queue, so it goes
2526
+ // through the same write gate as the other write tools.
2527
+ const remove = args?.remove === true;
2528
+ if (remove)
2529
+ await enforceWritable(ctx, conn, "qsys.write", args);
2127
2530
  const rows = await queryDataQueueEntries(conn, { library, queue, waitSeconds, remove });
2128
2531
  return json(rows);
2129
2532
  }
@@ -2136,15 +2539,20 @@ export async function handleTool(ctx, name, args) {
2136
2539
  }
2137
2540
  case "ibmi.dataarea.write": {
2138
2541
  const conn = await ctx.ensureActive(args?.connectionName);
2139
- enforceWritable(ctx, conn, "qsys.write", args);
2542
+ await enforceWritable(ctx, conn, "qsys.write", args);
2140
2543
  const library = requireIbmObjectName(args.library, "library");
2141
2544
  const dataArea = requireIbmObjectName(args.dataArea, "dataArea");
2142
- const value = String(args.value || "");
2143
- const startPosition = clampNumber(args?.startPosition, 1, 1, 2000);
2144
- const length = clampNumber(args?.length, Math.max(value.length, 1), 1, 2000);
2145
- const command = `CHGDTAARA DTAARA(${library}/${dataArea} (${startPosition} ${length})) VALUE(${quoteClString(value)})`;
2545
+ const value = String(args.value ?? "");
2546
+ let info;
2547
+ try {
2548
+ info = (await queryDataAreaInfo(conn, { library, dataArea }))[0];
2549
+ }
2550
+ catch {
2551
+ info = undefined;
2552
+ }
2553
+ const command = buildChangeDataAreaCommand(library, dataArea, value, info, args);
2146
2554
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2147
- return json({ ok: cmdResult.code === 0, command, ...cmdResult });
2555
+ return commandOutcome({ ok: cmdResult.code === 0, command, dataAreaType: info?.type || null, ...cmdResult });
2148
2556
  }
2149
2557
  case "ibmi.audit.list": {
2150
2558
  const data = await listAuditRecords({
@@ -2163,7 +2571,7 @@ export async function handleTool(ctx, name, args) {
2163
2571
  }
2164
2572
  case "ibmi.audit.export": {
2165
2573
  const exported = await exportAuditRecords({
2166
- outputPath: String(args.outputPath),
2574
+ outputPath: await guardLocalPath(ctx, args.outputPath, "write"),
2167
2575
  format: args?.format,
2168
2576
  filter: {
2169
2577
  limit: args?.limit,
@@ -2177,8 +2585,8 @@ export async function handleTool(ctx, name, args) {
2177
2585
  return json(exported);
2178
2586
  }
2179
2587
  case "ibmi.audit.purge": {
2180
- if (!Boolean(args?.dryRun) && !Boolean(args?.approve)) {
2181
- throw new Error("ibmi.audit.purge requires approve=true unless dryRun=true.");
2588
+ if (!Boolean(args?.dryRun)) {
2589
+ await requireDangerousApproval(ctx, "audit.purge", args, "ibmi.audit.purge requires approve=true unless dryRun=true.");
2182
2590
  }
2183
2591
  const purged = await purgeAuditRecords({
2184
2592
  before: String(args.before),
@@ -2197,6 +2605,7 @@ export async function handleTool(ctx, name, args) {
2197
2605
  const journal = requireIbmObjectName(args.journal, "journal");
2198
2606
  const objectLibrary = args?.objectLibrary ? requireIbmObjectName(args.objectLibrary, "objectLibrary") : undefined;
2199
2607
  const objectName = args?.objectName ? requireIbmObjectName(args.objectName, "objectName") : undefined;
2608
+ const objectType = args?.objectType ? requireObjectTypeValue(args.objectType, "objectType") : undefined;
2200
2609
  const journalCode = args?.journalCode ? requireJournalCode(args.journalCode) : undefined;
2201
2610
  const entryType = args?.entryType ? requireJournalEntryType(args.entryType) : undefined;
2202
2611
  const userName = args?.userName ? requireIbmObjectName(args.userName, "userName") : undefined;
@@ -2210,6 +2619,7 @@ export async function handleTool(ctx, name, args) {
2210
2619
  journal,
2211
2620
  objectLibrary,
2212
2621
  objectName,
2622
+ objectType,
2213
2623
  journalCode,
2214
2624
  entryType,
2215
2625
  userName,
@@ -2250,13 +2660,13 @@ export async function handleTool(ctx, name, args) {
2250
2660
  }
2251
2661
  case "ibmi.journal.receiver.create": {
2252
2662
  const conn = await ctx.ensureActive(args?.connectionName);
2253
- enforceWritable(ctx, conn, "qsys.write", args);
2663
+ await enforceWritable(ctx, conn, "qsys.write", args);
2254
2664
  const lib = requireIbmObjectName(args.library, "library");
2255
2665
  const rcv = requireIbmObjectName(args.receiver, "receiver");
2256
2666
  const threshold = clampNumber(args?.threshold, 1000000, 1, 1000000000);
2257
2667
  const command = `CRTJRNRCV JRNRCV(${lib}/${rcv}) THRESHOLD(${threshold})`;
2258
2668
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2259
- return json({
2669
+ return commandOutcome({
2260
2670
  ok: cmdResult.code === 0,
2261
2671
  command,
2262
2672
  ...cmdResult
@@ -2264,14 +2674,14 @@ export async function handleTool(ctx, name, args) {
2264
2674
  }
2265
2675
  case "ibmi.journal.create": {
2266
2676
  const conn = await ctx.ensureActive(args?.connectionName);
2267
- enforceWritable(ctx, conn, "qsys.write", args);
2677
+ await enforceWritable(ctx, conn, "qsys.write", args);
2268
2678
  const lib = requireIbmObjectName(args.library, "library");
2269
2679
  const jrn = requireIbmObjectName(args.journal, "journal");
2270
2680
  const rcvLib = requireIbmObjectName(args.receiverLibrary, "receiverLibrary");
2271
2681
  const rcv = requireIbmObjectName(args.receiver, "receiver");
2272
2682
  const command = `CRTJRN JRN(${lib}/${jrn}) JRNRCV(${rcvLib}/${rcv})`;
2273
2683
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2274
- return json({
2684
+ return commandOutcome({
2275
2685
  ok: cmdResult.code === 0,
2276
2686
  command,
2277
2687
  ...cmdResult
@@ -2279,14 +2689,14 @@ export async function handleTool(ctx, name, args) {
2279
2689
  }
2280
2690
  case "ibmi.journal.receiver.change": {
2281
2691
  const conn = await ctx.ensureActive(args?.connectionName);
2282
- enforceWritable(ctx, conn, "qsys.write", args);
2692
+ await enforceWritable(ctx, conn, "qsys.write", args);
2283
2693
  const jrnLib = requireIbmObjectName(args.journalLibrary, "journalLibrary");
2284
2694
  const jrn = requireIbmObjectName(args.journal, "journal");
2285
2695
  const rcvLib = requireIbmObjectName(args.receiverLibrary, "receiverLibrary");
2286
2696
  const rcv = requireIbmObjectName(args.receiver, "receiver");
2287
2697
  const command = `CHGJRN JRN(${jrnLib}/${jrn}) JRNRCV(${rcvLib}/${rcv})`;
2288
2698
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2289
- return json({
2699
+ return commandOutcome({
2290
2700
  ok: cmdResult.code === 0,
2291
2701
  command,
2292
2702
  ...cmdResult
@@ -2294,7 +2704,7 @@ export async function handleTool(ctx, name, args) {
2294
2704
  }
2295
2705
  case "ibmi.journal.startPf": {
2296
2706
  const conn = await ctx.ensureActive(args?.connectionName);
2297
- enforceWritable(ctx, conn, "qsys.write", args);
2707
+ await enforceWritable(ctx, conn, "qsys.write", args);
2298
2708
  const lib = requireIbmObjectName(args.library, "library");
2299
2709
  const file = requireIbmObjectName(args.file, "file");
2300
2710
  const jrnLib = requireIbmObjectName(args.journalLibrary, "journalLibrary");
@@ -2305,7 +2715,7 @@ export async function handleTool(ctx, name, args) {
2305
2715
  }
2306
2716
  const command = `STRJRNPF FILE(${lib}/${file}) JRN(${jrnLib}/${jrn}) IMAGES(${images})`;
2307
2717
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2308
- return json({
2718
+ return commandOutcome({
2309
2719
  ok: cmdResult.code === 0,
2310
2720
  command,
2311
2721
  ...cmdResult
@@ -2313,12 +2723,12 @@ export async function handleTool(ctx, name, args) {
2313
2723
  }
2314
2724
  case "ibmi.journal.endPf": {
2315
2725
  const conn = await ctx.ensureActive(args?.connectionName);
2316
- enforceWritable(ctx, conn, "qsys.write", args);
2726
+ await enforceWritable(ctx, conn, "qsys.write", args);
2317
2727
  const lib = requireIbmObjectName(args.library, "library");
2318
2728
  const file = requireIbmObjectName(args.file, "file");
2319
2729
  const command = `ENDJRNPF FILE(${lib}/${file})`;
2320
2730
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2321
- return json({
2731
+ return commandOutcome({
2322
2732
  ok: cmdResult.code === 0,
2323
2733
  command,
2324
2734
  ...cmdResult
@@ -2326,14 +2736,14 @@ export async function handleTool(ctx, name, args) {
2326
2736
  }
2327
2737
  case "ibmi.journal.startIfs": {
2328
2738
  const conn = await ctx.ensureActive(args?.connectionName);
2329
- enforceWritable(ctx, conn, "qsys.write", args);
2739
+ await enforceWritable(ctx, conn, "qsys.write", args);
2330
2740
  const objectType = requireIfsJournalObjectType(args?.objectType);
2331
2741
  const journalLibrary = requireIbmObjectName(args.journalLibrary, "journalLibrary");
2332
2742
  const journal = requireIbmObjectName(args.journal, "journal");
2333
2743
  const objectPath = requireIfsPath(args.path, "path");
2334
2744
  const command = `STRJRNOBJ OBJ((${quoteClString(objectPath)} ${objectType})) JRN(${journalLibrary}/${journal})`;
2335
2745
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2336
- return json({
2746
+ return commandOutcome({
2337
2747
  ok: cmdResult.code === 0,
2338
2748
  command,
2339
2749
  ...cmdResult
@@ -2341,12 +2751,12 @@ export async function handleTool(ctx, name, args) {
2341
2751
  }
2342
2752
  case "ibmi.journal.endIfs": {
2343
2753
  const conn = await ctx.ensureActive(args?.connectionName);
2344
- enforceWritable(ctx, conn, "qsys.write", args);
2754
+ await enforceWritable(ctx, conn, "qsys.write", args);
2345
2755
  const objectType = requireIfsJournalObjectType(args?.objectType);
2346
2756
  const objectPath = requireIfsPath(args.path, "path");
2347
2757
  const command = `ENDJRNOBJ OBJ((${quoteClString(objectPath)} ${objectType}))`;
2348
2758
  const cmdResult = await runClCommand(conn, command, "ile", undefined, 45000);
2349
- return json({
2759
+ return commandOutcome({
2350
2760
  ok: cmdResult.code === 0,
2351
2761
  command,
2352
2762
  ...cmdResult
@@ -2358,9 +2768,11 @@ export async function handleTool(ctx, name, args) {
2358
2768
  const journal = requireIbmObjectName(args.journal, "journal");
2359
2769
  const retentionDays = clampNumber(args?.retentionDays, 30, 1, 36500);
2360
2770
  const maxDeletes = clampNumber(args?.maxDeletes, 50, 1, 500);
2361
- const dryRun = Boolean(args?.dryRun);
2362
- if (!dryRun && !Boolean(args?.approve)) {
2363
- throw new Error("ibmi.journal.receivers.retention requires approve=true unless dryRun=true.");
2771
+ // The schema documents dryRun as defaulting to true; only an explicit
2772
+ // dryRun=false may delete receivers.
2773
+ const dryRun = args?.dryRun === undefined || args?.dryRun === null ? true : Boolean(args.dryRun);
2774
+ if (!dryRun) {
2775
+ await requireDangerousApproval(ctx, "journal.receivers.delete", args, "ibmi.journal.receivers.retention requires approve=true unless dryRun=true.");
2364
2776
  }
2365
2777
  const plan = await planJournalReceiverRetention(conn, {
2366
2778
  journalLibrary,
@@ -2378,7 +2790,7 @@ export async function handleTool(ctx, name, args) {
2378
2790
  candidates
2379
2791
  });
2380
2792
  }
2381
- enforceWritable(ctx, conn, "qsys.write", args);
2793
+ await enforceWritable(ctx, conn, "qsys.write", args);
2382
2794
  const deleted = [];
2383
2795
  for (const candidate of candidates) {
2384
2796
  const command = `DLTJRNRCV JRNRCV(${candidate.receiverLibrary}/${candidate.receiver}) DLTOPT(*IGNINQMSG)`;
@@ -2390,7 +2802,7 @@ export async function handleTool(ctx, name, args) {
2390
2802
  error: result.code === 0 ? undefined : String(result.stderr || "")
2391
2803
  });
2392
2804
  }
2393
- return json({
2805
+ return commandOutcome({
2394
2806
  ok: deleted.every(item => item.ok),
2395
2807
  dryRun: false,
2396
2808
  retentionDays,
@@ -2416,7 +2828,7 @@ export async function handleTool(ctx, name, args) {
2416
2828
  if (!args?.outputPath) {
2417
2829
  return json(report);
2418
2830
  }
2419
- const outputPath = path.resolve(String(args.outputPath));
2831
+ const outputPath = await guardLocalPath(ctx, args.outputPath, "write");
2420
2832
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
2421
2833
  await fs.writeFile(outputPath, JSON.stringify(report, null, 2), "utf8");
2422
2834
  let signaturePath;
@@ -2444,116 +2856,6 @@ export async function handleTool(ctx, name, args) {
2444
2856
  signature
2445
2857
  });
2446
2858
  }
2447
- case "ibmi.tn5250.connect": {
2448
- const name = getConnectionName(ctx, args);
2449
- await ctx.ensureActive(name);
2450
- const session = ensureTn5250Session(name);
2451
- appendTn5250Message(session, `Session ready for ${name}.`);
2452
- return json(session.screen);
2453
- }
2454
- case "ibmi.tn5250.readScreen": {
2455
- const session = requireTn5250Session(ctx, args);
2456
- return json(session.screen);
2457
- }
2458
- case "ibmi.tn5250.setField": {
2459
- const session = requireTn5250Session(ctx, args);
2460
- const field = findTn5250Field(session, args.fieldId);
2461
- if (!field)
2462
- throw new Error(`TN5250 field not found: ${args.fieldId}`);
2463
- if (field.protected)
2464
- throw new Error(`TN5250 field is protected: ${field.id}`);
2465
- const value = String(args.value || "");
2466
- field.value = value.length > field.length ? value.substring(0, field.length) : value;
2467
- touchTn5250Session(session);
2468
- return json(session.screen);
2469
- }
2470
- case "ibmi.tn5250.sendKeys": {
2471
- const session = requireTn5250Session(ctx, args);
2472
- const conn = await ctx.ensureActive(session.connectionName);
2473
- const keys = parseTn5250Keys(args.keys);
2474
- const timeoutMs = clampNumber(args?.timeoutMs, 45000, 1000, 300000);
2475
- const commandField = findTn5250Field(session, "command");
2476
- session.screen.lastKeys = keys;
2477
- session.screen.status = "busy";
2478
- touchTn5250Session(session);
2479
- for (const key of keys) {
2480
- if (key === "ENTER" || key === "RETURN") {
2481
- const command = String(commandField?.value || "").trim();
2482
- if (!command) {
2483
- appendTn5250Message(session, "No command entered.");
2484
- continue;
2485
- }
2486
- enforcePolicyOperation(ctx, "cl.run", {
2487
- ...args,
2488
- command,
2489
- connectionName: session.connectionName
2490
- });
2491
- const cmdResult = await runClCommand(conn, command, "ile", undefined, timeoutMs);
2492
- const recent = await readRecentJoblogMessages(conn, 5);
2493
- const messages = recent.map((row) => {
2494
- const id = String(row.MESSAGE_ID || "");
2495
- const text = String(row.MESSAGE_TEXT || "");
2496
- return id ? `[${id}] ${text}` : text;
2497
- });
2498
- session.screen.commandHistory.unshift({
2499
- at: new Date().toISOString(),
2500
- command,
2501
- ok: cmdResult.code === 0,
2502
- code: cmdResult.code,
2503
- stderr: cmdResult.stderr ? String(cmdResult.stderr) : undefined,
2504
- messages: messages.slice(0, 5)
2505
- });
2506
- if (session.screen.commandHistory.length > TN5250_MAX_HISTORY) {
2507
- session.screen.commandHistory = session.screen.commandHistory.slice(0, TN5250_MAX_HISTORY);
2508
- }
2509
- appendTn5250Message(session, `${cmdResult.code === 0 ? "OK" : "ERROR"}: ${command}`);
2510
- for (const line of messages.slice(0, 3)) {
2511
- appendTn5250Message(session, line);
2512
- }
2513
- if (commandField)
2514
- commandField.value = "";
2515
- continue;
2516
- }
2517
- if (key === "CLEAR" || key === "F3" || key === "F12") {
2518
- resetTn5250Screen(session, `Cleared via ${key}.`);
2519
- continue;
2520
- }
2521
- if (key === "TAB") {
2522
- rotateTn5250Cursor(session);
2523
- continue;
2524
- }
2525
- appendTn5250Message(session, `Unsupported key: ${key}`);
2526
- }
2527
- session.screen.status = "ready";
2528
- touchTn5250Session(session);
2529
- return json(session.screen);
2530
- }
2531
- case "ibmi.tn5250.waitFor": {
2532
- const session = requireTn5250Session(ctx, args);
2533
- const timeoutMs = clampNumber(args?.timeoutMs, 5000, 250, 120000);
2534
- const pattern = args?.text ? String(args.text) : "";
2535
- if (!pattern) {
2536
- return json({ matched: true, timeoutMs, screen: session.screen });
2537
- }
2538
- const matched = await waitForTn5250Text(session, pattern, timeoutMs);
2539
- return json({ matched, text: pattern, timeoutMs, screen: session.screen });
2540
- }
2541
- case "ibmi.tn5250.snapshot": {
2542
- const session = requireTn5250Session(ctx, args);
2543
- return json({
2544
- capturedAt: new Date().toISOString(),
2545
- connectionName: session.connectionName,
2546
- plainText: renderTn5250Screen(session.screen),
2547
- screen: session.screen
2548
- });
2549
- }
2550
- case "ibmi.tn5250.disconnect": {
2551
- const name = resolveTn5250SessionName(ctx, args);
2552
- if (!name)
2553
- throw new Error("connectionName is required (no active session)");
2554
- const existed = tn5250Sessions.delete(name);
2555
- return json({ disconnected: existed, connectionName: name });
2556
- }
2557
2859
  }
2558
2860
  throw new Error(`Unknown tool: ${name}`);
2559
2861
  }
@@ -2562,7 +2864,7 @@ async function listActions(ctx) {
2562
2864
  const custom = await ctx.store.listActions();
2563
2865
  return [...builtins, ...custom];
2564
2866
  }
2565
- async function resolveAction(ctx, args) {
2867
+ export async function resolveAction(ctx, args) {
2566
2868
  if (args.action)
2567
2869
  return args.action;
2568
2870
  if (!args.actionName)
@@ -2570,7 +2872,7 @@ async function resolveAction(ctx, args) {
2570
2872
  const actions = await listActions(ctx);
2571
2873
  return actions.find(a => a.name === args.actionName);
2572
2874
  }
2573
- async function runClCommand(conn, command, environment, cwd, timeoutMs) {
2875
+ export async function runClCommand(conn, command, environment, cwd, timeoutMs) {
2574
2876
  if (environment === "pase") {
2575
2877
  return await withTimeout(conn.sendCommand({ command, directory: cwd }), timeoutMs, `CL command timed out after ${timeoutMs}ms`);
2576
2878
  }
@@ -2578,22 +2880,16 @@ async function runClCommand(conn, command, environment, cwd, timeoutMs) {
2578
2880
  return await withTimeout(conn.sendQsh({ command, directory: cwd }), timeoutMs, `CL command timed out after ${timeoutMs}ms`);
2579
2881
  }
2580
2882
  try {
2581
- await withTimeout(conn.runSQL(`CALL QSYS2.QCMDEXC(${Tools.sqlString(command)})`), timeoutMs, `CL command timed out after ${timeoutMs}ms`);
2883
+ await runSqlWithTimeout(conn, `CALL QSYS2.QCMDEXC(${Tools.sqlString(command)})`, timeoutMs, `CL command timed out after ${timeoutMs}ms`);
2582
2884
  return { code: 0, signal: null, stdout: "", stderr: "", command };
2583
2885
  }
2584
2886
  catch (err) {
2585
2887
  return { code: 1, signal: null, stdout: "", stderr: err?.message || String(err), command };
2586
2888
  }
2587
2889
  }
2588
- async function readRecentJoblogMessages(conn, limit) {
2589
- try {
2590
- const capped = clampNumber(limit, 5, 1, 20);
2591
- return await conn.runSQL(`select message_id, message_text, severity, message_timestamp from table(qsys2.joblog_info('*')) order by message_timestamp desc fetch first ${capped} rows only`);
2592
- }
2593
- catch {
2594
- return [];
2595
- }
2596
- }
2890
+ // Every filter is applied on the IBM i (table function parameters and WHERE
2891
+ // predicates) before FETCH FIRST, so the row cap never hides matching entries.
2892
+ // Rows come back newest first.
2597
2893
  async function queryJournalEntries(conn, options) {
2598
2894
  let rows;
2599
2895
  try {
@@ -2602,42 +2898,7 @@ async function queryJournalEntries(conn, options) {
2602
2898
  catch {
2603
2899
  rows = await queryJournalEntriesViaOutfile(conn, options);
2604
2900
  }
2605
- let normalized = rows.map(normalizeJournalEntry);
2606
- if (options.objectLibrary) {
2607
- normalized = normalized.filter(row => (row.objectLibrary || "") === options.objectLibrary);
2608
- }
2609
- if (options.objectName) {
2610
- normalized = normalized.filter(row => (row.objectName || "") === options.objectName);
2611
- }
2612
- if (options.journalCode) {
2613
- normalized = normalized.filter(row => (row.journalCode || "") === options.journalCode);
2614
- }
2615
- if (options.entryType) {
2616
- normalized = normalized.filter(row => (row.entryType || "") === options.entryType);
2617
- }
2618
- if (options.userName) {
2619
- normalized = normalized.filter(row => (row.userName || "") === options.userName);
2620
- }
2621
- if (options.jobName) {
2622
- normalized = normalized.filter(row => (row.jobName || "").includes(options.jobName));
2623
- }
2624
- if (options.programName) {
2625
- normalized = normalized.filter(row => (row.programName || "") === options.programName);
2626
- }
2627
- if (typeof options.sinceSequence === "number") {
2628
- normalized = normalized.filter(row => typeof row.sequence === "number" && row.sequence >= options.sinceSequence);
2629
- }
2630
- if (options.sinceTimestamp) {
2631
- const cutoff = Date.parse(options.sinceTimestamp);
2632
- if (Number.isFinite(cutoff)) {
2633
- normalized = normalized.filter(row => {
2634
- if (!row.timestamp)
2635
- return false;
2636
- const value = Date.parse(row.timestamp);
2637
- return Number.isFinite(value) && value >= cutoff;
2638
- });
2639
- }
2640
- }
2901
+ const normalized = rows.map(normalizeJournalEntry);
2641
2902
  normalized.sort((a, b) => {
2642
2903
  const aSeq = typeof a.sequence === "number" ? a.sequence : -1;
2643
2904
  const bSeq = typeof b.sequence === "number" ? b.sequence : -1;
@@ -2649,6 +2910,27 @@ async function queryJournalEntries(conn, options) {
2649
2910
  });
2650
2911
  return normalized.slice(0, options.limit);
2651
2912
  }
2913
+ // Turns an ISO-8601 or IBM i style timestamp into a Db2 for i timestamp
2914
+ // expression in the system's local time. Values with an explicit zone (Z or
2915
+ // +hh:mm) are converted from UTC with CURRENT TIMEZONE; values without a zone
2916
+ // are taken as system local time.
2917
+ function sqlTimestampExpression(value) {
2918
+ const raw = String(value || "").trim();
2919
+ const local = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T-](\d{2})[:.](\d{2})(?:[:.](\d{2})(?:\.(\d{1,6}))?)?)?$/);
2920
+ if (local) {
2921
+ const [, y, mo, d, h = "00", mi = "00", s = "00", frac = "0"] = local;
2922
+ return `timestamp('${y}-${mo}-${d}-${h}.${mi}.${s}.${frac.padEnd(6, "0")}')`;
2923
+ }
2924
+ if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)) {
2925
+ throw new Error(`Invalid sinceTimestamp: ${value}`);
2926
+ }
2927
+ const parsed = Date.parse(raw);
2928
+ if (!Number.isFinite(parsed))
2929
+ throw new Error(`Invalid sinceTimestamp: ${value}`);
2930
+ const iso = new Date(parsed).toISOString();
2931
+ const db2 = `${iso.slice(0, 10)}-${iso.slice(11, 13)}.${iso.slice(14, 16)}.${iso.slice(17, 19)}.${iso.slice(20, 23)}000`;
2932
+ return `(timestamp('${db2}') + current timezone)`;
2933
+ }
2652
2934
  async function queryJobs(conn, options) {
2653
2935
  const scanLimit = Math.min(options.limit * 5, 5000);
2654
2936
  const rows = [];
@@ -2728,27 +3010,61 @@ function normalizeActiveJobRow(row) {
2728
3010
  cpuSeconds: toNullableNumber(pickFirst(row, ["CPU_TIME", "TOTAL_CPU_TIME", "ELAPSED_TOTAL_SECONDS"]))
2729
3011
  };
2730
3012
  }
3013
+ // Newest messages first. The table function form takes QUEUE_LIBRARY and
3014
+ // QUEUE_NAME; the view form is the fallback for releases without it.
2731
3015
  async function queryMessageQueueEntries(conn, options) {
2732
3016
  let rows;
2733
3017
  try {
2734
- rows = await conn.runSQL(`select * from table(qsys2.message_queue_info(message_queue_name => ${Tools.sqlString(options.messageQueue)}, message_queue_library => ${Tools.sqlString(options.library)})) fetch first ${options.limit} rows only`);
3018
+ rows = await conn.runSQL(`select x.*, hex(x.message_key) as message_key_hex from table(qsys2.message_queue_info(queue_library => ${Tools.sqlString(options.library)}, queue_name => ${Tools.sqlString(options.messageQueue)})) x ` +
3019
+ `order by message_timestamp desc fetch first ${options.limit} rows only`);
2735
3020
  }
2736
3021
  catch {
2737
- rows = await conn.runSQL(`select * from qsys2.message_queue_info where upper(message_queue_name)=${Tools.sqlString(options.messageQueue)} and upper(message_queue_library)=${Tools.sqlString(options.library)} fetch first ${options.limit} rows only`);
3022
+ rows = await conn.runSQL(`select x.*, hex(x.message_key) as message_key_hex from qsys2.message_queue_info x where message_queue_name=${Tools.sqlString(options.messageQueue)} and message_queue_library=${Tools.sqlString(options.library)} ` +
3023
+ `order by message_timestamp desc fetch first ${options.limit} rows only`);
2738
3024
  }
2739
3025
  return rows.map(normalizeMessageQueueEntry);
2740
3026
  }
2741
- async function querySpoolEntries(conn, limit) {
2742
- const queries = [
2743
- `select * from qsys2.output_queue_entries fetch first ${limit} rows only`,
2744
- `select * from table(qsys2.output_queue_entries()) fetch first ${limit} rows only`,
2745
- `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`,
2746
- `select job_name, spooled_file_name, spooled_file_number, output_queue_name, total_pages, cast('' as varchar(10)) as file_status from qsys2.output_queue_entries fetch first ${limit} rows only`,
2747
- `select job_name, spooled_file_name, cast(0 as integer) as spooled_file_number, output_queue_name, total_pages, file_status from qsys2.output_queue_entries fetch first ${limit} rows only`,
2748
- `select job_name, spooled_file_name, cast(0 as integer) as spooled_file_number, output_queue_name, total_pages, cast('' as varchar(10)) as file_status from qsys2.output_queue_entries fetch first ${limit} rows only`,
2749
- `select * from qsys2.spooled_file_info fetch first ${limit} rows only`,
2750
- `select * from table(qsys2.spooled_file_info()) fetch first ${limit} rows only`
2751
- ];
3027
+ // QSYS2.OUTPUT_QUEUE_ENTRIES columns: USER_NAME, OUTPUT_QUEUE_NAME,
3028
+ // OUTPUT_QUEUE_LIBRARY_NAME, JOB_NAME (qualified), CREATE_TIMESTAMP, ...
3029
+ function buildSpoolWhereClause(options) {
3030
+ const predicates = [];
3031
+ if (options.userName)
3032
+ predicates.push(`user_name = ${Tools.sqlString(options.userName)}`);
3033
+ if (options.outputQueue)
3034
+ predicates.push(`output_queue_name = ${Tools.sqlString(options.outputQueue)}`);
3035
+ if (options.outputQueueLibrary)
3036
+ predicates.push(`output_queue_library_name = ${Tools.sqlString(options.outputQueueLibrary)}`);
3037
+ if (options.jobName) {
3038
+ predicates.push(options.jobName.includes("/")
3039
+ ? `job_name = ${Tools.sqlString(options.jobName)}`
3040
+ : `job_name like ${Tools.sqlString(`%/${options.jobName}`)}`);
3041
+ }
3042
+ return predicates.length > 0 ? ` where ${predicates.join(" and ")}` : "";
3043
+ }
3044
+ async function querySpoolEntries(conn, options) {
3045
+ const limit = options.limit;
3046
+ const where = buildSpoolWhereClause(options);
3047
+ const queries = [];
3048
+ // OUTPUT_QUEUE_ENTRIES scans every spooled file on the system before the
3049
+ // WHERE applies, which can take minutes. SPOOLED_FILE_INFO filters by user
3050
+ // or job inside the service, so use it first when one of those is given.
3051
+ const qualifiedJob = options.jobName?.includes("/") ? options.jobName : undefined;
3052
+ if (options.userName || qualifiedJob) {
3053
+ const params = [
3054
+ options.userName ? `user_name => ${Tools.sqlString(options.userName)}` : "",
3055
+ qualifiedJob ? `job_name => ${Tools.sqlString(qualifiedJob)}` : ""
3056
+ ].filter(Boolean).join(", ");
3057
+ const filters = [
3058
+ options.outputQueue ? `output_queue = ${Tools.sqlString(options.outputQueue)}` : "",
3059
+ options.outputQueueLibrary ? `output_queue_library = ${Tools.sqlString(options.outputQueueLibrary)}` : "",
3060
+ options.jobName && !qualifiedJob ? `job_name = ${Tools.sqlString(options.jobName)}` : ""
3061
+ ].filter(Boolean);
3062
+ queries.push(`select * from table(qsys2.spooled_file_info(${params})) x${filters.length ? ` where ${filters.join(" and ")}` : ""} order by creation_timestamp desc fetch first ${limit} rows only`);
3063
+ }
3064
+ queries.push(`select * from qsys2.output_queue_entries${where} order by create_timestamp desc fetch first ${limit} rows only`, `select * from qsys2.output_queue_entries${where} fetch first ${limit} rows only`);
3065
+ if (!where) {
3066
+ queries.push(`select * from table(qsys2.output_queue_entries()) fetch first ${limit} rows only`, `select * from qsys2.spooled_file_info fetch first ${limit} rows only`, `select * from table(qsys2.spooled_file_info()) fetch first ${limit} rows only`);
3067
+ }
2752
3068
  let lastError;
2753
3069
  for (const sql of queries) {
2754
3070
  try {
@@ -2763,7 +3079,8 @@ async function querySpoolEntries(conn, limit) {
2763
3079
  function normalizeMessageQueueEntry(row) {
2764
3080
  return {
2765
3081
  source: "msgq",
2766
- messageKey: toNullableString(pickFirst(row, ["MESSAGE_KEY", "MSGKEY"])),
3082
+ // The raw key is binary; the hex form is what ibmi.msgq.reply accepts.
3083
+ messageKey: toNullableString(pickFirst(row, ["MESSAGE_KEY_HEX", "MSGKEY"])),
2767
3084
  messageId: toNullableString(pickFirst(row, ["MESSAGE_ID", "MSGID"])),
2768
3085
  messageType: toNullableString(pickFirst(row, ["MESSAGE_TYPE", "MSGTYPE"])),
2769
3086
  severity: toNullableNumber(pickFirst(row, ["SEVERITY", "MSGSEV"])),
@@ -2772,23 +3089,35 @@ function normalizeMessageQueueEntry(row) {
2772
3089
  sentAt: toNullableString(pickFirst(row, ["MESSAGE_TIMESTAMP", "SENT_TIMESTAMP"]))
2773
3090
  };
2774
3091
  }
3092
+ // QSYS2.OBJECT_LOCK_INFO is a view; filters go into its WHERE clause so the row
3093
+ // cap applies to matching locks only.
2775
3094
  async function queryLockEntries(conn, options) {
3095
+ const base = [];
3096
+ if (options.objectLibrary)
3097
+ base.push(`object_schema = ${Tools.sqlString(options.objectLibrary)}`);
3098
+ if (options.objectName)
3099
+ base.push(`object_name = ${Tools.sqlString(options.objectName)}`);
3100
+ if (options.objectType)
3101
+ base.push(`object_type = ${Tools.sqlString(options.objectType)}`);
3102
+ const memberPredicate = options.member ? `system_table_member = ${Tools.sqlString(options.member)}` : undefined;
3103
+ const whereOf = (predicates) => predicates.length > 0 ? ` where ${predicates.join(" and ")}` : "";
2776
3104
  let rows;
3105
+ let filterMemberLocally = false;
2777
3106
  try {
2778
- rows = await conn.runSQL(`select * from table(qsys2.object_lock_info()) fetch first ${Math.min(options.limit, 500)} rows only`);
3107
+ rows = await conn.runSQL(`select * from qsys2.object_lock_info${whereOf(memberPredicate ? [...base, memberPredicate] : base)} fetch first ${options.limit} rows only`);
2779
3108
  }
2780
- catch {
2781
- rows = await conn.runSQL(`select * from qsys2.object_lock_info fetch first ${Math.min(options.limit, 500)} rows only`);
3109
+ catch (err) {
3110
+ if (!memberPredicate)
3111
+ throw err;
3112
+ // Fallback when the member column is named differently on this release:
3113
+ // keep the object filters in SQL and match the member locally.
3114
+ rows = await conn.runSQL(`select * from qsys2.object_lock_info${whereOf(base)} fetch first 5000 rows only`);
3115
+ filterMemberLocally = true;
2782
3116
  }
2783
3117
  let normalized = rows.map(normalizeLockEntry);
2784
- if (options.objectLibrary)
2785
- normalized = normalized.filter(row => (row.objectLibrary || "") === String(options.objectLibrary).toUpperCase());
2786
- if (options.objectName)
2787
- normalized = normalized.filter(row => (row.objectName || "") === String(options.objectName).toUpperCase());
2788
- if (options.objectType)
2789
- normalized = normalized.filter(row => (row.objectType || "") === String(options.objectType).toUpperCase());
2790
- if (options.member)
2791
- normalized = normalized.filter(row => (row.member || "") === String(options.member).toUpperCase());
3118
+ if (filterMemberLocally && options.member) {
3119
+ normalized = normalized.filter(row => (row.member || "") === options.member);
3120
+ }
2792
3121
  return normalized.slice(0, options.limit);
2793
3122
  }
2794
3123
  function normalizeLockEntry(row) {
@@ -2797,7 +3126,7 @@ function normalizeLockEntry(row) {
2797
3126
  objectLibrary: toNullableString(pickFirst(row, ["OBJECT_SCHEMA", "OBJECT_LIBRARY", "OBJLIB"])),
2798
3127
  objectName: toNullableString(pickFirst(row, ["OBJECT_NAME", "OBJNAME"])),
2799
3128
  objectType: toNullableString(pickFirst(row, ["OBJECT_TYPE", "OBJTYPE"])),
2800
- member: toNullableString(pickFirst(row, ["MEMBER_NAME", "MEMBER"])),
3129
+ member: toNullableString(pickFirst(row, ["SYSTEM_TABLE_MEMBER", "MEMBER_NAME", "MEMBER"])),
2801
3130
  lockState: toNullableString(pickFirst(row, ["LOCK_STATE", "LOCK_STATUS", "STATUS"])),
2802
3131
  lockScope: toNullableString(pickFirst(row, ["LOCK_SCOPE", "SCOPE"])),
2803
3132
  lockHolderJob: toNullableString(pickFirst(row, ["JOB_NAME", "LOCK_JOB_NAME"])),
@@ -2825,23 +3154,27 @@ function normalizeObjectAuthorityRow(row) {
2825
3154
  raw: row
2826
3155
  };
2827
3156
  }
3157
+ const DATA_QUEUE_MAX_WAIT_SECONDS = 60;
3158
+ // remove=false never falls back to anything that takes the entry off the
3159
+ // queue: it peeks with RECEIVE_DATA_QUEUE(REMOVE => 'NO') and then with the
3160
+ // read-only DATA_QUEUE_ENTRIES service.
2828
3161
  async function queryDataQueueEntries(conn, options) {
3162
+ const queue = Tools.sqlString(options.queue);
3163
+ const library = Tools.sqlString(options.library);
3164
+ const waitSeconds = Math.max(0, Math.min(DATA_QUEUE_MAX_WAIT_SECONDS, Math.floor(options.waitSeconds)));
2829
3165
  let rows;
3166
+ if (options.remove) {
3167
+ rows = await conn.runSQL(`select * from table(qsys2.receive_data_queue(data_queue => ${queue}, data_queue_library => ${library}, ` +
3168
+ `wait_time => ${waitSeconds}, remove => 'YES')) fetch first 1 rows only`);
3169
+ return rows.map(normalizeDataQueueEntry);
3170
+ }
2830
3171
  try {
2831
- rows = await conn.runSQL(`select * from table(qsys2.receive_data_queue(${Tools.sqlString(options.queue)}, ${Tools.sqlString(options.library)}, ${Tools.sqlString(options.remove ? "YES" : "NO")}, ${options.waitSeconds})) fetch first 1 rows only`);
3172
+ rows = await conn.runSQL(`select * from table(qsys2.receive_data_queue(data_queue => ${queue}, data_queue_library => ${library}, ` +
3173
+ `wait_time => ${waitSeconds}, remove => 'NO')) fetch first 1 rows only`);
2832
3174
  }
2833
3175
  catch {
2834
- try {
2835
- rows = await conn.runSQL(`select * from table(qsys2.receive_data_queue(${Tools.sqlString(options.queue)}, ${Tools.sqlString(options.library)})) fetch first 1 rows only`);
2836
- }
2837
- catch {
2838
- try {
2839
- rows = await conn.runSQL(`select * from table(qsys2.data_queue_entries(${Tools.sqlString(options.queue)}, ${Tools.sqlString(options.library)})) fetch first 1 rows only`);
2840
- }
2841
- catch {
2842
- rows = await conn.runSQL(`select * from qsys2.data_queue_entries where upper(data_queue_name)=${Tools.sqlString(options.queue)} and upper(data_queue_library)=${Tools.sqlString(options.library)} fetch first 1 rows only`);
2843
- }
2844
- }
3176
+ rows = await conn.runSQL(`select * from table(qsys2.data_queue_entries(data_queue => ${queue}, data_queue_library => ${library})) ` +
3177
+ `order by ordinal_position fetch first 1 rows only`);
2845
3178
  }
2846
3179
  return rows.map(normalizeDataQueueEntry);
2847
3180
  }
@@ -2871,7 +3204,8 @@ function normalizeDataAreaInfoRow(row) {
2871
3204
  dataArea: toNullableString(pickFirst(row, ["DATA_AREA_NAME", "OBJECT_NAME"])),
2872
3205
  type: toNullableString(pickFirst(row, ["DATA_AREA_TYPE", "OBJECT_TYPE"])),
2873
3206
  length: toNullableNumber(pickFirst(row, ["LENGTH", "DATA_AREA_LENGTH"])),
2874
- value: toNullableString(pickFirst(row, ["CHARACTER_VALUE", "VALUE", "DECIMAL_VALUE"])),
3207
+ decimalPositions: toNullableNumber(pickFirst(row, ["DECIMAL_POSITIONS"])),
3208
+ value: toNullableString(pickFirst(row, ["DATA_AREA_VALUE", "CHARACTER_VALUE", "VALUE", "DECIMAL_VALUE"])),
2875
3209
  updatedAt: toNullableString(pickFirst(row, ["LAST_CHANGED_TIMESTAMP", "CHANGE_TIMESTAMP"]))
2876
3210
  };
2877
3211
  }
@@ -2884,22 +3218,100 @@ function buildSpoolCommand(commandName, args) {
2884
3218
  : "*LAST";
2885
3219
  return `${commandName} FILE(${spooledFileName}) JOB(${jobName}) SPLNBR(${spooledFileNumber})`;
2886
3220
  }
3221
+ function isQualifiedJobName(value) {
3222
+ return /^[^/]+\/[^/]+\/[^/]+$/.test(value);
3223
+ }
2887
3224
  async function queryJournalEntriesViaDisplayJournal(conn, options) {
2888
- const sampleSize = Math.min(options.limit * 5, 5000);
2889
- const rows = await conn.runSQL(`select * from table(qsys2.display_journal(journal_name => ${Tools.sqlString(options.journal)}, journal_library => ${Tools.sqlString(options.journalLibrary)})) fetch first ${sampleSize} rows only`);
3225
+ if (options.objectLibrary && !options.objectName) {
3226
+ // A library on its own is not a DISPLAY_JOURNAL filter; the outfile path
3227
+ // filters it in SQL.
3228
+ throw new Error("objectLibrary without objectName is handled by the DSPJRN outfile query");
3229
+ }
3230
+ const params = [
3231
+ `journal_library => ${Tools.sqlString(options.journalLibrary)}`,
3232
+ `journal_name => ${Tools.sqlString(options.journal)}`
3233
+ ];
3234
+ const where = [];
3235
+ const since = options.sinceTimestamp ? sqlTimestampExpression(options.sinceTimestamp) : undefined;
3236
+ if (typeof options.sinceSequence === "number" || since) {
3237
+ // Search the whole receiver chain, not only the attached receiver, when a
3238
+ // starting point is given.
3239
+ params.push(`starting_receiver_name => '*CURCHAIN'`);
3240
+ }
3241
+ if (typeof options.sinceSequence === "number") {
3242
+ params.push(`starting_sequence => ${Math.max(0, Math.floor(options.sinceSequence))}`);
3243
+ where.push(`sequence_number >= ${Math.max(0, Math.floor(options.sinceSequence))}`);
3244
+ if (since)
3245
+ where.push(`entry_timestamp >= ${since}`);
3246
+ }
3247
+ else if (since) {
3248
+ params.push(`starting_timestamp => ${since}`);
3249
+ }
3250
+ if (options.journalCode)
3251
+ params.push(`journal_codes => ${Tools.sqlString(options.journalCode)}`);
3252
+ if (options.entryType)
3253
+ params.push(`journal_entry_types => ${Tools.sqlString(options.entryType)}`);
3254
+ if (options.objectName) {
3255
+ params.push(`object_library => ${Tools.sqlString(options.objectLibrary || "*LIBL")}`);
3256
+ params.push(`object_name => ${Tools.sqlString(options.objectName)}`);
3257
+ const objectType = options.objectType || "*FILE";
3258
+ params.push(`object_objtype => ${Tools.sqlString(objectType)}`);
3259
+ if (objectType === "*FILE")
3260
+ params.push(`object_member => '*ALL'`);
3261
+ }
3262
+ if (options.userName)
3263
+ params.push(`user => ${Tools.sqlString(options.userName)}`);
3264
+ if (options.programName)
3265
+ params.push(`program => ${Tools.sqlString(options.programName)}`);
3266
+ if (options.jobName) {
3267
+ if (isQualifiedJobName(options.jobName))
3268
+ params.push(`job => ${Tools.sqlString(options.jobName)}`);
3269
+ else
3270
+ where.push(`job_name = ${Tools.sqlString(options.jobName)}`);
3271
+ }
3272
+ const whereClause = where.length > 0 ? ` where ${where.join(" and ")}` : "";
3273
+ const rows = await conn.runSQL(`select * from table(qsys2.display_journal(${params.join(", ")}))${whereClause} ` +
3274
+ `order by sequence_number desc fetch first ${options.limit} rows only`);
2890
3275
  return rows;
2891
3276
  }
3277
+ // DSPJRN writes to QTEMP, which belongs to one job. The CALL QCMDEXC and the
3278
+ // SELECT are sent in the same QZDFMDB2 request so they run in the same job.
2892
3279
  async function queryJournalEntriesViaOutfile(conn, options) {
2893
- const outfile = `MCPJ${String(Math.floor(Math.random() * 90000) + 10000)}`;
2894
- const fileFilter = options.objectLibrary && options.objectName
2895
- ? ` FILE((${options.objectLibrary}/${options.objectName}))`
2896
- : "";
2897
- const command = `DSPJRN JRN(${options.journalLibrary}/${options.journal}) OUTPUT(*OUTFILE) OUTFILE(QTEMP/${outfile})${fileFilter}`;
2898
- const result = await runClCommand(conn, command, "ile", undefined, 45000);
2899
- if (result.code !== 0) {
2900
- throw new Error(result.stderr || "Unable to query journal entries");
2901
- }
2902
- const rows = await conn.runSQL(`select * from QTEMP.${outfile}`);
3280
+ const outfile = `MCPJ${String(Math.floor(Math.random() * 900000) + 100000)}`;
3281
+ const rangeOption = typeof options.sinceSequence === "number" || options.sinceTimestamp ? " RCVRNG(*CURCHAIN)" : "";
3282
+ const command = `DSPJRN JRN(${options.journalLibrary}/${options.journal})${rangeOption} ` +
3283
+ `OUTPUT(*OUTFILE) OUTFILFMT(*TYPE5) OUTFILE(QTEMP/${outfile})`;
3284
+ const where = [];
3285
+ if (options.objectLibrary)
3286
+ where.push(`JOLIB = ${Tools.sqlString(options.objectLibrary)}`);
3287
+ if (options.objectName)
3288
+ where.push(`JOOBJ = ${Tools.sqlString(options.objectName)}`);
3289
+ if (options.journalCode)
3290
+ where.push(`JOCODE = ${Tools.sqlString(options.journalCode)}`);
3291
+ if (options.entryType)
3292
+ where.push(`JOENTT = ${Tools.sqlString(options.entryType)}`);
3293
+ if (options.userName)
3294
+ where.push(`JOUSPF = ${Tools.sqlString(options.userName)}`);
3295
+ if (options.programName)
3296
+ where.push(`JOPGM = ${Tools.sqlString(options.programName)}`);
3297
+ if (options.jobName) {
3298
+ if (isQualifiedJobName(options.jobName)) {
3299
+ const [number, user, name] = options.jobName.split("/");
3300
+ where.push(`JONBR = ${Tools.sqlString(number)}`, `JOUSER = ${Tools.sqlString(user)}`, `JOJOB = ${Tools.sqlString(name)}`);
3301
+ }
3302
+ else {
3303
+ where.push(`JOJOB = ${Tools.sqlString(options.jobName)}`);
3304
+ }
3305
+ }
3306
+ if (typeof options.sinceSequence === "number")
3307
+ where.push(`JOSEQN >= ${Math.max(0, Math.floor(options.sinceSequence))}`);
3308
+ if (options.sinceTimestamp)
3309
+ where.push(`JOTSTP >= ${sqlTimestampExpression(options.sinceTimestamp)}`);
3310
+ const whereClause = where.length > 0 ? ` where ${where.join(" and ")}` : "";
3311
+ const rows = await conn.runSQL([
3312
+ `CALL QSYS2.QCMDEXC(${Tools.sqlString(command)});`,
3313
+ `select * from QTEMP.${outfile}${whereClause} order by JOSEQN desc fetch first ${options.limit} rows only;`
3314
+ ].join("\n"));
2903
3315
  return rows;
2904
3316
  }
2905
3317
  function normalizeJournalEntry(row) {
@@ -2911,7 +3323,7 @@ function normalizeJournalEntry(row) {
2911
3323
  const objectName = toNullableString(pickFirst(row, ["OBJECT_NAME", "JOOBJ", "OBJNAME"]));
2912
3324
  const objectType = toNullableString(pickFirst(row, ["OBJECT_TYPE", "JOOBJT", "OBJTYPE"]));
2913
3325
  const jobName = toNullableString(pickFirst(row, ["JOB_NAME", "JOJOB", "JOB"]));
2914
- const userName = toNullableString(pickFirst(row, ["USER_NAME", "JOUSER", "USER"]));
3326
+ const userName = toNullableString(pickFirst(row, ["USER_NAME", "CURRENT_USER", "JOUSPF", "JOUSER", "USER"]));
2915
3327
  const programName = toNullableString(pickFirst(row, ["PROGRAM_NAME", "JOPGM", "PROGRAM"]));
2916
3328
  return {
2917
3329
  source: "journal",
@@ -2927,143 +3339,355 @@ function normalizeJournalEntry(row) {
2927
3339
  programName
2928
3340
  };
2929
3341
  }
2930
- function pickFirst(row, keys) {
3342
+ export function pickFirst(row, keys) {
2931
3343
  for (const key of keys) {
2932
3344
  if (typeof row[key] !== "undefined" && row[key] !== null)
2933
3345
  return row[key];
2934
3346
  }
2935
3347
  return undefined;
2936
3348
  }
2937
- function toNullableNumber(value) {
3349
+ export function toNullableNumber(value) {
2938
3350
  const n = Number(value);
2939
3351
  return Number.isFinite(n) ? n : null;
2940
3352
  }
2941
- function toNullableString(value) {
3353
+ export function toNullableString(value) {
2942
3354
  if (typeof value === "undefined" || value === null)
2943
3355
  return null;
2944
3356
  const text = String(value).trim();
2945
3357
  return text.length > 0 ? text : null;
2946
3358
  }
2947
- function ensureTn5250Session(connectionName) {
2948
- const existing = tn5250Sessions.get(connectionName);
2949
- if (existing) {
2950
- touchTn5250Session(existing);
2951
- return existing;
2952
- }
2953
- const now = new Date().toISOString();
2954
- const session = {
2955
- connectionName,
2956
- createdAt: Date.now(),
2957
- screen: {
2958
- sessionId: `tn5250:${connectionName}`,
2959
- connectionName,
2960
- title: "MCP TN5250 Command Session",
2961
- status: "ready",
2962
- textLines: [
2963
- `Connected to ${connectionName}.`,
2964
- "Set field 'command' (or fieldId '1') and send Enter.",
2965
- "Supported keys: Enter, Tab, F3, F12, Clear."
2966
- ],
2967
- fields: [
2968
- {
2969
- id: "command",
2970
- label: "Command",
2971
- value: "",
2972
- row: 20,
2973
- col: 2,
2974
- length: 512,
2975
- protected: false
2976
- }
2977
- ],
2978
- cursorFieldId: "command",
2979
- lastKeys: [],
2980
- commandHistory: [],
2981
- updatedAt: now
3359
+ function hasMemberListExtensions(args) {
3360
+ return args?.responseFormat === "page" || [
3361
+ "member", "memberPrefix", "memberContains", "sourceTypes", "changedSince", "limit", "cursor"
3362
+ ].some(key => typeof args?.[key] !== "undefined");
3363
+ }
3364
+ function hasStructuredMemberReadOptions(args) {
3365
+ return args?.responseFormat === "structured" || [
3366
+ "startLine", "endLine", "ranges", "before", "after", "metadataOnly",
3367
+ "expectedVersion", "expectedSha256", "forceFresh"
3368
+ ].some(key => typeof args?.[key] !== "undefined");
3369
+ }
3370
+ function memberVersionConflict(snapshot, args) {
3371
+ const versionMismatch = args?.expectedVersion && args.expectedVersion !== snapshot.version;
3372
+ const hashMismatch = args?.expectedSha256 && String(args.expectedSha256).toLowerCase() !== snapshot.sha256.toLowerCase();
3373
+ if (!versionMismatch && !hashMismatch)
3374
+ return undefined;
3375
+ const { identity } = snapshot;
3376
+ return toolError(`Version conflict: ${identity.library}/${identity.sourceFile}(${identity.member}) is now version ${snapshot.version}. ` +
3377
+ "Nothing was modified. Re-read the member and retry with the current version.", {
3378
+ code: "VERSION_CONFLICT",
3379
+ status: "version-conflict",
3380
+ identity,
3381
+ expectedVersion: args?.expectedVersion,
3382
+ expectedSha256: args?.expectedSha256,
3383
+ actualVersion: snapshot.version,
3384
+ actualSha256: snapshot.sha256,
3385
+ currentVersion: snapshot.version,
3386
+ currentSha256: snapshot.sha256,
3387
+ observedAt: snapshot.observedAt,
3388
+ modified: false
3389
+ });
3390
+ }
3391
+ const DEFAULT_SOURCE_READ_MAX_BYTES = 2_000_000;
3392
+ const DEFAULT_SOURCE_READ_MAX_LINES = 50_000;
3393
+ const MAX_RANGE_LINES = 2_000;
3394
+ function sourceReadLimits(conn) {
3395
+ const config = typeof conn?.getConfig === "function" ? conn.getConfig() || {} : {};
3396
+ const read = (value, fallback) => {
3397
+ const parsed = Number(value);
3398
+ if (value === undefined || value === null || value === "" || !Number.isFinite(parsed) || parsed < 0)
3399
+ return fallback;
3400
+ return Math.floor(parsed);
3401
+ };
3402
+ return {
3403
+ maxBytes: read(config.sourceReadMaxBytes, DEFAULT_SOURCE_READ_MAX_BYTES),
3404
+ maxLines: read(config.sourceReadMaxLines, DEFAULT_SOURCE_READ_MAX_LINES)
3405
+ };
3406
+ }
3407
+ function isFullMemberRead(args) {
3408
+ return !args?.metadataOnly &&
3409
+ typeof args?.startLine === "undefined" &&
3410
+ typeof args?.endLine === "undefined" &&
3411
+ !(Array.isArray(args?.ranges) && args.ranges.length > 0);
3412
+ }
3413
+ function exceedsSourceLimits(content, lineCount, limits) {
3414
+ const bytes = Buffer.byteLength(content, "utf8");
3415
+ const lines = typeof lineCount === "number" ? lineCount : (content === "" ? 0 : content.split("\n").length);
3416
+ const tooLarge = (limits.maxBytes > 0 && bytes > limits.maxBytes) || (limits.maxLines > 0 && lines > limits.maxLines);
3417
+ return tooLarge ? { bytes, lineCount: lines } : undefined;
3418
+ }
3419
+ function sourceTooLarge(content, lineCount, limits, args) {
3420
+ const size = exceedsSourceLimits(content, lineCount, limits);
3421
+ return size ? sourceTooLargeError(args, size, limits) : undefined;
3422
+ }
3423
+ function sourceTooLargeError(args, size, limits, extra = {}) {
3424
+ const name = `${String(args?.library || "").toUpperCase()}/${String(args?.sourceFile || "").toUpperCase()}(${String(args?.member || "").toUpperCase()})`;
3425
+ return toolError(`${name} is too large for a full read (${size.lineCount ?? "?"} lines` +
3426
+ `${typeof size.bytes === "number" ? `, ${size.bytes} bytes` : ""}; limits ${limits.maxLines} lines, ${limits.maxBytes} bytes). ` +
3427
+ "Read it in pieces with startLine/endLine or ranges, locate code with ibmi.search.members or ibmi.source.inspect " +
3428
+ "(search, outline), or use metadataOnly: true for its size and version.", { code: "SOURCE_TOO_LARGE", ...size, maxLines: limits.maxLines, maxBytes: limits.maxBytes, ...extra });
3429
+ }
3430
+ function formatMemberReadResult(snapshot, args, limits) {
3431
+ if (isFullMemberRead(args)) {
3432
+ const size = exceedsSourceLimits(snapshot.content, snapshot.lines.length, limits);
3433
+ if (size) {
3434
+ return sourceTooLargeError(args, size, limits, {
3435
+ identity: snapshot.identity,
3436
+ metadata: snapshot.metadata,
3437
+ version: snapshot.version,
3438
+ sha256: snapshot.sha256,
3439
+ observedAt: snapshot.observedAt
3440
+ });
2982
3441
  }
3442
+ }
3443
+ return json(formatMemberRead(snapshot, args, limits));
3444
+ }
3445
+ function memberMetadataEnvelope(args, metadata, observedAt) {
3446
+ return {
3447
+ status: "found",
3448
+ identity: {
3449
+ library: String(args.library).toUpperCase(),
3450
+ sourceFile: String(args.sourceFile).toUpperCase(),
3451
+ member: String(args.member).toUpperCase()
3452
+ },
3453
+ metadata,
3454
+ ...(metadata?.lastChanged ? { changeMarker: metadata.lastChanged } : {}),
3455
+ observedAt,
3456
+ contentLoaded: false,
3457
+ cacheStatus: "bypass",
3458
+ warnings: [
3459
+ "Content was not downloaded, so sha256 and version are omitted. Pass expectedVersion/expectedSha256 or read without metadataOnly to compute them."
3460
+ ]
2983
3461
  };
2984
- tn5250Sessions.set(connectionName, session);
2985
- return session;
2986
3462
  }
2987
- function requireTn5250Session(ctx, args) {
2988
- const name = resolveTn5250SessionName(ctx, args);
2989
- if (!name)
2990
- throw new Error("connectionName is required (no active session)");
2991
- const session = tn5250Sessions.get(name);
2992
- if (!session) {
2993
- throw new Error(`TN5250 session for ${name} not found. Call ibmi.tn5250.connect first.`);
3463
+ async function describeWrittenMember(conn, args, previous) {
3464
+ const identity = {
3465
+ library: String(args.library).toUpperCase(),
3466
+ sourceFile: String(args.sourceFile).toUpperCase(),
3467
+ member: String(args.member).toUpperCase()
3468
+ };
3469
+ const base = {
3470
+ status: "written",
3471
+ identity,
3472
+ modified: true,
3473
+ ...(previous ? { previousVersion: previous.version, previousSha256: previous.sha256 } : {})
3474
+ };
3475
+ try {
3476
+ // Read back what the host stored (record length and CCSID conversion may
3477
+ // differ from what was sent) so the caller can chain the next edit.
3478
+ const snapshot = await conn.content.getMemberSnapshot(args.library, args.sourceFile, args.member, true);
3479
+ return {
3480
+ ...base,
3481
+ version: snapshot.version,
3482
+ sha256: snapshot.sha256,
3483
+ lineCount: snapshot.lines.length,
3484
+ observedAt: snapshot.observedAt
3485
+ };
3486
+ }
3487
+ catch (error) {
3488
+ return { ...base, warnings: [`Write succeeded, but reading back the new version failed: ${error?.message || String(error)}`] };
2994
3489
  }
2995
- return session;
2996
3490
  }
2997
- function resolveTn5250SessionName(ctx, args) {
2998
- return args?.connectionName || ctx.activeName;
3491
+ function formatMemberRead(snapshot, args, limits) {
3492
+ const envelope = {
3493
+ status: "found",
3494
+ identity: snapshot.identity,
3495
+ metadata: snapshot.metadata,
3496
+ version: snapshot.version,
3497
+ sha256: snapshot.sha256,
3498
+ observedAt: snapshot.observedAt,
3499
+ ...(snapshot.validatedAt ? { validatedAt: snapshot.validatedAt } : {}),
3500
+ complete: snapshot.complete,
3501
+ transport: snapshot.transport,
3502
+ cacheStatus: snapshot.cacheStatus,
3503
+ lineCount: snapshot.lines.length,
3504
+ warnings: snapshot.warnings
3505
+ };
3506
+ if (args?.metadataOnly)
3507
+ return envelope;
3508
+ const selection = normalizeLineRanges(args, snapshot.lines.length);
3509
+ if (!selection.requested) {
3510
+ const size = limits ? exceedsSourceLimits(snapshot.content, snapshot.lines.length, limits) : undefined;
3511
+ if (size) {
3512
+ // Composite inspect read_ranges without ranges: never flood the caller.
3513
+ return {
3514
+ ...envelope,
3515
+ contentOmitted: true,
3516
+ ...size,
3517
+ warnings: [...snapshot.warnings, "Member exceeds the full-read size limit; request ranges instead."]
3518
+ };
3519
+ }
3520
+ return { ...envelope, content: snapshot.content };
3521
+ }
3522
+ return {
3523
+ ...envelope,
3524
+ warnings: [...snapshot.warnings, ...selection.warnings],
3525
+ ...(selection.truncated ? { truncated: true, nextLine: selection.nextLine } : {}),
3526
+ ranges: selection.ranges.map(range => ({
3527
+ start: range.start,
3528
+ end: range.end,
3529
+ lines: snapshot.lines.slice(range.start - 1, range.end).map((content, index) => ({
3530
+ number: range.start + index,
3531
+ content
3532
+ }))
3533
+ }))
3534
+ };
2999
3535
  }
3000
- function findTn5250Field(session, fieldId) {
3001
- const normalized = String(fieldId || "").trim().toLowerCase();
3002
- if (!normalized)
3003
- return undefined;
3004
- const aliases = normalized === "1" ? ["command", "1"] : [normalized];
3005
- return session.screen.fields.find(field => {
3006
- const id = field.id.toLowerCase();
3007
- return aliases.includes(id);
3008
- });
3536
+ function normalizeLineRanges(args, lineCount) {
3537
+ const before = clampInteger(args?.before, 0, 200, 0);
3538
+ const after = clampInteger(args?.after, 0, 200, 0);
3539
+ const requested = [];
3540
+ if (typeof args?.startLine !== "undefined" || typeof args?.endLine !== "undefined") {
3541
+ const start = typeof args.startLine !== "undefined" ? Number(args.startLine) : 1;
3542
+ // startLine without endLine reads to the end of the member.
3543
+ const end = typeof args.endLine !== "undefined" ? Number(args.endLine) : Math.max(start, lineCount);
3544
+ requested.push({ start, end });
3545
+ }
3546
+ if (Array.isArray(args?.ranges)) {
3547
+ for (const range of args.ranges.slice(0, 8))
3548
+ requested.push({ start: Number(range.start), end: Number(range.end) });
3549
+ }
3550
+ const warnings = [];
3551
+ if (requested.length === 0)
3552
+ return { requested: false, ranges: [], warnings, truncated: false };
3553
+ const normalized = [];
3554
+ for (const range of requested) {
3555
+ if (!Number.isFinite(range.start) || !Number.isFinite(range.end) || range.start < 1 || range.end < range.start) {
3556
+ throw new Error(`Invalid line range ${range.start}-${range.end}`);
3557
+ }
3558
+ const start = Math.max(1, Math.floor(range.start) - before);
3559
+ const end = Math.min(lineCount, Math.floor(range.end) + after);
3560
+ if (start > lineCount || end < start) {
3561
+ warnings.push(`Lines ${range.start}-${range.end} are past the end of the member (${lineCount} lines).`);
3562
+ continue;
3563
+ }
3564
+ normalized.push({ start, end });
3565
+ }
3566
+ normalized.sort((a, b) => a.start - b.start || a.end - b.end);
3567
+ const merged = [];
3568
+ for (const range of normalized) {
3569
+ const previous = merged.at(-1);
3570
+ if (previous && range.start <= previous.end + 1)
3571
+ previous.end = Math.max(previous.end, range.end);
3572
+ else
3573
+ merged.push({ ...range });
3574
+ }
3575
+ // Cap the response instead of failing; nextLine says where to continue.
3576
+ const limited = [];
3577
+ let remaining = MAX_RANGE_LINES;
3578
+ let nextLine;
3579
+ for (const range of merged) {
3580
+ if (remaining <= 0) {
3581
+ nextLine ??= range.start;
3582
+ break;
3583
+ }
3584
+ const length = range.end - range.start + 1;
3585
+ if (length > remaining) {
3586
+ limited.push({ start: range.start, end: range.start + remaining - 1 });
3587
+ nextLine = range.start + remaining;
3588
+ remaining = 0;
3589
+ continue;
3590
+ }
3591
+ limited.push(range);
3592
+ remaining -= length;
3593
+ }
3594
+ if (nextLine !== undefined) {
3595
+ warnings.push(`Response limited to ${MAX_RANGE_LINES} lines; continue from line ${nextLine}.`);
3596
+ }
3597
+ return { requested: true, ranges: limited, warnings, truncated: nextLine !== undefined, nextLine };
3009
3598
  }
3010
- function parseTn5250Keys(value) {
3011
- const raw = String(value || "").trim();
3012
- if (!raw)
3013
- throw new Error("keys is required");
3014
- return raw
3015
- .split(/[,\s+]+/)
3016
- .map(part => part.trim().toUpperCase())
3017
- .filter(Boolean);
3018
- }
3019
- function touchTn5250Session(session) {
3020
- session.screen.updatedAt = new Date().toISOString();
3021
- tn5250Sessions.set(session.connectionName, session);
3022
- }
3023
- function appendTn5250Message(session, message) {
3024
- session.screen.textLines.unshift(message);
3025
- if (session.screen.textLines.length > TN5250_MAX_LINES) {
3026
- session.screen.textLines = session.screen.textLines.slice(0, TN5250_MAX_LINES);
3027
- }
3028
- touchTn5250Session(session);
3029
- }
3030
- function resetTn5250Screen(session, message) {
3031
- const commandField = findTn5250Field(session, "command");
3032
- if (commandField)
3033
- commandField.value = "";
3034
- session.screen.textLines = [message, `Connected to ${session.connectionName}.`];
3035
- session.screen.cursorFieldId = "command";
3036
- session.screen.lastKeys = [];
3037
- touchTn5250Session(session);
3038
- }
3039
- function rotateTn5250Cursor(session) {
3040
- const fields = session.screen.fields;
3041
- if (fields.length === 0)
3599
+ function normalizeSearchTerms(args) {
3600
+ const terms = [
3601
+ ...(typeof args?.term === "string" ? [args.term] : []),
3602
+ ...(Array.isArray(args?.terms) ? args.terms : [])
3603
+ ].map(value => String(value)).filter(value => value.length > 0);
3604
+ const unique = [...new Set(terms)];
3605
+ if (unique.length < 1 || unique.length > 20)
3606
+ throw new Error("Provide term or between 1 and 20 terms");
3607
+ return unique;
3608
+ }
3609
+ function rankMemberCandidates(candidates, preferredFiles) {
3610
+ if (!preferredFiles?.length)
3042
3611
  return;
3043
- const idx = fields.findIndex(field => field.id === session.screen.cursorFieldId);
3044
- const next = idx >= 0 ? (idx + 1) % fields.length : 0;
3045
- session.screen.cursorFieldId = fields[next].id;
3046
- touchTn5250Session(session);
3047
- }
3048
- async function waitForTn5250Text(session, text, timeoutMs) {
3049
- const deadline = Date.now() + timeoutMs;
3050
- const needle = String(text).toLowerCase();
3051
- while (Date.now() <= deadline) {
3052
- const haystack = renderTn5250Screen(session.screen).toLowerCase();
3053
- if (haystack.includes(needle))
3054
- return true;
3055
- await new Promise(resolve => setTimeout(resolve, 200));
3056
- }
3057
- return false;
3058
- }
3059
- function renderTn5250Screen(screen) {
3060
- const lines = [];
3061
- lines.push(`${screen.title} (${screen.status})`);
3062
- lines.push(...screen.textLines);
3063
- for (const field of screen.fields) {
3064
- lines.push(`${field.label}[${field.id}]=${field.value}`);
3612
+ const ranks = new Map(preferredFiles.map((value, index) => [String(value).toUpperCase(), index]));
3613
+ candidates.sort((a, b) => (ranks.get(String(a.file).toUpperCase()) ?? Number.MAX_SAFE_INTEGER) -
3614
+ (ranks.get(String(b.file).toUpperCase()) ?? Number.MAX_SAFE_INTEGER) || String(a.file).localeCompare(String(b.file)));
3615
+ }
3616
+ function inspectMemberOperation(snapshot, operation, limits) {
3617
+ switch (operation.operation) {
3618
+ case "metadata":
3619
+ return { operation: "metadata", metadata: snapshot.metadata };
3620
+ case "read_ranges":
3621
+ return { operation: "read_ranges", ...formatMemberRead(snapshot, { ranges: operation.ranges, before: operation.before, after: operation.after }, limits) };
3622
+ case "search":
3623
+ return { operation: "search", ...searchSnapshot(snapshot, operation) };
3624
+ case "outline":
3625
+ return { operation: "outline", language: sourceLanguageOf(snapshot), symbols: outlineSnapshot(snapshot) };
3626
+ case "dependencies":
3627
+ return { operation: "dependencies", language: sourceLanguageOf(snapshot), dependencies: dependenciesFromSnapshot(snapshot) };
3628
+ default:
3629
+ throw new Error(`Unsupported inspection operation: ${operation.operation}`);
3630
+ }
3631
+ }
3632
+ function searchSnapshot(snapshot, operation) {
3633
+ const terms = normalizeSearchTerms({ term: operation.query, terms: operation.terms });
3634
+ const before = clampInteger(operation.before, 0, 50, 0);
3635
+ const after = clampInteger(operation.after, 0, 50, 0);
3636
+ const maxMatches = clampInteger(operation.maxMatches, 1, 1_000, 100);
3637
+ const lowered = terms.map(term => term.toLowerCase());
3638
+ const matches = [];
3639
+ for (let index = 0; index < snapshot.lines.length && matches.length < maxMatches; index++) {
3640
+ const line = snapshot.lines[index];
3641
+ const matchedTerms = terms.filter((_term, termIndex) => line.toLowerCase().includes(lowered[termIndex]));
3642
+ if (matchedTerms.length === 0)
3643
+ continue;
3644
+ const start = Math.max(0, index - before);
3645
+ const end = Math.min(snapshot.lines.length - 1, index + after);
3646
+ matches.push({
3647
+ line: index + 1,
3648
+ content: line,
3649
+ matchedTerms,
3650
+ context: snapshot.lines.slice(start, end + 1).map((content, contextIndex) => ({ number: start + contextIndex + 1, content }))
3651
+ });
3065
3652
  }
3066
- return lines.join("\n");
3653
+ return { terms, matches, returned: matches.length, truncated: matches.length >= maxMatches };
3654
+ }
3655
+ function outlineSnapshot(snapshot) {
3656
+ return inspectSource(snapshot.lines, snapshot.metadata?.extension).symbols;
3657
+ }
3658
+ function dependenciesFromSnapshot(snapshot) {
3659
+ return inspectSource(snapshot.lines, snapshot.metadata?.extension).dependencies;
3660
+ }
3661
+ function sourceLanguageOf(snapshot) {
3662
+ return detectSourceLanguage(snapshot.metadata?.extension, snapshot.lines);
3663
+ }
3664
+ async function readSystemFacts(conn) {
3665
+ if (conn.__mcpSystemFacts)
3666
+ return conn.__mcpSystemFacts;
3667
+ try {
3668
+ const rows = await conn.runSQL("select OS_VERSION, OS_RELEASE, HOST_NAME, current_user as EFFECTIVE_USER " +
3669
+ "from SYSIBMADM.ENV_SYS_INFO fetch first 1 row only");
3670
+ const row = rows[0];
3671
+ const version = String(row?.OS_VERSION || "").replace(/\D/g, "");
3672
+ const release = String(row?.OS_RELEASE || "").replace(/\D/g, "");
3673
+ const facts = {
3674
+ ...(version && release ? { release: `${Number(version)}.${Number(release)}` } : {}),
3675
+ ...(row?.EFFECTIVE_USER ? { effectiveUser: String(row.EFFECTIVE_USER).trim() } : {}),
3676
+ ...(row?.HOST_NAME ? { systemName: String(row.HOST_NAME).trim() } : {})
3677
+ };
3678
+ conn.__mcpSystemFacts = facts;
3679
+ return facts;
3680
+ }
3681
+ catch {
3682
+ // Older IBM i releases may not expose SYSIBMADM.ENV_SYS_INFO.
3683
+ }
3684
+ return {};
3685
+ }
3686
+ export function clampInteger(value, minimum, maximum, fallback) {
3687
+ const parsed = Number(value);
3688
+ if (!Number.isFinite(parsed))
3689
+ return fallback;
3690
+ return Math.max(minimum, Math.min(maximum, Math.floor(parsed)));
3067
3691
  }
3068
3692
  function rejectCredentialArgs(args, toolName) {
3069
3693
  if (!args || typeof args !== "object")
@@ -3075,19 +3699,19 @@ function rejectCredentialArgs(args, toolName) {
3075
3699
  throw new Error(`${toolName} does not accept storePassword. Credentials must be managed via local control-plane UI.`);
3076
3700
  }
3077
3701
  }
3078
- function result(text) {
3702
+ export function result(text) {
3079
3703
  return {
3080
3704
  content: [{ type: "text", text }],
3081
3705
  structuredContent: { ok: true, text }
3082
3706
  };
3083
3707
  }
3084
- function json(obj) {
3708
+ export function json(obj) {
3085
3709
  return {
3086
3710
  content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
3087
3711
  structuredContent: { ok: true, data: obj }
3088
3712
  };
3089
3713
  }
3090
- function toolError(message, error) {
3714
+ export function toolError(message, error) {
3091
3715
  return {
3092
3716
  isError: true,
3093
3717
  content: [{ type: "text", text: message }],
@@ -3100,13 +3724,179 @@ function toolError(message, error) {
3100
3724
  }
3101
3725
  };
3102
3726
  }
3103
- function getConnectionName(ctx, args) {
3727
+ export function jsonError(message, obj) {
3728
+ return {
3729
+ isError: true,
3730
+ content: [{ type: "text", text: `${message}\n${JSON.stringify(obj, null, 2)}` }],
3731
+ structuredContent: {
3732
+ ok: false,
3733
+ data: obj,
3734
+ error: { message }
3735
+ }
3736
+ };
3737
+ }
3738
+ // Returns the payload as normal JSON when the command worked and as a tool
3739
+ // error (isError) when it did not, so agents can't mistake a failure for "OK".
3740
+ export function commandOutcome(payload) {
3741
+ if (payload.ok)
3742
+ return json(payload);
3743
+ const detail = String(payload.stderr || "").trim().split(/\r?\n/)[0] || "";
3744
+ const label = payload.command ? `Command failed: ${payload.command}` : "Command failed";
3745
+ return jsonError(detail ? `${label} (${detail})` : label, payload);
3746
+ }
3747
+ export function requireCommandSuccess(res, label) {
3748
+ if (!res || res.code !== 0) {
3749
+ const detail = String(res?.stderr || res?.stdout || "").trim();
3750
+ throw new Error(`${label} failed (exit ${res?.code ?? "unknown"})${detail ? `: ${detail}` : ""}`);
3751
+ }
3752
+ return res;
3753
+ }
3754
+ const IFS_READ_DEFAULT_MAX_BYTES = 1024 * 1024;
3755
+ const IFS_READ_HARD_MAX_BYTES = 16 * 1024 * 1024;
3756
+ // Size of an IFS stream file from `ls -ldL`, or undefined when it can't be
3757
+ // determined (the download is then checked after the fact).
3758
+ export async function statIfsFileSize(conn, remotePath) {
3759
+ try {
3760
+ const res = await conn.sendCommand({ command: `ls -ldL ${Tools.escapePath(remotePath)}` });
3761
+ if (!res || res.code !== 0)
3762
+ return undefined;
3763
+ const parts = String(res.stdout || "").trim().split(/\s+/);
3764
+ if (parts.length < 5 || parts[0].startsWith("d"))
3765
+ return undefined;
3766
+ const size = Number(parts[4]);
3767
+ return Number.isFinite(size) && size >= 0 ? size : undefined;
3768
+ }
3769
+ catch {
3770
+ return undefined;
3771
+ }
3772
+ }
3773
+ // Decodes data as UTF-8 text, or returns undefined when it is binary (contains
3774
+ // NUL bytes or is not valid UTF-8).
3775
+ export function decodeUtf8Text(data) {
3776
+ if (data.includes(0))
3777
+ return undefined;
3778
+ try {
3779
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
3780
+ }
3781
+ catch {
3782
+ return undefined;
3783
+ }
3784
+ }
3785
+ const SQL_TRAILING_ROW_CLAUSE = new RegExp("(?:fetch\\s+(?:first|next)\\s+(?:\\S+\\s+)?rows?\\s+only" +
3786
+ "|for\\s+(?:read|fetch)\\s+only" +
3787
+ "|for\\s+update(?:\\s+of\\s+[\\w\\s,\"]+)?" +
3788
+ "|optimize\\s+for\\s+\\S+\\s+rows?" +
3789
+ "|with\\s+(?:nc|ur|cs|rs|rr)(?:\\s+keep\\s+(?:update|exclusive|share)\\s+locks)?" +
3790
+ "|limit\\s+\\S+(?:\\s+offset\\s+\\S+)?" +
3791
+ "|offset\\s+\\S+\\s+rows?" +
3792
+ "|skip\\s+locked\\s+data|use\\s+currently\\s+committed|wait\\s+for\\s+outcome)$");
3793
+ // Adds FETCH FIRST n ROWS ONLY to a SELECT/WITH query so the row cap is applied
3794
+ // on the IBM i. Queries that already end with a row-limiting, locking or
3795
+ // isolation clause are left unchanged (the client-side slice still applies).
3796
+ function applySqlRowCap(sql, rowLimit) {
3797
+ const body = sql.replace(/[\s;]+$/, "");
3798
+ const normalized = body
3799
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
3800
+ .split("\n")
3801
+ .map(line => line.replace(/--.*$/, ""))
3802
+ .join(" ")
3803
+ .replace(/\s+/g, " ")
3804
+ .trim()
3805
+ .toLowerCase();
3806
+ if (!/^(?:select|with)\b/.test(normalized))
3807
+ return { sql, applied: false };
3808
+ if (SQL_TRAILING_ROW_CLAUSE.test(normalized))
3809
+ return { sql, applied: false };
3810
+ return { sql: `${body}\nfetch first ${Math.max(1, Math.floor(rowLimit))} rows only`, applied: true };
3811
+ }
3812
+ function requireSpoolJobFilter(value) {
3813
+ const raw = String(value || "").trim().toUpperCase();
3814
+ return raw.includes("/") ? requireJobName(raw) : requireIbmObjectName(raw, "jobName");
3815
+ }
3816
+ export function requireObjectTypeValue(value, label) {
3817
+ const objectType = String(value || "").trim().toUpperCase();
3818
+ if (!/^\*[A-Z]{2,9}$/.test(objectType))
3819
+ throw new Error(`Invalid ${label}: ${value}`);
3820
+ return objectType;
3821
+ }
3822
+ // SNDMSG only sends *INFO and *INQ messages; *COMP and *DIAG need SNDPGMMSG.
3823
+ function buildSendMessageCommand(message, library, messageQueue, messageType) {
3824
+ const queue = `${library}/${messageQueue}`;
3825
+ switch (messageType) {
3826
+ case "*INQ":
3827
+ return `SNDMSG MSG(${quoteClString(message)}) TOMSGQ(${queue}) MSGTYPE(*INQ) RPYMSGQ(${queue})`;
3828
+ case "*COMP":
3829
+ case "*DIAG":
3830
+ return `SNDPGMMSG MSG(${quoteClString(message)}) TOMSGQ(${queue}) MSGTYPE(${messageType})`;
3831
+ default:
3832
+ return `SNDMSG MSG(${quoteClString(message)}) TOMSGQ(${queue}) MSGTYPE(*INFO)`;
3833
+ }
3834
+ }
3835
+ // CALL to the Send Data Queue (QSNDDTAQ) API with explicitly typed parameters:
3836
+ // queue name CHAR(10), library CHAR(10), data length DEC(5,0), data.
3837
+ function buildQsnddtaqCall(library, queue, message) {
3838
+ if (/[^\x20-\x7E]/.test(message)) {
3839
+ throw new Error("QSYS2.SEND_DATA_QUEUE is not available and the QSNDDTAQ fallback only supports printable single-byte text.");
3840
+ }
3841
+ if (message.length > 5000) {
3842
+ throw new Error("QSYS2.SEND_DATA_QUEUE is not available and the QSNDDTAQ fallback is limited to 5000 characters.");
3843
+ }
3844
+ const length = message.length;
3845
+ return `CALL PGM(QSYS/QSNDDTAQ) PARM((${quoteClString(queue)} (*CHAR 10)) (${quoteClString(library)} (*CHAR 10)) ` +
3846
+ `(${length} (*DEC 5 0)) (${quoteClString(message)} (*CHAR ${length})))`;
3847
+ }
3848
+ // Formats CHGDTAARA for the data area's type: *DEC takes an unquoted number and
3849
+ // *LGL takes '0' or '1' (neither allows a substring); *CHAR keeps the
3850
+ // substring form so only the given positions change.
3851
+ function buildChangeDataAreaCommand(library, dataArea, value, info, args) {
3852
+ const target = `${library}/${dataArea}`;
3853
+ const type = String(info?.type || "*CHAR").trim().toUpperCase();
3854
+ const substringRequested = args?.startPosition !== undefined || args?.length !== undefined;
3855
+ if (type === "*DEC") {
3856
+ if (substringRequested)
3857
+ throw new Error("startPosition and length are only valid for *CHAR data areas");
3858
+ const text = value.trim();
3859
+ const match = text.match(/^([+-]?)(\d*)(?:\.(\d*))?$/);
3860
+ if (!match || (match[2] === "" && !match[3]))
3861
+ throw new Error(`Value for *DEC data area must be numeric: ${value}`);
3862
+ const intDigits = match[2].replace(/^0+(?=\d)/, "");
3863
+ const fraction = match[3] || "";
3864
+ const decimals = typeof info?.decimalPositions === "number" ? info.decimalPositions : undefined;
3865
+ const totalDigits = typeof info?.length === "number" ? info.length : undefined;
3866
+ if (decimals !== undefined && fraction.replace(/0+$/, "").length > decimals) {
3867
+ throw new Error(`Value has more than ${decimals} decimal positions`);
3868
+ }
3869
+ if (totalDigits !== undefined && decimals !== undefined && intDigits.replace(/^0+/, "").length > totalDigits - decimals) {
3870
+ throw new Error(`Value does not fit a *DEC ${totalDigits} ${decimals} data area`);
3871
+ }
3872
+ const formatted = `${match[1] === "-" ? "-" : ""}${intDigits || "0"}${fraction ? `.${fraction}` : ""}`;
3873
+ return `CHGDTAARA DTAARA(${target}) VALUE(${formatted})`;
3874
+ }
3875
+ if (type === "*LGL") {
3876
+ if (substringRequested)
3877
+ throw new Error("startPosition and length are only valid for *CHAR data areas");
3878
+ const normalized = value.trim().toLowerCase();
3879
+ const logical = ["1", "true", "*on", "on", "yes"].includes(normalized)
3880
+ ? "1"
3881
+ : ["0", "false", "*off", "off", "no"].includes(normalized) ? "0" : undefined;
3882
+ if (!logical)
3883
+ throw new Error(`Value for *LGL data area must be 0 or 1: ${value}`);
3884
+ return `CHGDTAARA DTAARA(${target}) VALUE('${logical}')`;
3885
+ }
3886
+ const startPosition = clampNumber(args?.startPosition, 1, 1, 2000);
3887
+ const length = clampNumber(args?.length, Math.max(value.length, 1), 1, 2000);
3888
+ if (typeof info?.length === "number" && info.length > 0 && startPosition + length - 1 > info.length) {
3889
+ throw new Error(`Positions ${startPosition} to ${startPosition + length - 1} are outside the ${info.length}-character data area`);
3890
+ }
3891
+ return `CHGDTAARA DTAARA(${target} (${startPosition} ${length})) VALUE(${quoteClString(value)})`;
3892
+ }
3893
+ export function getConnectionName(ctx, args) {
3104
3894
  const name = args?.connectionName || ctx.activeName;
3105
3895
  if (!name)
3106
3896
  throw new Error("connectionName is required (no active connection)");
3107
3897
  return name;
3108
3898
  }
3109
- function requireQsysName(conn, value, label) {
3899
+ export function requireQsysName(conn, value, label) {
3110
3900
  const raw = String(value || "").trim().toUpperCase();
3111
3901
  if (!raw)
3112
3902
  throw new Error(`${label} is required`);
@@ -3151,7 +3941,7 @@ function requireMessageKey(value) {
3151
3941
  }
3152
3942
  throw new Error(`Invalid messageKey: ${value}`);
3153
3943
  }
3154
- async function updateConnectionSettings(ctx, name, update) {
3944
+ export async function updateConnectionSettings(ctx, name, update) {
3155
3945
  const conn = await ctx.store.getConnection(name);
3156
3946
  if (!conn)
3157
3947
  throw new Error(`Connection ${name} not found`);
@@ -3165,21 +3955,52 @@ async function updateConnectionSettings(ctx, name, update) {
3165
3955
  // No active session for this connection.
3166
3956
  }
3167
3957
  }
3168
- async function applyLibraryList(conn, libraryList, currentLibrary) {
3958
+ /**
3959
+ * applyToJob: puts the library list in effect now. A connected client applies
3960
+ * its configured list (which the caller has just updated) to the persistent
3961
+ * SQL job, or checks it for the per-call QZDFMDB2 and qsh jobs; libraries that
3962
+ * do not exist are left out and reported in `warning` instead of failing.
3963
+ */
3964
+ export async function applyLibraryList(conn, libraryList, currentLibrary) {
3965
+ const names = [...(libraryList || []), ...(currentLibrary ? [currentLibrary] : [])].map(l => String(l).toUpperCase());
3966
+ const invalid = names.find(l => !conn.validQsysName(l));
3967
+ if (invalid)
3968
+ throw new Error(`Invalid library name: ${invalid}`);
3969
+ if (typeof conn.applyConfiguredLibraryList === "function") {
3970
+ const applied = await conn.applyConfiguredLibraryList();
3971
+ return applied?.warning ? { warning: applied.warning } : {};
3972
+ }
3169
3973
  if (libraryList && libraryList.length > 0) {
3170
3974
  const normalized = libraryList.map((l) => String(l).toUpperCase());
3171
3975
  const invalid = normalized.find((l) => !conn.validQsysName(l));
3172
3976
  if (invalid)
3173
3977
  throw new Error(`Invalid library name: ${invalid}`);
3174
- const libs = normalized.join(" ");
3175
- await conn.sendQsh({ command: `system \"CHGLIBL LIBL(${libs})\"` });
3978
+ if (typeof conn.runCL === "function") {
3979
+ // Runs in the persistent SQL job when there is one, so later SQL and CL see it.
3980
+ requireCommandSuccess(await conn.runCL(`CHGLIBL LIBL(${normalized.join(" ")})`), "CHGLIBL");
3981
+ }
3982
+ else {
3983
+ // '$' is escaped so names like $LIB are not expanded by qsh.
3984
+ const libs = normalized.join(" ").replace(/\$/g, "\\$");
3985
+ requireCommandSuccess(await conn.sendQsh({ command: `system \"CHGLIBL LIBL(${libs})\"` }), "CHGLIBL");
3986
+ }
3176
3987
  }
3177
3988
  if (currentLibrary) {
3178
3989
  const lib = String(currentLibrary).toUpperCase();
3179
3990
  if (!conn.validQsysName(lib))
3180
3991
  throw new Error(`Invalid current library: ${lib}`);
3181
- await conn.sendQsh({ command: `system \"CHGCURLIB ${lib}\"` });
3992
+ if (typeof conn.runCL === "function") {
3993
+ requireCommandSuccess(await conn.runCL(`CHGCURLIB CURLIB(${lib})`), "CHGCURLIB");
3994
+ }
3995
+ else {
3996
+ requireCommandSuccess(await conn.sendQsh({ command: `system \"CHGCURLIB ${lib.replace(/\$/g, "\\$")}\"` }), "CHGCURLIB");
3997
+ }
3182
3998
  }
3999
+ return {};
4000
+ }
4001
+ /** "OK", plus the library list warning from applyToJob when there is one. */
4002
+ function okApplied(applied, text = "OK") {
4003
+ return result(applied?.warning ? `${text}. Warning: ${applied.warning}` : text);
3183
4004
  }
3184
4005
  function stripUndefined(obj) {
3185
4006
  const out = {};
@@ -3247,6 +4068,42 @@ async function compareDeployTrees(conn, localRoot, remoteRoot) {
3247
4068
  }
3248
4069
  };
3249
4070
  }
4071
+ /**
4072
+ * Re-checks every file present on both sides by SHA-256, so files with equal
4073
+ * size but different content count as changed and size-unknown files are
4074
+ * resolved. Files whose remote hash can't be computed stay unresolved.
4075
+ */
4076
+ async function refineComparisonByHash(conn, comparison) {
4077
+ const sizeOf = new Map();
4078
+ for (const entry of [...comparison.changed, ...comparison.unresolved])
4079
+ sizeOf.set(entry.path, entry);
4080
+ const shared = [...comparison.identical, ...comparison.changed.map(e => e.path), ...comparison.unresolved.map(e => e.path)]
4081
+ .sort((a, b) => a.localeCompare(b));
4082
+ const remoteHashes = await sha256RemoteFiles(conn, comparison.remoteRoot, shared);
4083
+ const identical = [];
4084
+ const changed = [];
4085
+ const unresolved = [];
4086
+ for (const rel of shared) {
4087
+ const sizes = sizeOf.get(rel);
4088
+ const localSha256 = await sha256LocalFile(path.join(comparison.localRoot, rel));
4089
+ const remoteSha256 = remoteHashes.get(rel);
4090
+ const base = { path: rel, localSize: sizes?.localSize, remoteSize: sizes?.remoteSize, localSha256, remoteSha256 };
4091
+ if (!remoteSha256)
4092
+ unresolved.push({ ...base, reason: "hash_unavailable" });
4093
+ else if (remoteSha256 === localSha256)
4094
+ identical.push(rel);
4095
+ else
4096
+ changed.push({ ...base, reason: "content_mismatch" });
4097
+ }
4098
+ return {
4099
+ ...comparison,
4100
+ comparedBy: "sha256",
4101
+ identical,
4102
+ changed,
4103
+ unresolved,
4104
+ summary: { ...comparison.summary, identical: identical.length, changed: changed.length, unresolved: unresolved.length }
4105
+ };
4106
+ }
3250
4107
  async function listLocalFiles(root) {
3251
4108
  const resolvedRoot = path.resolve(root);
3252
4109
  const results = new Map();
@@ -3271,6 +4128,10 @@ async function listRemoteFiles(conn, remoteRoot) {
3271
4128
  const normalizedRoot = remoteRoot.replace(/\/$/, "");
3272
4129
  const find = conn.remoteFeatures.find || "/QOpenSys/pkgs/bin/find";
3273
4130
  const stat = conn.remoteFeatures.stat;
4131
+ // A remote root that does not exist yet is an empty tree (first deploy).
4132
+ const exists = await conn.sendCommand({ command: `test -d ${Tools.escapePath(normalizedRoot)}` });
4133
+ if (exists.code !== 0)
4134
+ return new Map();
3274
4135
  if (stat) {
3275
4136
  const statCmd = `${find} ${Tools.escapePath(normalizedRoot)} -type f -exec ${stat} -c '%n|%s' {} \\;`;
3276
4137
  const statRes = await conn.sendCommand({ command: statCmd });
@@ -3321,7 +4182,7 @@ function joinRemotePath(root, rel) {
3321
4182
  const base = root.replace(/\/$/, "");
3322
4183
  return `${base}/${rel}`.replace(/\\/g, "/");
3323
4184
  }
3324
- function mapActionExecution(action, targetType, targetPath, result) {
4185
+ export function mapActionExecution(action, targetType, targetPath, result) {
3325
4186
  const diagnostics = normalizeCommandDiagnostics(result);
3326
4187
  const code = Number(result?.code);
3327
4188
  const ok = Number.isFinite(code) ? code === 0 : false;
@@ -3385,32 +4246,60 @@ function normalizeJoblogRow(row) {
3385
4246
  timestamp: row.MESSAGE_TIMESTAMP ? String(row.MESSAGE_TIMESTAMP) : undefined
3386
4247
  };
3387
4248
  }
3388
- function normalizeSpoolEntry(row) {
4249
+ export function normalizeSpoolEntry(row) {
3389
4250
  return {
3390
4251
  ...row,
3391
4252
  source: "spool",
3392
- jobName: toNullableString(pickFirst(row, ["JOB_NAME", "JOB"])) || "",
4253
+ jobName: toNullableString(pickFirst(row, ["QUALIFIED_JOB_NAME", "JOB_NAME", "JOB"])) || "",
3393
4254
  spooledFileName: toNullableString(pickFirst(row, ["SPOOLED_FILE_NAME", "SPOOLED_FILE", "SPLF_NAME"])) || "",
3394
4255
  spooledFileNumber: toNullableNumber(pickFirst(row, ["SPOOLED_FILE_NUMBER", "SPLF_NUMBER", "FILE_NUMBER"])),
3395
4256
  outputQueue: toNullableString(pickFirst(row, ["OUTPUT_QUEUE_NAME", "OUTQ_NAME", "OUTPUT_QUEUE"])) || "",
4257
+ outputQueueLibrary: toNullableString(pickFirst(row, ["OUTPUT_QUEUE_LIBRARY_NAME", "OUTPUT_QUEUE_LIBRARY"])) || "",
4258
+ userName: toNullableString(pickFirst(row, ["USER_NAME", "JOB_USER"])) || "",
4259
+ createdAt: toNullableString(pickFirst(row, ["CREATE_TIMESTAMP", "CREATION_TIMESTAMP"])),
3396
4260
  totalPages: toNullableNumber(pickFirst(row, ["TOTAL_PAGES", "PAGES"])),
3397
4261
  status: toNullableString(pickFirst(row, ["FILE_STATUS", "STATUS"])) || ""
3398
4262
  };
3399
4263
  }
3400
- function normalizeSpoolLine(row) {
4264
+ export function normalizeSpoolLine(row) {
4265
+ const position = pickFirst(row, ["ORDINAL_POSITION", "LINE_NUMBER"]);
3401
4266
  return {
3402
4267
  ...row,
3403
4268
  source: "spool",
3404
- lineNumber: Number.isFinite(Number(row.LINE_NUMBER)) ? Number(row.LINE_NUMBER) : null,
3405
- text: String(row.SPOOLED_DATA || "")
4269
+ lineNumber: Number.isFinite(Number(position)) && position !== null && position !== "" ? Number(position) : null,
4270
+ text: String(row.SPOOLED_DATA ?? "")
3406
4271
  };
3407
4272
  }
3408
- function clampNumber(value, fallback, min, max) {
4273
+ export function clampNumber(value, fallback, min, max) {
3409
4274
  const n = Number(value);
3410
4275
  if (!Number.isFinite(n))
3411
4276
  return fallback;
3412
4277
  return Math.max(min, Math.min(max, Math.floor(n)));
3413
4278
  }
4279
+ /**
4280
+ * Runs SQL with a time limit. When the limit is reached the call fails and the
4281
+ * signal asks the client to cancel the statement in its persistent SQL job, so
4282
+ * it does not keep running on the IBM i.
4283
+ */
4284
+ async function runSqlWithTimeout(conn, sql, timeoutMs, timeoutMessage) {
4285
+ const controller = new AbortController();
4286
+ let timeoutHandle;
4287
+ try {
4288
+ return await Promise.race([
4289
+ conn.runSQL(sql, { signal: controller.signal }),
4290
+ new Promise((_, reject) => {
4291
+ timeoutHandle = setTimeout(() => {
4292
+ controller.abort();
4293
+ reject(new Error(timeoutMessage));
4294
+ }, timeoutMs);
4295
+ })
4296
+ ]);
4297
+ }
4298
+ finally {
4299
+ if (timeoutHandle)
4300
+ clearTimeout(timeoutHandle);
4301
+ }
4302
+ }
3414
4303
  async function withTimeout(promise, timeoutMs, timeoutMessage) {
3415
4304
  let timeoutHandle;
3416
4305
  try {
@@ -3427,14 +4316,7 @@ async function withTimeout(promise, timeoutMs, timeoutMessage) {
3427
4316
  }
3428
4317
  }
3429
4318
  function getSqlStatementType(sql) {
3430
- const stripped = sql
3431
- .replace(/\/\*[\s\S]*?\*\//g, " ")
3432
- .split("\n")
3433
- .filter(line => !line.trim().startsWith("--"))
3434
- .join(" ")
3435
- .trim();
3436
- const token = stripped.split(/\s+/)[0] || "UNKNOWN";
3437
- return token.toUpperCase();
4319
+ return parseSqlStatementType(sql);
3438
4320
  }
3439
4321
  function getRowColumns(rows) {
3440
4322
  if (!Array.isArray(rows) || rows.length === 0)
@@ -3442,6 +4324,10 @@ function getRowColumns(rows) {
3442
4324
  return Object.keys(rows[0] || {});
3443
4325
  }
3444
4326
  function inferAffectedRows(rows) {
4327
+ // The persistent SQL job reports the update count of INSERT/UPDATE/DELETE.
4328
+ const updateCount = rows?.updateCount;
4329
+ if (typeof updateCount === "number" && Number.isFinite(updateCount))
4330
+ return updateCount;
3445
4331
  if (!Array.isArray(rows) || rows.length === 0)
3446
4332
  return null;
3447
4333
  const candidateKeys = [
@@ -3486,37 +4372,111 @@ function toSafeSqlError(err, sql, timeoutMs) {
3486
4372
  };
3487
4373
  }
3488
4374
  function isReadOnlySql(sql) {
3489
- const stripped = sql
3490
- .replace(/\/\*[\s\S]*?\*\//g, " ")
3491
- .split("\n")
3492
- .filter(line => !line.trim().startsWith("--"))
3493
- .join(" ")
3494
- .trim()
3495
- .toLowerCase();
3496
- return stripped.startsWith("select ") || stripped.startsWith("with ") || stripped.startsWith("values ");
4375
+ return checkReadOnlySql(sql).readOnly;
3497
4376
  }
3498
- function enforceWritable(ctx, conn, operation, args) {
4377
+ export async function enforceWritable(ctx, conn, operation, args) {
3499
4378
  if (conn.getConfig().readOnlyMode)
3500
4379
  throw new Error("Connection is in read-only mode");
3501
- enforcePolicyOperation(ctx, operation, args);
4380
+ await enforcePolicyOperation(ctx, operation, args);
3502
4381
  }
3503
- function enforcePolicyOperation(ctx, operation, args) {
4382
+ async function enforcePolicyOperation(ctx, operation, args) {
3504
4383
  const policy = ctx.getPolicy(args?.connectionName);
3505
4384
  if (policy.profile === "read-only") {
3506
4385
  throw new Error(`Blocked by policy (${policy.profile}): ${operation}`);
3507
4386
  }
3508
- if (isDeniedByPolicy(policy, operation, args)) {
3509
- throw new Error(`Blocked by policy: ${operation}`);
4387
+ const deniedReason = isDeniedByPolicy(policy, operation, args);
4388
+ if (deniedReason) {
4389
+ throw new Error(`Blocked by policy: ${operation} (${deniedReason})`);
4390
+ }
4391
+ if (policy.profile !== "guarded")
4392
+ return;
4393
+ // Shell text can do anything the profile can, so under guarded it always
4394
+ // needs the person's say, whatever requireApprovalFor lists.
4395
+ if (isShellOperation(args)) {
4396
+ await requireConfirmation(ctx, policy, "shell.run", args, false);
4397
+ }
4398
+ else if (requiresApproval(policy, operation)) {
4399
+ await requireConfirmation(ctx, policy, operation, args, operation !== "ifs.delete");
3510
4400
  }
3511
- if (requiresApproval(policy, operation) && !Boolean(args?.approve)) {
3512
- throw new Error(`Operation '${operation}' requires approve=true under '${policy.profile}' policy.`);
4401
+ }
4402
+ /**
4403
+ * Get the person's approval for a guarded operation. With approvalMode
4404
+ * "prompt" (the default) the MCP client asks them; the agent's approve flag
4405
+ * counts for nothing. With "agent", approve=true is enough.
4406
+ */
4407
+ export async function requireConfirmation(ctx, policy, operation, args, allowSession) {
4408
+ if (policy.approvalMode === "agent") {
4409
+ if (!Boolean(args?.approve)) {
4410
+ throw new Error(`Operation '${operation}' requires approve=true under '${policy.profile}' policy.`);
4411
+ }
4412
+ return;
4413
+ }
4414
+ const connectionName = String(args?.connectionName || ctx.activeName || "");
4415
+ const sessionKey = `${connectionName}\u0000${operation}`;
4416
+ if (allowSession && ctx.sessionApprovals?.has(sessionKey))
4417
+ return;
4418
+ const unsupported = new Error(`Operation '${operation}' needs the user's confirmation, but this MCP client cannot show confirmation prompts. ` +
4419
+ "Ask the user to run it from a client that supports MCP elicitation, or to set this connection's approval mode " +
4420
+ "to \"agent\" in config.json (approve=true is then accepted).");
4421
+ if (!ctx.confirm)
4422
+ throw unsupported;
4423
+ const answer = await ctx.confirm({
4424
+ operation,
4425
+ connectionName,
4426
+ toolName: toolCallStore.getStore()?.toolName,
4427
+ details: describeOperation(args),
4428
+ allowSession
4429
+ });
4430
+ if (answer === "unsupported")
4431
+ throw unsupported;
4432
+ if (!answer.allowed) {
4433
+ throw new Error(`Operation '${operation}' was not confirmed by the user, so it did not run.`);
4434
+ }
4435
+ if (allowSession && answer.forSession)
4436
+ ctx.sessionApprovals?.add(sessionKey);
4437
+ }
4438
+ /**
4439
+ * Approval for operations that always need it, whatever requireApprovalFor
4440
+ * lists: under guarded the person is asked (no session allowance); other
4441
+ * profiles keep the agent's approve=true.
4442
+ */
4443
+ export async function requireDangerousApproval(ctx, operation, args, approveMessage) {
4444
+ const policy = ctx.getPolicy(args?.connectionName);
4445
+ if (policy.profile === "guarded") {
4446
+ await requireConfirmation(ctx, policy, operation, args, false);
4447
+ return;
4448
+ }
4449
+ if (!Boolean(args?.approve))
4450
+ throw new Error(approveMessage);
4451
+ }
4452
+ const DESCRIBED_ARG_KEYS = [
4453
+ "command", "environment", "cwd", "sql",
4454
+ "library", "file", "sourceFile", "member", "object", "type", "objectType", "name", "newName",
4455
+ "path", "remotePath", "remoteDirectory", "targetPath", "localPath", "recursive", "confirmPath",
4456
+ "deleteExtraRemote", "libraryList", "currentLibrary", "remove",
4457
+ "confirmName", "journalLibrary", "journal", "retentionDays", "maxDeletes", "before"
4458
+ ];
4459
+ /** Short plain-text lines describing the arguments that decide what runs. */
4460
+ export function describeOperation(args) {
4461
+ const lines = [];
4462
+ if (!args || typeof args !== "object")
4463
+ return lines;
4464
+ for (const key of DESCRIBED_ARG_KEYS) {
4465
+ const value = args[key];
4466
+ if (value === undefined || value === null || value === "")
4467
+ continue;
4468
+ let text = typeof value === "string" ? value : JSON.stringify(value);
4469
+ if (text.length > 600)
4470
+ text = `${text.substring(0, 600)}... (${text.length} characters)`;
4471
+ lines.push(`${key}: ${text}`);
3513
4472
  }
4473
+ return lines;
3514
4474
  }
3515
4475
  function isMissingColumnError(err, column) {
3516
4476
  const message = String(err?.message || err || "").toUpperCase();
3517
4477
  return message.includes("42703") && message.includes(column.toUpperCase());
3518
4478
  }
3519
- function requireIfsPath(value, label) {
4479
+ export function requireIfsPath(value, label) {
3520
4480
  const pathValue = String(value || "").trim();
3521
4481
  if (!pathValue.startsWith("/")) {
3522
4482
  throw new Error(`Invalid ${label}: must be an absolute IFS path`);
@@ -3533,10 +4493,10 @@ function requireIfsJournalObjectType(value) {
3533
4493
  }
3534
4494
  return objectType;
3535
4495
  }
3536
- function quoteClString(value) {
4496
+ export function quoteClString(value) {
3537
4497
  return `'${String(value).replace(/'/g, "''")}'`;
3538
4498
  }
3539
- function requireIbmObjectName(value, label) {
4499
+ export function requireIbmObjectName(value, label) {
3540
4500
  const name = String(value || "").trim().toUpperCase();
3541
4501
  if (!/^[A-Z#@$][A-Z0-9#@$]{0,9}$/.test(name)) {
3542
4502
  throw new Error(`Invalid ${label}: ${value}`);
@@ -3678,14 +4638,58 @@ function requiresApproval(policy, operation) {
3678
4638
  const requires = policy.requireApprovalFor || [];
3679
4639
  return requires.includes(operation);
3680
4640
  }
4641
+ /** Returns the reason an operation is denied by the command policy, if any. */
3681
4642
  function isDeniedByPolicy(policy, operation, args) {
3682
- const denyCommands = (policy.denyCommands || []).map(v => v.toLowerCase());
3683
4643
  if (operation === "cl.run" && args?.command) {
3684
- const command = String(args.command).toLowerCase();
3685
- if (denyCommands.some(prefix => command.startsWith(prefix)))
3686
- return true;
4644
+ const environment = String(args.environment || "ile");
4645
+ return environment === "ile"
4646
+ ? checkClCommand(policy, String(args.command))
4647
+ : checkShellCommand(policy, String(args.command));
4648
+ }
4649
+ if (typeof args?.sql === "string") {
4650
+ return checkSqlEmbeddedCl(policy, args.sql);
4651
+ }
4652
+ return undefined;
4653
+ }
4654
+ /** Library, IFS path and embedded-CL policy checks that apply to every tool. */
4655
+ async function enforceResourcePolicy(ctx, toolName, args) {
4656
+ const resolver = ctx;
4657
+ const policy = typeof resolver.resolvePolicy === "function"
4658
+ ? await resolver.resolvePolicy(args?.connectionName)
4659
+ : typeof resolver.getPolicy === "function" ? resolver.getPolicy(args?.connectionName) : undefined;
4660
+ if (!policy)
4661
+ return;
4662
+ const reason = checkResourcePolicy(policy, toolName, args);
4663
+ if (reason) {
4664
+ throw new Error(`Blocked by policy: ${toolName} (${reason})`);
4665
+ }
4666
+ }
4667
+ /** Check the policy against an action's expanded command before it runs. */
4668
+ export function actionCommandGuard(ctx, environment, args) {
4669
+ return async (expanded) => {
4670
+ const policy = ctx.getPolicy(args?.connectionName);
4671
+ const env = environment || "ile";
4672
+ const reason = env === "ile"
4673
+ ? checkClCommands(policy, expanded)
4674
+ : checkShellCommand(policy, expanded);
4675
+ if (reason)
4676
+ throw new Error(`Blocked by policy: actions.run (${reason})`);
4677
+ if (policy.profile === "guarded" && (env !== "ile" || clStartsShell(expanded))) {
4678
+ await requireConfirmation(ctx, policy, "shell.run", { ...args, command: expanded, environment: env }, false);
4679
+ }
4680
+ return expanded;
4681
+ };
4682
+ }
4683
+ /** Resolve a local path and refuse it outside the allowed local roots. */
4684
+ export async function guardLocalPath(ctx, value, mode) {
4685
+ let settingsRoots;
4686
+ try {
4687
+ settingsRoots = (await ctx.store.getSettings())?.allowedLocalRoots;
4688
+ }
4689
+ catch {
4690
+ settingsRoots = undefined;
3687
4691
  }
3688
- return false;
4692
+ return resolveAllowedLocalPath(value, mode, configuredLocalRoots(settingsRoots));
3689
4693
  }
3690
4694
  const APPROVAL_HINT_TOOLS = new Set([
3691
4695
  "ibmi.qsys.members.write",
@@ -3742,11 +4746,32 @@ function withGuardedApprovalHint(tool) {
3742
4746
  if (!schema.properties.approve) {
3743
4747
  schema.properties.approve = {
3744
4748
  type: "boolean",
3745
- description: "Set true to approve guarded-policy operations."
4749
+ description: "Only used when the connection's approvalMode is \"agent\". By default the user is asked to confirm guarded operations and this flag is ignored."
3746
4750
  };
3747
4751
  }
3748
4752
  return { ...tool, inputSchema: schema };
3749
4753
  }
4754
+ /** Tools outside the qsys/search/source families that also accept connectionName. */
4755
+ const CONNECTION_NAME_TOOLS = new Set([
4756
+ "ibmi.find.ifs",
4757
+ "ibmi.actions.run",
4758
+ "ibmi.libl.validate",
4759
+ "ibmi.resolve.path",
4760
+ "ibmi.debug.status",
4761
+ "ibmi.journal.objects.list"
4762
+ ]);
4763
+ function withSourceConnectionName(tool) {
4764
+ const accepts = /^ibmi\.(?:qsys|search|source|ifs|deploy)\./.test(tool.name) || CONNECTION_NAME_TOOLS.has(tool.name);
4765
+ if (!accepts)
4766
+ return tool;
4767
+ const schema = tool.inputSchema || { type: "object", properties: {} };
4768
+ if (!schema.properties)
4769
+ schema.properties = {};
4770
+ if (!schema.properties.connectionName) {
4771
+ schema.properties.connectionName = { type: "string", description: "Saved IBM i connection name." };
4772
+ }
4773
+ return { ...tool, inputSchema: schema };
4774
+ }
3750
4775
  function withStrictInputSchema(tool) {
3751
4776
  const schema = strictifySchema(tool.inputSchema || { type: "object", properties: {} });
3752
4777
  return { ...tool, inputSchema: schema };
@@ -3775,33 +4800,10 @@ function isObjectSchema(schema) {
3775
4800
  return schema.type.includes("object");
3776
4801
  return schema.type === "object";
3777
4802
  }
3778
- function resolveIfsWriteOperation(pathValue) {
4803
+ export function resolveIfsWriteOperation(pathValue) {
3779
4804
  const target = String(pathValue || "");
3780
4805
  if (/^\/qsys\.lib\//i.test(target)) {
3781
4806
  return "qsys.write";
3782
4807
  }
3783
4808
  return "ifs.write";
3784
4809
  }
3785
- function parseEvfevent(content) {
3786
- const diagnostics = [];
3787
- for (const rawLine of content.split(/\r?\n/)) {
3788
- const line = rawLine.trimEnd();
3789
- if (!line.trim())
3790
- continue;
3791
- const match = line.match(/^\s*(\d+)\s+(\d+)\s+([A-Z0-9]+)\s+(INFO|WARN|ERROR)?\s*(.*)$/i);
3792
- if (match) {
3793
- diagnostics.push({
3794
- source: "evfevent",
3795
- line: Number(match[1]),
3796
- column: Number(match[2]),
3797
- code: match[3],
3798
- severity: (match[4] || "ERROR").toUpperCase(),
3799
- message: match[5] || line
3800
- });
3801
- }
3802
- else {
3803
- diagnostics.push({ source: "evfevent", severity: inferSeverity(line), message: line });
3804
- }
3805
- }
3806
- return diagnostics;
3807
- }