mcp-for-i 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +17 -0
  2. package/dist/config/store.js +229 -109
  3. package/dist/controlplane/auth.js +128 -0
  4. package/dist/controlplane/clientIntegrationService.js +83 -4
  5. package/dist/controlplane/commands.js +14 -9
  6. package/dist/controlplane/connectionService.js +110 -5
  7. package/dist/controlplane/connectionTest.js +329 -0
  8. package/dist/controlplane/errors.js +12 -0
  9. package/dist/controlplane/runtimeService.js +98 -26
  10. package/dist/controlplane/server.js +122 -11
  11. package/dist/controlplane/serverShared.js +25 -6
  12. package/dist/controlplane/serviceManager.js +113 -100
  13. package/dist/controlplane/ui.js +1742 -920
  14. package/dist/ibmi/CompileTools.js +29 -1
  15. package/dist/ibmi/IBMiContent.js +435 -63
  16. package/dist/ibmi/IbmDocs.js +685 -0
  17. package/dist/ibmi/LocalLanguageActions.js +129 -3
  18. package/dist/ibmi/Search.js +158 -52
  19. package/dist/ibmi/Tools.js +7 -10
  20. package/dist/ibmi/client.js +220 -31
  21. package/dist/ibmi/evfevent.js +256 -0
  22. package/dist/ibmi/knownHosts.js +190 -0
  23. package/dist/ibmi/libraryList.js +93 -0
  24. package/dist/ibmi/sourceInspect.js +815 -0
  25. package/dist/ibmi/sqlJob.js +888 -0
  26. package/dist/ibmi/sqlParse.js +317 -0
  27. package/dist/ibmi/tn5250/certTrust.js +59 -0
  28. package/dist/ibmi/tn5250/client.js +310 -0
  29. package/dist/ibmi/tn5250/datastream.js +109 -0
  30. package/dist/ibmi/tn5250/ebcdic.js +74 -0
  31. package/dist/ibmi/tn5250/index.js +6 -0
  32. package/dist/ibmi/tn5250/screen.js +827 -0
  33. package/dist/ibmi/tn5250/telnet.js +186 -0
  34. package/dist/ibmi/variables.js +18 -7
  35. package/dist/index.js +43 -22
  36. package/dist/mcp/annotations.js +155 -0
  37. package/dist/mcp/audit.js +490 -133
  38. package/dist/mcp/confirm.js +48 -0
  39. package/dist/mcp/context.js +43 -5
  40. package/dist/mcp/deployMethods.js +192 -0
  41. package/dist/mcp/logger.js +5 -10
  42. package/dist/mcp/modules/bulkMembers.js +476 -0
  43. package/dist/mcp/modules/changedLines.js +189 -0
  44. package/dist/mcp/modules/compare.js +198 -0
  45. package/dist/mcp/modules/compile.js +515 -0
  46. package/dist/mcp/modules/ifsOps.js +123 -0
  47. package/dist/mcp/modules/index.js +38 -0
  48. package/dist/mcp/modules/inspect.js +811 -0
  49. package/dist/mcp/modules/libl.js +246 -0
  50. package/dist/mcp/modules/members.js +171 -0
  51. package/dist/mcp/modules/objects.js +339 -0
  52. package/dist/mcp/modules/system.js +266 -0
  53. package/dist/mcp/modules/tn5250.js +515 -0
  54. package/dist/mcp/policy.js +412 -0
  55. package/dist/mcp/tools.js +1700 -698
  56. package/dist/security/credentialStore.js +62 -13
  57. package/dist/security/localPaths.js +138 -0
  58. package/dist/security/redact.js +79 -0
  59. package/dist/util/atomicWrite.js +101 -0
  60. package/dist/util/fileLock.js +206 -0
  61. package/docs/capabilities.md +50 -7
  62. package/docs/configuration.md +189 -60
  63. package/docs/examples.md +109 -73
  64. package/docs/overview.md +15 -15
  65. package/docs/security.md +101 -16
  66. package/docs/tools.md +363 -188
  67. package/package.json +56 -53
