mcp-for-i 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -148,9 +148,12 @@ npm run release:patch -- --no-push
148
148
  - Passwords are stored in keychain when available.
149
149
  - Control plane is local-first (`127.0.0.1` by default).
150
150
  - Guarded policy profile is the default for operational safety.
151
+ - Tool schemas are strict (`additionalProperties: false`) with runtime argument validation, including nested connection/action/filter/profile payloads.
152
+ - QSYS object names and source settings are validated before command execution to reduce interpolation and injection risk.
151
153
 
152
154
  ## Links
153
155
 
154
156
  - Main repo: [ashishthomas2202/mcp-for-i](https://github.com/ashishthomas2202/mcp-for-i)
155
157
  - Skills repo: [ashishthomas2202/mcp-for-i-skills](https://github.com/ashishthomas2202/mcp-for-i-skills)
156
158
  - Docs: `docs/`
159
+ - Full capability guide: `docs/capabilities.md`
@@ -76,16 +76,34 @@ export var CompileTools;
76
76
  }
77
77
  CompileTools.runCommand = runCommand;
78
78
  function buildLibraryList(config) {
79
- return config.libraryList.slice(0).reverse();
79
+ return config.libraryList
80
+ .slice(0)
81
+ .map(lib => String(lib || "").trim().toUpperCase())
82
+ .filter(Boolean)
83
+ .reverse();
80
84
  }
81
85
  function buildLiblistCommands(connection, config) {
82
- const defaultLibs = Tools.sanitizeObjNamesForPase((connection.defaultUserLibraries || []).filter(l => l && l !== "QSYS"));
83
- const currentLib = Tools.sanitizeObjNamesForPase([config.currentLibrary || "QGPL"])[0];
84
- const userLibs = Tools.sanitizeObjNamesForPase(buildLibraryList(config));
85
- return [
86
+ const defaultLibNames = (connection.defaultUserLibraries || [])
87
+ .filter(l => l && l !== "QSYS")
88
+ .map(l => assertLibraryName(connection, l, "default library"));
89
+ const userLibNames = buildLibraryList(config).map(l => assertLibraryName(connection, l, "library list"));
90
+ const currentLib = assertLibraryName(connection, config.currentLibrary || "QGPL", "current library");
91
+ const defaultLibs = Tools.sanitizeObjNamesForPase(defaultLibNames);
92
+ const userLibs = Tools.sanitizeObjNamesForPase(userLibNames);
93
+ const commands = [
86
94
  `liblist -d ${IBMiClient.escapeForShell(defaultLibs.join(` `))}`,
87
- `liblist -c ${IBMiClient.escapeForShell(currentLib)}`,
88
- `liblist -a ${IBMiClient.escapeForShell(userLibs.join(` `))}`
95
+ `liblist -c ${IBMiClient.escapeForShell(Tools.sanitizeObjNamesForPase([currentLib])[0])}`
89
96
  ];
97
+ if (userLibs.length > 0) {
98
+ commands.push(`liblist -a ${IBMiClient.escapeForShell(userLibs.join(` `))}`);
99
+ }
100
+ return commands;
90
101
  }
91
102
  })(CompileTools || (CompileTools = {}));
103
+ function assertLibraryName(connection, value, context) {
104
+ const normalized = String(value || "").trim().toUpperCase();
105
+ if (!normalized || !connection.validQsysName(normalized)) {
106
+ throw new Error(`Invalid ${context}: ${value}`);
107
+ }
108
+ return normalized;
109
+ }
@@ -86,11 +86,11 @@ export class IBMiContent {
86
86
  if (withDates !== undefined)
87
87
  return withDates;
88
88
  }
89
- const lib = this.ibmi.upperCaseName(library);
90
- const file = this.ibmi.upperCaseName(sourceFile);
91
- const mbr = this.ibmi.upperCaseName(member);
89
+ const lib = this.requireQsysName(library, "library");
90
+ const file = this.requireQsysName(sourceFile, "sourceFile");
91
+ const mbr = this.requireQsysName(member, "member");
92
92
  const tempRemote = this.ibmi.getTempRemote("mbr");
