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