@@ -0,0 +1,412 @@
1
+ import path from "path";
2
+ const LIBRARY_ARG_KEYS = [
3
+ "library",
4
+ "journalLibrary",
5
+ "receiverLibrary",
6
+ "objectLibrary",
7
+ "outQueueLibrary",
8
+ "currentLibrary"
9
+ ];
10
+ const LIBRARY_LIST_ARG_KEYS = ["libraryList"];
11
+ const QUALIFIED_OBJECT_ARG_KEYS = ["object", "dataArea", "queue", "messageQueue", "outQueue", "subsystemDescription"];
12
+ const PATH_ARG_KEYS = ["path", "remotePath", "remoteDirectory", "targetPath", "cwd"];
13
+ /** Tools whose arguments describe local configuration, not IBM i resources. */
14
+ const RESOURCE_POLICY_EXEMPT_TOOLS = new Set([
15
+ "ibmi.connect",
16
+ "ibmi.disconnect",
17
+ "ibmi.connections.list",
18
+ "ibmi.connections.add",
19
+ "ibmi.connections.update",
20
+ "ibmi.connections.delete",
21
+ "ibmi.session.list",
22
+ "ibmi.session.status",
23
+ "ibmi.session.keepalive",
24
+ "ibmi.session.terminate",
25
+ "ibmi.actions.save",
26
+ "ibmi.actions.delete",
27
+ "ibmi.actions.list",
28
+ "ibmi.docs.search",
29
+ "ibmi.docs.open",
30
+ "ibmi.diagnostics.parseEvfevent",
31
+ "ibmi.audit.list",
32
+ "ibmi.audit.verify",
33
+ "ibmi.audit.export",
34
+ "ibmi.audit.purge",
35
+ "ibmi.profiles.list",
36
+ "ibmi.profiles.delete",
37
+ "ibmi.filters.list",
38
+ "ibmi.filters.delete"
39
+ ]);
40
+ /**
41
+ * Normalize a CL command string to its command name: trim, drop a leading
42
+ * `?` prompt marker, uppercase, take the first token and strip any library
43
+ * qualifier.
44
+ */
45
+ export function normalizeClCommandName(command) {
46
+ let text = String(command || "").trim();
47
+ while (text.startsWith("?"))
48
+ text = text.substring(1).trim();
49
+ const token = text.split(/[\s(]/, 1)[0] || "";
50
+ const upper = token.toUpperCase();
51
+ const slash = upper.lastIndexOf("/");
52
+ return slash >= 0 ? upper.substring(slash + 1) : upper;
53
+ }
54
+ function normalizeCommandEntry(entry) {
55
+ return normalizeClCommandName(entry);
56
+ }
57
+ function genericMatch(value, pattern) {
58
+ if (!pattern)
59
+ return false;
60
+ if (pattern === "*" || pattern === "*ALL")
61
+ return true;
62
+ if (pattern.endsWith("*") && !pattern.startsWith("*")) {
63
+ return value.startsWith(pattern.substring(0, pattern.length - 1));
64
+ }
65
+ return value === pattern;
66
+ }
67
+ /** Returns a reason when the CL command is blocked, or undefined. */
68
+ export function checkClCommand(policy, command) {
69
+ const name = normalizeClCommandName(command);
70
+ if (!name)
71
+ return undefined;
72
+ const deny = (policy.denyCommands || []).map(normalizeCommandEntry).filter(Boolean);
73
+ if (deny.some(entry => genericMatch(name, entry))) {
74
+ return `command ${name} is in denyCommands`;
75
+ }
76
+ const allow = (policy.allowCommands || []).map(normalizeCommandEntry).filter(Boolean);
77
+ if (allow.length > 0 && !allow.some(entry => genericMatch(name, entry))) {
78
+ return `command ${name} is not in allowCommands`;
79
+ }
80
+ return undefined;
81
+ }
82
+ function hasCommandPolicy(policy) {
83
+ return (policy.denyCommands || []).length > 0 || (policy.allowCommands || []).length > 0;
84
+ }
85
+ /** Check one or more newline-separated CL commands. */
86
+ export function checkClCommands(policy, commands) {
87
+ for (const line of String(commands || "").split(/\r?\n/)) {
88
+ if (!line.trim())
89
+ continue;
90
+ const reason = checkClCommand(policy, line);
91
+ if (reason)
92
+ return reason;
93
+ }
94
+ return undefined;
95
+ }
96
+ /**
97
+ * Best-effort check of a PASE or QSH shell command. CL reached through
98
+ * `system` or `cl` invocations is checked. Since shell text cannot be fully
99
+ * verified, a non-empty allowCommands blocks shell environments entirely.
100
+ */
101
+ export function checkShellCommand(policy, command) {
102
+ if (!hasCommandPolicy(policy))
103
+ return undefined;
104
+ if ((policy.allowCommands || []).length > 0) {
105
+ return "shell (qsh/pase) commands cannot be verified against allowCommands";
106
+ }
107
+ const pattern = /(?:^|[\s;&|`(])(?:\/QOpenSys\/usr\/bin\/)?(?:system|cl)\s+(?:-\w+\s+)*(?:"([^"]*)"|'([^']*)'|(\S+))/gi;
108
+ let match;
109
+ while ((match = pattern.exec(String(command || ""))) !== null) {
110
+ const cl = match[1] ?? match[2] ?? match[3] ?? "";
111
+ const reason = checkClCommand(policy, cl);
112
+ if (reason)
113
+ return reason;
114
+ }
115
+ return undefined;
116
+ }
117
+ /**
118
+ * Find CL embedded in SQL text: lines starting with `@` and QCMDEXC calls
119
+ * whose first argument is a string literal.
120
+ */
121
+ export function extractEmbeddedCl(sql) {
122
+ const text = String(sql || "");
123
+ const commands = [];
124
+ for (const line of text.split(/\r?\n/)) {
125
+ const trimmed = line.trim();
126
+ if (trimmed.startsWith("@")) {
127
+ const cmd = trimmed.substring(1).trim().replace(/;\s*$/, "");
128
+ if (cmd)
129
+ commands.push(cmd);
130
+ }
131
+ }
132
+ let unverifiable = false;
133
+ const qcmdexc = /\bQCMDEXC\b/gi;
134
+ let match;
135
+ while ((match = qcmdexc.exec(text)) !== null) {
136
+ const rest = text.substring(match.index + match[0].length);
137
+ const literal = rest.match(/^\s*\(\s*(?:COMMAND\s*=>\s*)?'((?:[^']|'')*)'\s*(?=[,)])/i);
138
+ if (literal) {
139
+ commands.push(literal[1].replace(/''/g, "'"));
140
+ }
141
+ else if (/^\s*\(/.test(rest)) {
142
+ unverifiable = true;
143
+ }
144
+ }
145
+ return { commands, unverifiable };
146
+ }
147
+ /** Check CL embedded in SQL text against the command policy. */
148
+ export function checkSqlEmbeddedCl(policy, sql) {
149
+ if (!hasCommandPolicy(policy))
150
+ return undefined;
151
+ const embedded = extractEmbeddedCl(sql);
152
+ for (const command of embedded.commands) {
153
+ const reason = checkClCommand(policy, command);
154
+ if (reason)
155
+ return reason;
156
+ }
157
+ if (embedded.unverifiable) {
158
+ return "QCMDEXC with a non-literal command cannot be verified against the command policy";
159
+ }
160
+ return undefined;
161
+ }
162
+ export function checkLibrary(policy, library) {
163
+ const name = String(library || "").trim().toUpperCase();
164
+ if (!name)
165
+ return undefined;
166
+ const deny = (policy.denyLibraries || []).map(v => String(v).trim().toUpperCase()).filter(Boolean);
167
+ const allow = (policy.allowLibraries || []).map(v => String(v).trim().toUpperCase()).filter(Boolean);
168
+ const isSpecialOrGeneric = name.startsWith("*") || name.includes("*");
169
+ if (!isSpecialOrGeneric && deny.some(entry => genericMatch(name, entry))) {
170
+ return `library ${name} is in denyLibraries`;
171
+ }
172
+ if (allow.length > 0) {
173
+ if (isSpecialOrGeneric) {
174
+ return `library value ${name} cannot be checked against allowLibraries`;
175
+ }
176
+ if (!allow.some(entry => genericMatch(name, entry))) {
177
+ return `library ${name} is not in allowLibraries`;
178
+ }
179
+ }
180
+ return undefined;
181
+ }
182
+ export function normalizeIfsPath(value) {
183
+ const raw = String(value || "").trim();
184
+ if (!raw)
185
+ return "";
186
+ let normalized = path.posix.normalize(raw.replace(/\\/g, "/"));
187
+ if (normalized.length > 1 && normalized.endsWith("/"))
188
+ normalized = normalized.slice(0, -1);
189
+ return normalized;
190
+ }
191
+ function pathEntryMatches(target, entry) {
192
+ const normalizedEntry = normalizeIfsPath(entry);
193
+ if (!normalizedEntry.startsWith("/"))
194
+ return false;
195
+ if (normalizedEntry === "/")
196
+ return true;
197
+ const escaped = normalizedEntry
198
+ .toLowerCase()
199
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
200
+ .replace(/\*/g, "[^/]*");
201
+ return new RegExp(`^${escaped}(?:/.*)?$`).test(target.toLowerCase());
202
+ }
203
+ export function checkIfsPath(policy, value) {
204
+ const raw = String(value || "").trim();
205
+ if (!raw)
206
+ return undefined;
207
+ const allow = (policy.allowPaths || []).filter(Boolean);
208
+ const deny = (policy.denyPaths || []).filter(Boolean);
209
+ if (allow.length === 0 && deny.length === 0)
210
+ return undefined;
211
+ if (!raw.startsWith("/")) {
212
+ return allow.length > 0 ? `relative path ${raw} cannot be checked against allowPaths` : undefined;
213
+ }
214
+ const target = normalizeIfsPath(raw);
215
+ if (deny.some(entry => pathEntryMatches(target, entry))) {
216
+ return `path ${target} is in denyPaths`;
217
+ }
218
+ if (allow.length > 0 && !allow.some(entry => pathEntryMatches(target, entry))) {
219
+ return `path ${target} is not in allowPaths`;
220
+ }
221
+ return undefined;
222
+ }
223
+ /** Library named by an IFS path such as /QSYS.LIB/MYLIB.LIB/FILE.FILE. */
224
+ function libraryFromQsysPath(value) {
225
+ const match = normalizeIfsPath(value).match(/^(?:\/[^/]+)?\/QSYS\.LIB\/([^/]+)\.LIB(?:\/|$)/i);
226
+ return match ? match[1].toUpperCase() : undefined;
227
+ }
228
+ /** Collect libraries and IFS paths that a tool call refers to. */
229
+ export function collectPolicyResources(toolName, args) {
230
+ const libraries = [];
231
+ const paths = [];
232
+ if (!args || typeof args !== "object")
233
+ return { libraries, paths };
234
+ for (const key of LIBRARY_ARG_KEYS) {
235
+ const value = args[key];
236
+ if (typeof value === "string" && value.trim())
237
+ libraries.push(value.trim());
238
+ }
239
+ for (const key of LIBRARY_LIST_ARG_KEYS) {
240
+ const value = args[key];
241
+ if (Array.isArray(value)) {
242
+ for (const item of value)
243
+ if (typeof item === "string" && item.trim())
244
+ libraries.push(item.trim());
245
+ }
246
+ }
247
+ for (const key of QUALIFIED_OBJECT_ARG_KEYS) {
248
+ const value = args[key];
249
+ if (typeof value === "string" && value.includes("/")) {
250
+ const lib = value.split("/")[0].trim();
251
+ if (lib)
252
+ libraries.push(lib);
253
+ }
254
+ }
255
+ for (const key of PATH_ARG_KEYS) {
256
+ const value = args[key];
257
+ if (typeof value !== "string" || !value.trim())
258
+ continue;
259
+ if (toolName === "ibmi.resolve.path" && key === "path" && !value.trim().startsWith("/")) {
260
+ const parts = value.split("/").filter(Boolean);
261
+ const lib = parts.length === 4 ? parts[1] : parts[0];
262
+ if (lib)
263
+ libraries.push(lib);
264
+ continue;
265
+ }
266
+ if (key === "targetPath" && args.targetType && args.targetType !== "streamfile")
267
+ continue;
268
+ paths.push(value.trim());
269
+ const qsysLib = libraryFromQsysPath(value);
270
+ if (qsysLib)
271
+ libraries.push(qsysLib);
272
+ }
273
+ return { libraries, paths };
274
+ }
275
+ /** Check library, path and embedded-CL policy for any tool call. */
276
+ export function checkResourcePolicy(policy, toolName, args) {
277
+ if (RESOURCE_POLICY_EXEMPT_TOOLS.has(toolName))
278
+ return undefined;
279
+ const resources = collectPolicyResources(toolName, args);
280
+ for (const library of resources.libraries) {
281
+ const reason = checkLibrary(policy, library);
282
+ if (reason)
283
+ return reason;
284
+ }
285
+ for (const ifsPath of resources.paths) {
286
+ const reason = checkIfsPath(policy, ifsPath);
287
+ if (reason)
288
+ return reason;
289
+ }
290
+ if (typeof args?.sql === "string") {
291
+ const reason = checkSqlEmbeddedCl(policy, args.sql);
292
+ if (reason)
293
+ return reason;
294
+ }
295
+ return undefined;
296
+ }
297
+ const PROTECTED_DELETE_PREFIXES = [
298
+ "/qibm/proddata",
299
+ "/qopensys/qibm/proddata",
300
+ "/qsys.lib/qsys.lib",
301
+ "/qsys.lib/qsys2.lib",
302
+ "/qsys.lib/qusrsys.lib",
303
+ "/qsys.lib/qgpl.lib"
304
+ ];
305
+ /**
306
+ * Validate a path for ifs.delete. Refuses empty or relative paths, `..`,
307
+ * wildcards, the root, top-level directories, /QSYS.LIB/<lib>.LIB,
308
+ * second-level directories under /QOpenSys and /QIBM, and IBM product
309
+ * directories. Returns the normalized path.
310
+ */
311
+ export function assertSafeIfsDeletePath(value) {
312
+ return assertSafeIfsTargetPath(value, "ifs.delete", "delete");
313
+ }
314
+ /**
315
+ * The same guard as assertSafeIfsDeletePath for other operations that change
316
+ * or remove what is at a path (copy destinations, move sources and targets).
317
+ * `operation` names the tool in the error and `verb` describes the change.
318
+ */
319
+ export function assertSafeIfsTargetPath(value, operation, verb = "modify") {
320
+ const refuse = (reason) => new Error(`${operation} refused: ${reason}`);
321
+ const raw = String(value ?? "").trim();
322
+ if (!raw)
323
+ throw refuse("path is empty");
324
+ if (/[\0\r\n]/.test(raw))
325
+ throw refuse("path contains control characters");
326
+ if (!raw.startsWith("/"))
327
+ throw refuse("path must be absolute");
328
+ if (raw.split(/[\\/]/).some(segment => segment === "..")) {
329
+ throw refuse("path must not contain '..'");
330
+ }
331
+ if (/[*?[\]]/.test(raw))
332
+ throw refuse("wildcards are not allowed");
333
+ const normalized = normalizeIfsPath(raw);
334
+ const lower = normalized.toLowerCase();
335
+ const segments = lower.split("/").filter(Boolean);
336
+ if (segments.length === 0)
337
+ throw refuse(`cannot ${verb} the root directory`);
338
+ if (segments.length === 1)
339
+ throw refuse(`cannot ${verb} top-level directory ${normalized}`);
340
+ if (segments[0] === "qsys.lib" && segments.length === 2) {
341
+ throw refuse(`cannot ${verb} a library through the IFS (${normalized})`);
342
+ }
343
+ if (segments[0] === "home" && segments.length === 2) {
344
+ throw refuse(`cannot ${verb} a user home directory (${normalized})`);
345
+ }
346
+ if ((segments[0] === "qopensys" || segments[0] === "qibm") && segments.length === 2) {
347
+ throw refuse(`cannot ${verb} system directory ${normalized}`);
348
+ }
349
+ if (PROTECTED_DELETE_PREFIXES.some(prefix => lower === prefix || lower.startsWith(`${prefix}/`))) {
350
+ throw refuse(`${normalized} is a protected system location`);
351
+ }
352
+ return normalized;
353
+ }
354
+ /**
355
+ * CL that starts a shell: QSH/STRQSH, or a CALL to one of the PASE or QShell
356
+ * entry programs. Matched anywhere in the text, so it also catches a shell
357
+ * nested in SBMJOB CMD(...) or a QCMDEXC string.
358
+ */
359
+ const SHELL_IN_CL = /(?:^|[^A-Z0-9_$#@])(?:QSH|STRQSH|QP2SHELL2?|QP2TERM|QZSHSH|QZSHCHLD|QZSHQSHC)(?:[^A-Z0-9_$#@]|$)/i;
360
+ /** True when CL text starts a PASE or QShell shell. */
361
+ export function clStartsShell(command) {
362
+ return SHELL_IN_CL.test(String(command || ""));
363
+ }
364
+ /**
365
+ * True when an operation runs shell text: a qsh/pase command, CL that starts
366
+ * a shell, or SQL whose embedded CL does (or can't be read).
367
+ */
368
+ export function isShellOperation(args) {
369
+ if (typeof args?.command === "string" && args.command.trim()) {
370
+ const environment = String(args.environment || "ile").toLowerCase();
371
+ if (environment !== "ile")
372
+ return true;
373
+ if (clStartsShell(args.command))
374
+ return true;
375
+ }
376
+ if (typeof args?.sql === "string") {
377
+ const embedded = extractEmbeddedCl(args.sql);
378
+ if (embedded.unverifiable)
379
+ return true;
380
+ if (embedded.commands.some(clStartsShell))
381
+ return true;
382
+ }
383
+ return false;
384
+ }
385
+ /**
386
+ * Connection fields an agent may not change through MCP tools. They decide
387
+ * how much the agent is allowed to do, so they are set in the web UI or in
388
+ * config.json by a person.
389
+ */
390
+ export const PROTECTED_CONNECTION_SETTINGS = [
391
+ "readOnlyMode",
392
+ "tempDir",
393
+ "tempLibrary",
394
+ "autoClearTempData",
395
+ "mapepireMode",
396
+ "mapepirePort",
397
+ "mapepireServerPath",
398
+ "mapepireJavaPath"
399
+ ];
400
+ /** Refuse policy and safety-setting changes made through connections.add/update. */
401
+ export function assertAgentConnectionChangeAllowed(toolName, args) {
402
+ if (args && typeof args === "object" && args.policy !== undefined) {
403
+ throw new Error(`${toolName} cannot set a connection's policy. Change the policy in the web UI (mcp-for-i control) or in config.json.`);
404
+ }
405
+ const settings = args?.settings;
406
+ if (settings && typeof settings === "object") {
407
+ const blocked = PROTECTED_CONNECTION_SETTINGS.filter(key => settings[key] !== undefined);
408
+ if (blocked.length > 0) {
409
+ throw new Error(`${toolName} cannot change ${blocked.join(", ")}. These settings control what the agent may do; change them in the web UI or in config.json.`);
410
+ }
411
+ }
412
+ }