93
- const ccsid = this.config.sourceFileCCSID || "*FILE";
93
+ const ccsid = this.normalizeSourceFileCcsid(this.config.sourceFileCCSID);
94
94
  const cmd = `QSYS/CPYTOSTMF FROMMBR('${Tools.qualifyPath(lib, file, mbr, undefined, true)}') ` +
95
95
  `TOSTMF('${tempRemote}') STMFOPT(*REPLACE) STMFCCSID(1208) DBFCCSID(${ccsid})`;
96
96
  const result = await this.ibmi.sendQsh({ command: `system \"${IBMiClient.escapeForShell(cmd)}\"` });
@@ -114,12 +114,12 @@ export class IBMiContent {
114
114
  }
115
115
  async uploadMemberContentRaw(library, sourceFile, member, content) {
116
116
  this.assertWritable();
117
- const lib = this.ibmi.upperCaseName(library);
118
- const file = this.ibmi.upperCaseName(sourceFile);
119
- const mbr = this.ibmi.upperCaseName(member);
117
+ const lib = this.requireQsysName(library, "library");
118
+ const file = this.requireQsysName(sourceFile, "sourceFile");
119
+ const mbr = this.requireQsysName(member, "member");
120
120
  const tempRemote = this.ibmi.getTempRemote("mbr");
121
121
  await this.writeStreamfileRaw(tempRemote, content);
122
- const ccsid = this.config.sourceFileCCSID || "*FILE";
122
+ const ccsid = this.normalizeSourceFileCcsid(this.config.sourceFileCCSID);
123
123
  const cmd = `QSYS/CPYFRMSTMF FROMSTMF('${tempRemote}') ` +
124
124
  `TOMBR('${Tools.qualifyPath(lib, file, mbr, undefined, true)}') MBROPT(*REPLACE) ` +
125
125
  `STMFCCSID(1208) DBFCCSID(${ccsid})`;
@@ -181,7 +181,7 @@ export class IBMiContent {
181
181
  return badLibs;
182
182
  }
183
183
  async getObjectList(library, types = ["*ALL"]) {
184
- const lib = this.ibmi.upperCaseName(library);
184
+ const lib = this.requireQsysName(library, "library");
185
185
  const safeTypes = types.map(t => this.ibmi.upperCaseName(t));
186
186
  const hasInvalidType = safeTypes.some(t => !/^[*A-Z0-9]+$/.test(t));
187
187
  if (hasInvalidType)
@@ -197,8 +197,8 @@ export class IBMiContent {
197
197
  }));
198
198
  }
