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,48 @@
1
+ import { ElicitResultSchema } from "@modelcontextprotocol/sdk/types.js";
2
+ /** How long a confirmation prompt waits for the person before the operation is refused. */
3
+ export const CONFIRMATION_TIMEOUT_MS = 10 * 60 * 1000;
4
+ /**
5
+ * Ask the person through the MCP client (elicitation) before a guarded
6
+ * operation runs. Clients that don't support elicitation get "unsupported",
7
+ * which refuses the operation.
8
+ */
9
+ export function createElicitationConfirm(server, timeoutMs = CONFIRMATION_TIMEOUT_MS) {
10
+ return async (request) => {
11
+ if (!server.getClientCapabilities()?.elicitation)
12
+ return "unsupported";
13
+ const result = await server.request({
14
+ method: "elicitation/create",
15
+ params: {
16
+ mode: "form",
17
+ message: confirmationMessage(request),
18
+ requestedSchema: request.allowSession
19
+ ? {
20
+ type: "object",
21
+ properties: {
22
+ allowForSession: {
23
+ type: "boolean",
24
+ title: "Allow this kind of change on this connection until the server restarts",
25
+ default: false
26
+ }
27
+ }
28
+ }
29
+ : { type: "object", properties: {} }
30
+ }
31
+ }, ElicitResultSchema, { timeout: timeoutMs });
32
+ return {
33
+ allowed: result.action === "accept",
34
+ forSession: result.action === "accept" && result.content?.allowForSession === true
35
+ };
36
+ };
37
+ }
38
+ export function confirmationMessage(request) {
39
+ const what = request.operation === "shell.run"
40
+ ? "run a shell command"
41
+ : `perform a guarded operation (${request.operation})`;
42
+ const lines = [`The agent wants to ${what} on IBM i connection "${request.connectionName || "(active)"}".`];
43
+ if (request.toolName)
44
+ lines.push(`Tool: ${request.toolName}`);
45
+ lines.push(...request.details);
46
+ lines.push("", "Accept to let it run. Decline to stop it.");
47
+ return lines.join("\n");
48
+ }
@@ -11,6 +11,13 @@ const DEFAULT_RECONNECT_ATTEMPTS = 2;
11
11
  export class McpContext {
12
12
  store = new ConfigStore();
13
13
  activeName;
14
+ /**
15
+ * Asks the person to confirm a guarded operation. Set by the server from
16
+ * the MCP client's elicitation support; unset means nobody can be asked.
17
+ */
18
+ confirm;
19
+ /** Operations the person allowed for the rest of this session, keyed by connection and operation. */
20
+ sessionApprovals = new Set();
14
21
  sessions = new Map();
15
22
  cursors = new Map();
16
23
  connectLocks = new Map();
@@ -61,8 +68,9 @@ export class McpContext {
61
68
  }
62
69
  async ensureActive(name) {
63
70
  const sessionName = name || this.activeName;
64
- if (!sessionName)
65
- throw new Error("Not connected");
71
+ if (!sessionName) {
72
+ throw new Error("No active connection. Call ibmi.connect or pass connectionName.");
73
+ }
66
74
  if (!this.sessions.has(sessionName)) {
67
75
  await this.connectByName(sessionName);
68
76
  }
@@ -70,6 +78,25 @@ export class McpContext {
70
78
  this.touchSession(sessionName);
71
79
  return this.sessions.get(sessionName).client;
72
80
  }
81
+ /**
82
+ * Policy for a connection, read from the stored connection when no session
83
+ * is open yet, so checks that run before connecting still see it.
84
+ */
85
+ async resolvePolicy(name) {
86
+ const sessionName = name || this.activeName;
87
+ if (!sessionName)
88
+ return defaultPolicy();
89
+ const session = this.sessions.get(sessionName);
90
+ if (session)
91
+ return session.policy;
92
+ try {
93
+ const stored = await this.store.getConnection(sessionName);
94
+ return normalizePolicy(stored?.policy);
95
+ }
96
+ catch {
97
+ return defaultPolicy();
98
+ }
99
+ }
73
100
  getPolicy(name) {
74
101
  const sessionName = name || this.activeName;
75
102
  if (!sessionName)
@@ -108,7 +135,9 @@ export class McpContext {
108
135
  lastValidatedAt: session.lastValidatedAt ? new Date(session.lastValidatedAt).toISOString() : undefined,
109
136
  expiresAt: new Date(session.expiresAt).toISOString(),
110
137
  idleSecondsRemaining: Math.max(0, Math.floor((session.expiresAt - now) / 1000)),
111
- lastError: session.lastError
138
+ lastError: session.lastError,
139
+ // Persistent SQL job (Mapepire): open, fallen back to QZDFMDB2, or not started yet.
140
+ sqlJob: session.client.sqlJob?.status?.()
112
141
  };
113
142
  }
114
143
  async keepaliveSession(name) {
@@ -125,6 +154,10 @@ export class McpContext {
125
154
  throw new Error("connectionName is required (no active session)");
126
155
  await this.disconnect(target);
127
156
  }
157
+ /**
158
+ * Closes the session, including its persistent SQL job (IBMiClient.disconnect
159
+ * ends the Mapepire job before closing SSH).
160
+ */
128
161
  async disconnect(name) {
129
162
  if (name) {
130
163
  const session = this.sessions.get(name);
@@ -142,7 +175,9 @@ export class McpContext {
142
175
  }
143
176
  this.sessions.delete(name);
144
177
  if (this.activeName === name) {
145
- this.activeName = this.sessions.keys().next().value;
178
+ // Never fall back to another open session: later calls without
179
+ // connectionName must not silently target a different system.
180
+ this.activeName = undefined;
146
181
  }
147
182
  await this.persistSessionState(true);
148
183
  return;
@@ -188,7 +223,10 @@ export class McpContext {
188
223
  const stored = await this.store.getConnection(name);
189
224
  if (!stored)
190
225
  throw new Error(`Unknown connection: ${name}`);
191
- const password = await getPassword(name);
226
+ // Key-based connections must still work when the keychain is unavailable.
227
+ const password = stored.privateKeyPath
228
+ ? await getPassword(name).catch(() => undefined)
229
+ : await getPassword(name);
192
230
  const connection = {
193
231
  name: stored.name,
194
232
  host: stored.host,
@@ -0,0 +1,192 @@
1
+ import crypto from "crypto";
2
+ import { createReadStream } from "fs";
3
+ import fs from "fs/promises";
4
+ import path from "path";
5
+ import { execFile } from "child_process";
6
+ import { Tools } from "../ibmi/Tools.js";
7
+ export const DEPLOY_METHODS = ["all", "changed", "gitChanged", "selection"];
8
+ export function normalizeDeployMethod(value) {
9
+ if (value === undefined || value === null || value === "")
10
+ return "all";
11
+ const method = String(value);
12
+ if (!DEPLOY_METHODS.includes(method)) {
13
+ throw new Error(`Invalid method: ${method}. Expected one of ${DEPLOY_METHODS.join(", ")}`);
14
+ }
15
+ return method;
16
+ }
17
+ export async function sha256LocalFile(filePath) {
18
+ return new Promise((resolve, reject) => {
19
+ const hash = crypto.createHash("sha256");
20
+ const stream = createReadStream(filePath);
21
+ stream.on("error", reject);
22
+ stream.on("data", chunk => hash.update(chunk));
23
+ stream.on("end", () => resolve(hash.digest("hex")));
24
+ });
25
+ }
26
+ // Upper bound on the size of one remote hashing command (bytes of quoted
27
+ // paths) and on the number of files hashed per call.
28
+ const REMOTE_HASH_BATCH_BYTES = 24_000;
29
+ const REMOTE_HASH_BATCH_FILES = 200;
30
+ const NO_HASH_TOOL_MARKER = "MCPFORI_NO_SHA256_TOOL";
31
+ /**
32
+ * Picks sha256sum (PATH or /QOpenSys/pkgs/bin) or openssl, then hashes the
33
+ * given files. Kept as one shell snippet so detection costs no extra call.
34
+ */
35
+ function remoteHashCommand(remoteRoot, relPaths) {
36
+ const quoted = relPaths.map(rel => Tools.shellQuote(`./${rel}`)).join(" ");
37
+ return [
38
+ `cd ${Tools.shellQuote(remoteRoot)} || exit 2`,
39
+ `if command -v sha256sum >/dev/null 2>&1; then H="sha256sum"`,
40
+ `elif [ -x /QOpenSys/pkgs/bin/sha256sum ]; then H="/QOpenSys/pkgs/bin/sha256sum"`,
41
+ `elif command -v openssl >/dev/null 2>&1; then H="openssl dgst -sha256 -r"`,
42
+ `elif [ -x /QOpenSys/usr/bin/openssl ]; then H="/QOpenSys/usr/bin/openssl dgst -sha256 -r"`,
43
+ `else echo ${NO_HASH_TOOL_MARKER} >&2; exit 127; fi`,
44
+ `$H ${quoted}`
45
+ ].join("; ");
46
+ }
47
+ /** Parses sha256sum / `openssl dgst -r` / `openssl dgst` output into rel path -> hex. */
48
+ export function parseRemoteHashOutput(output) {
49
+ const hashes = new Map();
50
+ for (const rawLine of String(output || "").split(/\r?\n/)) {
51
+ const line = rawLine.trimEnd();
52
+ if (!line)
53
+ continue;
54
+ let hex;
55
+ let file;
56
+ const coreutils = line.match(/^\\?([0-9a-fA-F]{64}) [ *](.+)$/);
57
+ if (coreutils) {
58
+ hex = coreutils[1];
59
+ file = coreutils[2];
60
+ }
61
+ else {
62
+ const bsd = line.match(/^SHA2?-?256\((.+)\)\s*=\s*([0-9a-fA-F]{64})$/);
63
+ if (bsd) {
64
+ file = bsd[1];
65
+ hex = bsd[2];
66
+ }
67
+ }
68
+ if (!hex || !file)
69
+ continue;
70
+ hashes.set(file.replace(/^\.\//, ""), hex.toLowerCase());
71
+ }
72
+ return hashes;
73
+ }
74
+ /**
75
+ * Hashes remote files (paths relative to remoteRoot) in batches. Files that
76
+ * could not be hashed are simply absent from the result. Throws when the
77
+ * remote system has neither sha256sum nor openssl.
78
+ */
79
+ export async function sha256RemoteFiles(conn, remoteRoot, relPaths) {
80
+ const result = new Map();
81
+ const root = remoteRoot.replace(/\/+$/, "") || "/";
82
+ const usable = relPaths.filter(rel => !/[\r\n]/.test(rel));
83
+ let batch = [];
84
+ let batchBytes = 0;
85
+ const flush = async () => {
86
+ if (batch.length === 0)
87
+ return;
88
+ const res = await conn.sendCommand({ command: remoteHashCommand(root, batch) });
89
+ if (String(res?.stderr || "").includes(NO_HASH_TOOL_MARKER)) {
90
+ throw new Error("No SHA-256 tool (sha256sum or openssl) found on the IBM i; use method \"all\" instead");
91
+ }
92
+ if (res?.code === 2 && !String(res?.stdout || "").trim()) {
93
+ throw new Error(`Remote directory ${root} is not accessible: ${String(res?.stderr || "").trim()}`);
94
+ }
95
+ // A non-zero exit with partial output means some files could not be
96
+ // read; those stay unhashed and are treated as changed by the caller.
97
+ for (const [file, hex] of parseRemoteHashOutput(res?.stdout || ""))
98
+ result.set(file, hex);
99
+ batch = [];
100
+ batchBytes = 0;
101
+ };
102
+ for (const rel of usable) {
103
+ const size = rel.length + 6;
104
+ if (batch.length > 0 && (batch.length >= REMOTE_HASH_BATCH_FILES || batchBytes + size > REMOTE_HASH_BATCH_BYTES)) {
105
+ await flush();
106
+ }
107
+ batch.push(rel);
108
+ batchBytes += size;
109
+ }
110
+ await flush();
111
+ return result;
112
+ }
113
+ function runGit(cwd, args) {
114
+ return new Promise((resolve, reject) => {
115
+ execFile("git", ["-C", cwd, ...args], { maxBuffer: 64 * 1024 * 1024, windowsHide: true, shell: false }, (error, stdout, stderr) => {
116
+ if (error) {
117
+ const detail = String(stderr || error.message || "").trim();
118
+ reject(new Error(`git ${args[0]} failed: ${detail}`));
119
+ return;
120
+ }
121
+ resolve(String(stdout));
122
+ });
123
+ });
124
+ }
125
+ /** Parses `git status --porcelain -z` output (paths relative to the repo root). */
126
+ export function parseGitPorcelainZ(output) {
127
+ const entries = [];
128
+ const tokens = String(output || "").split("\0");
129
+ for (let i = 0; i < tokens.length; i++) {
130
+ const token = tokens[i];
131
+ if (token.length < 4)
132
+ continue;
133
+ const status = token.substring(0, 2);
134
+ const file = token.substring(3);
135
+ entries.push({ status, path: file });
136
+ // Renames and copies are followed by the original path.
137
+ if (status[0] === "R" || status[0] === "C")
138
+ i++;
139
+ }
140
+ return entries;
141
+ }
142
+ /**
143
+ * Files changed in the git working tree under localRoot (staged, unstaged
144
+ * and untracked), as paths relative to localRoot with forward slashes.
145
+ */
146
+ export async function gitChangedFiles(localRoot) {
147
+ const prefix = (await runGit(localRoot, ["rev-parse", "--show-prefix"])).trim().replace(/\\/g, "/");
148
+ const output = await runGit(localRoot, ["status", "--porcelain", "-z", "--untracked-files=all", "--", "."]);
149
+ const files = new Set();
150
+ const deleted = new Set();
151
+ for (const entry of parseGitPorcelainZ(output)) {
152
+ const repoPath = entry.path.replace(/\\/g, "/");
153
+ if (prefix && !repoPath.startsWith(prefix))
154
+ continue;
155
+ const rel = repoPath.substring(prefix.length);
156
+ if (!rel || rel.endsWith("/"))
157
+ continue;
158
+ const full = path.join(localRoot, rel);
159
+ const stat = await fs.stat(full).catch(() => undefined);
160
+ if (stat?.isFile())
161
+ files.add(rel);
162
+ else if (!stat)
163
+ deleted.add(rel);
164
+ }
165
+ return {
166
+ files: Array.from(files).sort((a, b) => a.localeCompare(b)),
167
+ deleted: Array.from(deleted).sort((a, b) => a.localeCompare(b))
168
+ };
169
+ }
170
+ /**
171
+ * Normalizes an explicit file selection: relative paths only, no `..`, with
172
+ * forward slashes and duplicates removed.
173
+ */
174
+ export function normalizeDeploySelection(value) {
175
+ if (!Array.isArray(value) || value.length === 0) {
176
+ throw new Error("files must be a non-empty array of paths relative to localPath for method \"selection\"");
177
+ }
178
+ const out = new Set();
179
+ for (const item of value) {
180
+ const raw = String(item ?? "").trim().replace(/\\/g, "/").replace(/^\.\//, "");
181
+ if (!raw)
182
+ throw new Error("files contains an empty path");
183
+ if (raw.includes("\0") || /[\r\n]/.test(raw))
184
+ throw new Error(`Invalid file path: ${item}`);
185
+ if (raw.startsWith("/") || /^[A-Za-z]:/.test(raw))
186
+ throw new Error(`files must be relative to localPath: ${item}`);
187
+ if (raw.split("/").some(segment => segment === ".."))
188
+ throw new Error(`files must not contain '..': ${item}`);
189
+ out.add(raw.replace(/\/+/g, "/"));
190
+ }
191
+ return Array.from(out).sort((a, b) => a.localeCompare(b));
192
+ }
@@ -1,4 +1,5 @@
1
1
  import fs from "fs";
2
+ import { REDACTED, isSecretKey, redactSecretsInText } from "../security/redact.js";
2
3
  const levelOrder = {
3
4
  error: 0,
4
5
  warn: 1,
@@ -35,25 +36,19 @@ function safeWrite(line) {
35
36
  }
36
37
  }
37
38
  export function redact(value) {
38
- const secretKeys = ["password", "pass", "token", "secret", "key", "privatekey"];
39
39
  if (Array.isArray(value)) {
40
40
  return value.map(redact);
41
41
  }
42
42
  if (value && typeof value === "object") {
43
43
  const out = {};
44
44
  for (const [k, v] of Object.entries(value)) {
45
- const lower = k.toLowerCase();
46
- if (secretKeys.some(s => lower.includes(s))) {
47
- out[k] = "***";
48
- }
49
- else {
50
- out[k] = redact(v);
51
- }
45
+ out[k] = isSecretKey(k) ? REDACTED : redact(v);
52
46
  }
53
47
  return out;
54
48
  }
55
- if (typeof value === "string" && value.length > 500) {
56
- return value.substring(0, 500) + "...(truncated)";
49
+ if (typeof value === "string") {
50
+ const clean = redactSecretsInText(value);
51
+ return clean.length > 500 ? clean.substring(0, 500) + "...(truncated)" : clean;
57
52
  }
58
53
  return value;
59
54
  }