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
@@ -1,4 +1,5 @@
1
- import fs from "fs/promises";
1
+ import fs from "fs";
2
+ import fsp from "fs/promises";
2
3
  import os from "os";
3
4
  import path from "path";
4
5
  import { spawn } from "child_process";
@@ -229,7 +230,7 @@ export class ClientIntegrationService {
229
230
  async isCodeConfigured() {
230
231
  const userConfigPath = getVsCodeUserMcpPath();
231
232
  try {
232
- const raw = await fs.readFile(userConfigPath, "utf8");
233
+ const raw = await fsp.readFile(userConfigPath, "utf8");
233
234
  const parsed = JSON.parse(raw);
234
235
  if (Array.isArray(parsed?.servers)) {
235
236
  return parsed.servers.some((entry) => entry?.name === SERVER_NAME);
@@ -263,10 +264,88 @@ async function commandExists(command) {
263
264
  const result = await runCommand(command, ["--help"]);
264
265
  return result.ok;
265
266
  }
266
- async function runCommand(command, args) {
267
+ /**
268
+ * Finds a command on PATH the way cmd.exe does (PATHEXT order). Returns the
269
+ * full path, or undefined when nothing matches.
270
+ */
271
+ export function resolveWindowsCommand(command, env = process.env) {
272
+ const pathExt = (env.PATHEXT || ".COM;.EXE;.BAT;.CMD")
273
+ .split(";")
274
+ .map(ext => ext.trim().toLowerCase())
275
+ .filter(Boolean);
276
+ const hasExt = pathExt.includes(path.extname(command).toLowerCase());
277
+ const candidates = hasExt ? [command] : pathExt.map(ext => command + ext);
278
+ const isPath = command.includes("/") || command.includes("\\");
279
+ const dirs = isPath
280
+ ? [""]
281
+ : String(env.PATH || env.Path || "").split(";").map(dir => dir.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean);
282
+ for (const dir of dirs) {
283
+ for (const candidate of candidates) {
284
+ const full = dir ? path.join(dir, candidate) : candidate;
285
+ try {
286
+ if (fs.statSync(full).isFile())
287
+ return full;
288
+ }
289
+ catch {
290
+ // not here
291
+ }
292
+ }
293
+ }
294
+ return undefined;
295
+ }
296
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
297
+ /** Quotes one argument for cmd.exe, following the MSVCRT argv rules. */
298
+ export function escapeCmdArgument(value, doubleEscapeMeta) {
299
+ let arg = String(value);
300
+ // Double the backslashes that precede a quote, then escape the quote.
301
+ arg = arg.replace(/(\\*)"/g, '$1$1\\"');
302
+ // Double trailing backslashes so they do not escape the closing quote.
303
+ arg = arg.replace(/(\\*)$/, "$1$1");
304
+ arg = `"${arg}"`;
305
+ arg = arg.replace(CMD_META, "^$1");
306
+ // .cmd shims re-parse their arguments (%*), so they need a second pass.
307
+ if (doubleEscapeMeta)
308
+ arg = arg.replace(CMD_META, "^$1");
309
+ return arg;
310
+ }
311
+ /**
312
+ * Builds how to start a command. On Windows, npm-installed CLIs (codex,
313
+ * claude, code) are .cmd shims that only start through cmd.exe, so they are
314
+ * resolved via PATHEXT and launched with every argument escaped. The
315
+ * arguments are fixed by this module, never taken from a request.
316
+ */
317
+ export function buildSpawnSpec(command, args, platform = process.platform, env = process.env) {
318
+ if (platform !== "win32") {
319
+ return { file: command, args, windowsVerbatimArguments: false };
320
+ }
321
+ const resolved = resolveWindowsCommand(command, env);
322
+ if (!resolved)
323
+ return undefined;
324
+ if (!/\.(cmd|bat)$/i.test(resolved)) {
325
+ return { file: resolved, args, windowsVerbatimArguments: false };
326
+ }
327
+ const commandLine = [
328
+ resolved.replace(CMD_META, "^$1"),
329
+ ...args.map(arg => escapeCmdArgument(arg, true))
330
+ ].join(" ");
331
+ return {
332
+ file: env.ComSpec || env.COMSPEC || "cmd.exe",
333
+ args: ["/d", "/s", "/c", `"${commandLine}"`],
334
+ windowsVerbatimArguments: true
335
+ };
336
+ }
337
+ export async function runCommand(command, args, env = process.env) {
267
338
  return new Promise(resolve => {
268
- const child = spawn(command, args, {
339
+ const spec = buildSpawnSpec(command, args, process.platform, env);
340
+ if (!spec) {
341
+ resolve({ ok: false, stdout: "", stderr: `${command} was not found on PATH` });
342
+ return;
343
+ }
344
+ const child = spawn(spec.file, spec.args, {
345
+ env,
269
346
  windowsHide: true,
347
+ windowsVerbatimArguments: spec.windowsVerbatimArguments,
348
+ timeout: 120000,
270
349
  stdio: ["ignore", "pipe", "pipe"]
271
350
  });
272
351
  const stdout = [];
@@ -1,7 +1,7 @@
1
1
  import { startControlPlaneServer } from "./server.js";
2
2
  import { openUrl } from "./platform.js";
3
3
  import { getControlPlaneUrl } from "./serverShared.js";
4
- import { disableBackgroundService, enableBackgroundService, ensureControlPlaneRunning, getServiceStatus } from "./serviceManager.js";
4
+ import { disableBackgroundService, enableBackgroundService, ensureControlPlaneRunning, getServiceStatus, stopControlPlane } from "./serviceManager.js";
5
5
  export async function runControlCommand(args, options) {
6
6
  const stderr = options.stderr || process.stderr;
7
7
  const command = normalizeCommand(args, options.defaultCommand || "serve");
@@ -17,6 +17,13 @@ export async function runControlCommand(args, options) {
17
17
  case "open":
18
18
  await openControlUi(stderr, scriptPath, nodePath);
19
19
  return;
20
+ case "stop":
21
+ await stop(stderr, scriptPath, nodePath);
22
+ return;
23
+ case "restart":
24
+ await stop(stderr, scriptPath, nodePath);
25
+ await openControlUi(stderr, scriptPath, nodePath, false);
26
+ return;
20
27
  case "enable":
21
28
  await enableService(stderr, scriptPath, nodePath);
22
29
  return;
@@ -29,25 +36,52 @@ export async function runControlCommand(args, options) {
29
36
  return;
30
37
  }
31
38
  }
39
+ /**
40
+ * Runs the control plane in the foreground. Resolves only after the server has
41
+ * stopped listening, so a caller that exits when this returns keeps it alive.
42
+ */
32
43
  async function serve(stderr) {
44
+ let server;
33
45
  try {
34
- const server = await startControlPlaneServer();
35
- stderr.write(`mcp-for-i control plane running at http://${server.host}:${server.port}\n`);
36
- const shutdown = async () => {
37
- await server.close();
38
- process.exit(0);
39
- };
40
- process.on("SIGINT", shutdown);
41
- process.on("SIGTERM", shutdown);
46
+ server = await startControlPlaneServer();
42
47
  }
43
48
  catch (err) {
44
49
  if (err?.code === "EADDRINUSE") {
45
50
  const url = getControlPlaneUrl();
46
51
  stderr.write(`mcp-for-i control plane already running at ${url}\n`);
47
- process.exit(0);
52
+ return;
48
53
  }
49
54
  throw err;
50
55
  }
56
+ stderr.write(`mcp-for-i control plane running at ${getControlPlaneUrl()}\n`);
57
+ const shutdown = () => {
58
+ server.close().catch(() => undefined);
59
+ };
60
+ process.once("SIGINT", shutdown);
61
+ process.once("SIGTERM", shutdown);
62
+ await server.closed;
63
+ if (await server.shutdownRequested) {
64
+ stderr.write("mcp-for-i control plane stopped\n");
65
+ // Background timers (update checks, client probes) must not keep a stopped server's process alive.
66
+ process.exit(0);
67
+ }
68
+ }
69
+ async function stop(stderr, scriptPath, nodePath) {
70
+ const url = getControlPlaneUrl();
71
+ const result = await stopControlPlane(scriptPath, nodePath);
72
+ if (!result.wasRunning) {
73
+ stderr.write(`Control plane is not running at ${url}\n`);
74
+ }
75
+ else if (result.stopped) {
76
+ stderr.write(`Stopped control plane at ${url}\n`);
77
+ }
78
+ else {
79
+ stderr.write(`Control plane at ${url} did not stop: ${result.detail || "it is still answering"}\n`);
80
+ process.exitCode = 1;
81
+ }
82
+ if (result.serviceEnabled) {
83
+ stderr.write(`The background service is still enabled (${result.managedBy}), so it starts again at the next login. Run "mcp-for-i control disable" to turn that off.\n`);
84
+ }
51
85
  }
52
86
  async function printStatus(stderr, scriptPath, nodePath) {
53
87
  const url = getControlPlaneUrl();
@@ -61,12 +95,17 @@ async function printStatus(stderr, scriptPath, nodePath) {
61
95
  if (status.detail)
62
96
  stderr.write(`Detail: ${status.detail}\n`);
63
97
  }
64
- async function openControlUi(stderr, scriptPath, nodePath) {
98
+ async function openControlUi(stderr, scriptPath, nodePath, openBrowser = true) {
65
99
  const url = getControlPlaneUrl();
66
100
  const result = await ensureControlPlaneRunning(scriptPath, nodePath);
67
101
  if (result.started) {
68
102
  stderr.write(`Started control plane at ${url}\n`);
69
103
  }
104
+ if (!openBrowser) {
105
+ if (!result.started)
106
+ stderr.write(`Control plane is running at ${url}\n`);
107
+ return;
108
+ }
70
109
  try {
71
110
  await openUrl(url);
72
111
  stderr.write(`Opening ${url}\n`);
@@ -94,6 +133,8 @@ function printHelp(stderr) {
94
133
  stderr.write(`mcp-for-i-control commands:\n`);
95
134
  stderr.write(` serve Start control plane server in the foreground\n`);
96
135
  stderr.write(` open Start control plane in the background if needed, then open UI\n`);
136
+ stderr.write(` stop Stop the running control plane\n`);
137
+ stderr.write(` restart Stop the control plane, then start it again in the background\n`);
97
138
  stderr.write(` status Show running and background-service status\n`);
98
139
  stderr.write(` enable Enable background service for this platform\n`);
99
140
  stderr.write(` disable Disable background service for this platform\n`);
@@ -1,16 +1,96 @@
1
1
  import { deletePassword, getPassword, setPassword } from "../security/credentialStore.js";
2
+ import { ControlPlaneError } from "./errors.js";
3
+ import { testConnection } from "./connectionTest.js";
4
+ /** True when host, port or username differ (host compared case-insensitively). */
5
+ export function connectionEndpointChanged(before, after) {
6
+ const host = (value) => String(value ?? "").trim().toLowerCase();
7
+ const port = (value) => Number(value || 22);
8
+ const user = (value) => String(value ?? "").trim().toUpperCase();
9
+ return host(before.host) !== host(after.host)
10
+ || port(before.port) !== port(after.port)
11
+ || user(before.username) !== user(after.username);
12
+ }
2
13
  export class ConnectionService {
3
14
  store;
4
- constructor(store) {
15
+ runTest;
16
+ /** Last test result per saved connection, kept in memory only. */
17
+ lastTests = new Map();
18
+ /** Endpoints with a test in progress, so a double click signs on once. */
19
+ running = new Set();
20
+ constructor(store, runTest = testConnection) {
5
21
  this.store = store;
22
+ this.runTest = runTest;
6
23
  }
7
24
  async list() {
8
- return this.store.listConnections();
25
+ const list = await this.store.listConnections();
26
+ return list.map(conn => {
27
+ const lastTest = this.lastTests.get(conn.name);
28
+ return lastTest ? { ...conn, lastTest } : conn;
29
+ });
30
+ }
31
+ /** Tests a saved connection with its stored details and password. */
32
+ async testSaved(name) {
33
+ const stored = await this.store.getConnection(name);
34
+ if (!stored)
35
+ throw new ControlPlaneError(404, `Connection ${name} not found`);
36
+ const password = await getPassword(name).catch(() => undefined);
37
+ const result = await this.guardedTest({
38
+ host: stored.host,
39
+ port: stored.port,
40
+ username: stored.username,
41
+ password: password || undefined,
42
+ privateKeyPath: stored.privateKeyPath
43
+ });
44
+ this.lastTests.set(name, result);
45
+ return result;
46
+ }
47
+ /**
48
+ * Tests details that may not be saved yet (the add and edit form). A saved
49
+ * password is only used for the same host, port and user it was saved for.
50
+ */
51
+ async testDetails(input) {
52
+ const host = typeof input?.host === "string" ? input.host.trim() : "";
53
+ const username = typeof input?.username === "string" ? input.username.trim() : "";
54
+ if (!host || !username)
55
+ throw new ControlPlaneError(400, "host and username are required");
56
+ const port = Number(input.port || 22);
57
+ let password = typeof input.password === "string" && input.password ? input.password : undefined;
58
+ const name = typeof input.name === "string" ? input.name.trim() : "";
59
+ const stored = name ? await this.store.getConnection(name) : undefined;
60
+ if (!password && stored && !connectionEndpointChanged(stored, { host, port, username })) {
61
+ password = (await getPassword(name).catch(() => undefined)) || undefined;
62
+ }
63
+ const privateKeyPath = typeof input.privateKeyPath === "string" && input.privateKeyPath.trim()
64
+ ? input.privateKeyPath.trim()
65
+ : undefined;
66
+ return this.guardedTest({ host, port, username, password, privateKeyPath });
67
+ }
68
+ async guardedTest(target) {
69
+ const key = [target.host.trim().toLowerCase(), Number(target.port || 22), target.username.trim().toUpperCase()].join("|");
70
+ if (this.running.has(key)) {
71
+ throw new ControlPlaneError(409, "A test of this connection is already running");
72
+ }
73
+ this.running.add(key);
74
+ try {
75
+ return await this.runTest(target);
76
+ }
77
+ finally {
78
+ this.running.delete(key);
79
+ }
9
80
  }
10
81
  async get(name) {
11
82
  return this.store.getConnection(name);
12
83
  }
13
84
  async add(input) {
85
+ const name = typeof input?.name === "string" ? input.name.trim() : "";
86
+ if (!name || !input.host || !input.username) {
87
+ throw new ControlPlaneError(400, "name, host and username are required");
88
+ }
89
+ // Adding must never overwrite an existing entry: that would let a caller
90
+ // repoint a connection without the checks in update().
91
+ if (await this.store.getConnection(input.name)) {
92
+ throw new ControlPlaneError(409, `Connection ${input.name} already exists`);
93
+ }
14
94
  await this.store.upsertConnection({
15
95
  name: input.name,
16
96
  host: input.host,
@@ -28,7 +108,7 @@ export class ConnectionService {
28
108
  async update(name, input) {
29
109
  const existing = await this.store.getConnection(name);
30
110
  if (!existing)
31
- throw new Error(`Connection ${name} not found`);
111
+ throw new ControlPlaneError(404, `Connection ${name} not found`);
32
112
  const next = {
33
113
  ...existing,
34
114
  host: input.host ?? existing.host,
@@ -38,9 +118,28 @@ export class ConnectionService {
38
118
  settings: input.settings ? { ...(existing.settings || {}), ...input.settings } : existing.settings,
39
119
  policy: input.policy ?? existing.policy
40
120
  };
121
+ // A saved password must never follow the connection to a different
122
+ // endpoint or user. Changing host, port or username therefore needs the
123
+ // password in the same request.
124
+ const endpointChanged = connectionEndpointChanged(existing, next);
125
+ const newPassword = typeof input.password === "string" && input.password ? input.password : undefined;
126
+ if (endpointChanged && !newPassword && await getPassword(name)) {
127
+ throw new ControlPlaneError(400, "Changing host, port or username requires entering the password again in the same request");
128
+ }
41
129
  await this.store.upsertConnection(next);
42
- if (input.storePassword && input.password) {
43
- await setPassword(name, input.password);
130
+ // An earlier result says nothing about new details or a new password.
131
+ if (endpointChanged || newPassword || next.privateKeyPath !== existing.privateKeyPath) {
132
+ this.lastTests.delete(name);
133
+ }
134
+ if (newPassword && (input.storePassword || endpointChanged)) {
135
+ if (input.storePassword === false) {
136
+ // The caller does not want it stored; drop the old one so it is not
137
+ // sent to the new endpoint.
138
+ await deletePassword(name);
139
+ }
140
+ else {
141
+ await setPassword(name, newPassword);
142
+ }
44
143
  }
45
144
  return this.get(name);
46
145
  }
@@ -64,16 +163,22 @@ export class ConnectionService {
64
163
  await setPassword(newName, password);
65
164
  }
66
165
  await deletePassword(oldName);
166
+ const lastTest = this.lastTests.get(oldName);
167
+ this.lastTests.delete(oldName);
168
+ if (lastTest)
169
+ this.lastTests.set(newName, lastTest);
67
170
  return this.get(newName);
68
171
  }
69
172
  async delete(name) {
70
173
  await this.store.deleteConnection(name);
71
174
  await deletePassword(name);
175
+ this.lastTests.delete(name);
72
176
  }
73
177
  async setConnectionPassword(name, password) {
74
178
  const existing = await this.store.getConnection(name);
75
179
  if (!existing)
76
180
  throw new Error(`Connection ${name} not found`);
77
181
  await setPassword(name, password);
182
+ this.lastTests.delete(name);
78
183
  }
79
184
  }