199
199
  async getMemberList(options) {
200
- const lib = this.ibmi.upperCaseName(options.library);
201
- const file = this.ibmi.upperCaseName(options.sourceFile);
200
+ const lib = this.requireQsysName(options.library, "library");
201
+ const file = this.requireQsysName(options.sourceFile, "sourceFile");
202
202
  const rows = await this.ibmi.runSQL(`select SYSTEM_TABLE_MEMBER, SOURCE_TYPE, PARTITION_TEXT from QSYS2.SYSPARTITIONSTAT where SYSTEM_TABLE_SCHEMA=${Tools.sqlString(lib)} and SYSTEM_TABLE_NAME=${Tools.sqlString(file)}`);
203
203
  return rows.map(r => ({
204
204
  library: lib,
@@ -209,9 +209,9 @@ export class IBMiContent {
209
209
  }));
210
210
  }
211
211
  async getMemberInfo(library, sourceFile, member) {
212
- const lib = this.ibmi.upperCaseName(library);
213
- const file = this.ibmi.upperCaseName(sourceFile);
214
- const mbr = this.ibmi.upperCaseName(member);
212
+ const lib = this.requireQsysName(library, "library");
213
+ const file = this.requireQsysName(sourceFile, "sourceFile");
214
+ const mbr = this.requireQsysName(member, "member");
215
215
  const rows = await this.ibmi.runSQL(`select SYSTEM_TABLE_MEMBER, SOURCE_TYPE, PARTITION_TEXT from QSYS2.SYSPARTITIONSTAT where SYSTEM_TABLE_SCHEMA=${Tools.sqlString(lib)} and SYSTEM_TABLE_NAME=${Tools.sqlString(file)} and SYSTEM_TABLE_MEMBER=${Tools.sqlString(mbr)}`);
216
216
  const row = rows[0];
217
217
  if (!row)
@@ -225,9 +225,9 @@ export class IBMiContent {
225
225
  };
226
226
  }
227
227
  async downloadMemberContentWithDates(library, sourceFile, member) {
228
- const lib = this.ibmi.upperCaseName(library);
229
- const file = this.ibmi.upperCaseName(sourceFile);
230
- const mbr = this.ibmi.upperCaseName(member);
228
+ const lib = this.requireQsysName(library, "library");
229
+ const file = this.requireQsysName(sourceFile, "sourceFile");
230
+ const mbr = this.requireQsysName(member, "member");
231
231
  const tempLib = await this.getTempLibrary();
232
232
  if (!tempLib)
233
233
  return undefined;
@@ -251,9 +251,9 @@ export class IBMiContent {
251
251
  }
252
252
  async uploadMemberContentWithDates(library, sourceFile, member, body) {
253
253
  this.assertWritable();
254
- const lib = this.ibmi.upperCaseName(library);
255
- const file = this.ibmi.upperCaseName(sourceFile);
256
- const mbr = this.ibmi.upperCaseName(member);
254
+ const lib = this.requireQsysName(library, "library");
255
+ const file = this.requireQsysName(sourceFile, "sourceFile");
256
+ const mbr = this.requireQsysName(member, "member");
257
257
  const tempLib = await this.getTempLibrary();
258
258
  if (!tempLib) {
259
259
  throw new Error(`Temp library is not available for source-date updates.`);
@@ -322,42 +322,64 @@ export class IBMiContent {
322
322
  }
323
323
  async createLibrary(library) {
324
324
  this.assertWritable();
325
- const lib = this.ibmi.upperCaseName(library);
325
+ const lib = this.requireQsysName(library, "library");
326
326
  return this.ibmi.sendQsh({ command: `system \"CRTLIB LIB(${lib})\"` });
327
327
  }
328
328
  async createSourceFile(library, file, rcdlen = 112) {
329
329
  this.assertWritable();
330
- const lib = this.ibmi.upperCaseName(library);
331
- const src = this.ibmi.upperCaseName(file);
330
+ const lib = this.requireQsysName(library, "library");
331
+ const src = this.requireQsysName(file, "sourceFile");
332
332
  return this.ibmi.sendQsh({ command: `system \"CRTSRCPF FILE(${lib}/${src}) RCDLEN(${rcdlen})\"` });
333
333
  }
334
334
  async createMember(library, file, member, srctype) {
335
335
  this.assertWritable();
336
- const lib = this.ibmi.upperCaseName(library);
337
- const src = this.ibmi.upperCaseName(file);
338
- const mbr = this.ibmi.upperCaseName(member);
339
- const type = srctype ? this.ibmi.upperCaseName(srctype) : "*NONE";
336
+ const lib = this.requireQsysName(library, "library");
337
+ const src = this.requireQsysName(file, "sourceFile");
338
+ const mbr = this.requireQsysName(member, "member");
339
+ const type = this.normalizeSourceType(srctype);
340
340
  return this.ibmi.sendQsh({ command: `system \"ADDPFM FILE(${lib}/${src}) MBR(${mbr}) SRCTYPE(${type})\"` });
341
341
  }
342
342
  async renameMember(library, file, member, newMember) {
343
343
  this.assertWritable();
344
- const lib = this.ibmi.upperCaseName(library);
345
- const src = this.ibmi.upperCaseName(file);
346
- const mbr = this.ibmi.upperCaseName(member);
347
- const newMbr = this.ibmi.upperCaseName(newMember);
344
+ const lib = this.requireQsysName(library, "library");
345
+ const src = this.requireQsysName(file, "sourceFile");
346
+ const mbr = this.requireQsysName(member, "member");
347
+ const newMbr = this.requireQsysName(newMember, "newMember");
348
348
  return this.ibmi.sendQsh({ command: `system \"RNMM FILE(${lib}/${src}) MBR(${mbr}) NEWMBR(${newMbr})\"` });
349
349
  }
350
350
  async deleteMember(library, file, member) {
351
351
  this.assertWritable();
352
- const lib = this.ibmi.upperCaseName(library);
353
- const src = this.ibmi.upperCaseName(file);
354
- const mbr = this.ibmi.upperCaseName(member);
352
+ const lib = this.requireQsysName(library, "library");
353
+ const src = this.requireQsysName(file, "sourceFile");
354
+ const mbr = this.requireQsysName(member, "member");
355
355
  return this.ibmi.sendQsh({ command: `system \"RMVM FILE(${lib}/${src}) MBR(${mbr})\"` });
356
356
  }
357
357
  async createTempFile() {
358
358
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "mcpfori-"));
359
359
  return path.join(dir, "tmp");
360
360
  }
361
+ requireQsysName(value, label) {
362
+ const normalized = this.ibmi.upperCaseName(String(value || "").trim());
363
+ if (!normalized || !this.ibmi.validQsysName(normalized)) {
364
+ throw new Error(`Invalid ${label}: ${value}`);
365
+ }
366
+ return normalized;
367
+ }
368
+ normalizeSourceType(value) {
369
+ const normalized = this.ibmi.upperCaseName(String(value || "*NONE").trim());
370
+ if (!/^\*?[A-Z0-9#@$._]{1,10}$/.test(normalized)) {
371
+ throw new Error(`Invalid source type: ${value}`);
372
+ }
373
+ return normalized;
374
+ }
375
+ normalizeSourceFileCcsid(value) {
376
+ const normalized = String(value || "*FILE").trim().toUpperCase();
377
+ if (/^\d{1,5}$/.test(normalized))
378
+ return normalized;
379
+ if (/^\*[A-Z0-9]{2,10}$/.test(normalized))
380
+ return normalized;
381
+ throw new Error(`Invalid sourceFileCCSID setting: ${value}`);
382
+ }
361
383
  assertWritable() {
362
384
  if (this.config.readOnlyMode) {
363
385
  throw new Error("Connection is in read-only mode");
@@ -14,6 +14,14 @@ export var Search;
14
14
  };
15
15
  }
16
16
  async function searchMembers(connection, library, sourceFile, searchTerm, members, readOnly) {
17
+ const lib = String(library || "").trim().toUpperCase();
18
+ const src = String(sourceFile || "").trim().toUpperCase();
19
+ if (!connection.validQsysName(lib)) {
20
+ throw new Error(`Invalid library: ${library}`);
21
+ }
22
+ if (!connection.validQsysName(src)) {
23
+ throw new Error(`Invalid source file: ${sourceFile}`);
24
+ }
17
25
  let detailedMembers;
18
26
  let memberFilter;
19
27
  const pfgrep = connection.remoteFeatures.pfgrep;
@@ -42,7 +50,7 @@ export var Search;
42
50
  : `/usr/bin/grep -inHR -F "${sanitizeSearchTerm(searchTerm)}" ${memberFilter}`;
43
51
  const result = await connection.sendCommand({
44
52
  command,
45
- directory: `/QSYS.LIB/${library}.LIB/${sourceFile}.FILE`
53
+ directory: `/QSYS.LIB/${lib}.LIB/${src}.FILE`
46
54
  });
47
55
  if (!result.stderr) {
48
56
  let hits = parseGrepOutput(result.stdout || '', readOnly);
@@ -39,7 +39,7 @@ export class IBMiClient {
39
39
  if (name.length > 10)
40
40
  return false;
41
41
  const upper = this.upperCaseName(name);
42
- return /^[A-Z][A-Z0-9_.]{0,9}$/.test(upper);
42
+ return /^[A-Z#@$][A-Z0-9#@$._]{0,9}$/.test(upper);
43
43
  }
44
44
  async connect(connection) {
45
45
  this.currentHost = connection.host;
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import crypto from "crypto";
2
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
@@ -6,7 +7,8 @@ import { getTools, handleTool } from "./mcp/tools.js";
6
7
  import { McpContext } from "./mcp/context.js";
7
8
  import { log } from "./mcp/logger.js";
8
9
  import { validateToolInput } from "./mcp/validation.js";
9
- const server = new Server({ name: "mcp-for-i", version: "0.1.4" }, { capabilities: { tools: {} } });
10
+ import { appendAuditRecord } from "./mcp/audit.js";
11
+ const server = new Server({ name: "mcp-for-i", version: "0.1.8" }, { capabilities: { tools: {} } });
10
12
  const ctx = new McpContext();
11
13
  const tools = getTools();
12
14
  const toolMap = new Map(tools.map(tool => [tool.name, tool]));
@@ -15,6 +17,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
15
17
  }));
16
18
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
17
19
  const { name, arguments: args } = request.params;
20
+ const correlationId = crypto.randomUUID();
21
+ const startedAt = Date.now();
18
22
  try {
19
23
  const toolDef = toolMap.get(name);
20
24
  if (!toolDef)
@@ -23,16 +27,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
23
27
  if (errors.length > 0) {
24
28
  throw new Error(`Invalid arguments: ${errors.join("; ")}`);
25
29
  }
26
- log("info", "tool.call", { name, args: redactSensitive(args || {}) });
30
+ const redactedArgs = redactSensitive(args || {});
31
+ log("info", "tool.call", { name, correlationId, args: redactedArgs });
27
32
  const result = await handleTool(ctx, name, args || {});
28
- log("debug", "tool.result", { name });
33
+ const durationMs = Date.now() - startedAt;
34
+ log("debug", "tool.result", { name, correlationId, durationMs });
35
+ await safeAppendAudit({
36
+ tool: name,
37
+ status: "ok",
38
+ args: redactedArgs,
39
+ connectionName: resolveConnectionName(name, args || {}, ctx.activeName),
40
+ approve: Boolean(args && typeof args === "object" && args.approve),
41
+ durationMs,
42
+ correlationId,
43
+ resultSummary: summarizeResult(result)
44
+ });
29
45
  return result;
30
46
  }
31
47
  catch (err) {
32
- log("error", "tool.error", { name, error: err?.message || String(err) });
48
+ const durationMs = Date.now() - startedAt;
49
+ const errorMessage = err?.message || String(err);
50
+ log("error", "tool.error", { name, correlationId, durationMs, error: errorMessage });
51
+ await safeAppendAudit({
52
+ tool: name,
53
+ status: "error",
54
+ args: redactSensitive(args || {}),
55
+ connectionName: resolveConnectionName(name, args || {}, ctx.activeName),
56
+ approve: Boolean(args && typeof args === "object" && args.approve),
57
+ durationMs,
58
+ correlationId,
59
+ error: errorMessage
60
+ });
33
61
  return {
34
62
  isError: true,
35
- content: [{ type: "text", text: err?.message || String(err) }]
63
+ content: [{ type: "text", text: errorMessage }]
36
64
  };
37
65
  }
38
66
  });
@@ -57,3 +85,28 @@ function redactSensitive(input) {
57
85
  }
58
86
  return input;
59
87
  }
88
+ function summarizeResult(result) {
89
+ const contentCount = Array.isArray(result?.content) ? result.content.length : 0;
90
+ return {
91
+ isError: Boolean(result?.isError),
92
+ contentCount,
93
+ hasStructuredContent: typeof result?.structuredContent !== "undefined"
94
+ };
95
+ }
96
+ function resolveConnectionName(tool, args, activeName) {
97
+ if (args?.connectionName)
98
+ return String(args.connectionName);
99
+ if (tool === "ibmi.connect" && args?.name)
100
+ return String(args.name);
101
+ return activeName;
102
+ }
103
+ async function safeAppendAudit(input) {
104
+ if (process.env.MCP_FOR_I_AUDIT_ENABLED === "0")
105
+ return;
106
+ try {
107
+ await appendAuditRecord(input);
108
+ }
109
+ catch (err) {
110
+ log("warn", "audit.append.failed", { error: err?.message || String(err) });
111
+ }
112
+ }