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
@@ -10,46 +10,143 @@ import { ConnectionService } from "./connectionService.js";
10
10
  import { RuntimeService } from "./runtimeService.js";
11
11
  import { ClientIntegrationService } from "./clientIntegrationService.js";
12
12
  import { renderControlPlaneHtml } from "./ui.js";
13
+ import { ControlPlaneError } from "./errors.js";
14
+ import { HEALTH_SERVICE_ID } from "./serverShared.js";
15
+ import { CONTROL_TOKEN_HEADER, ensureControlToken, isAllowedHostHeader, isAllowedOrigin, tokensMatch } from "./auth.js";
16
+ /** Largest accepted request body. Bigger bodies get 413. */
17
+ export const MAX_BODY_BYTES = 1024 * 1024;
13
18
  export async function startControlPlaneServer(opts) {
14
19
  const host = opts?.host || process.env.MCP_FOR_I_CONTROL_HOST || "127.0.0.1";
15
- const port = normalizePort(opts?.port || Number(process.env.MCP_FOR_I_CONTROL_PORT || 3980));
20
+ const port = typeof opts?.port === "number"
21
+ ? normalizePort(opts.port, true)
22
+ : normalizePort(Number(process.env.MCP_FOR_I_CONTROL_PORT || 3980));
16
23
  const rootDir = opts?.rootDir || process.env.MCP_FOR_I_ROOT_DIR || getDefaultRootDir();
24
+ const token = opts?.token || ensureControlToken();
17
25
  const store = new ConfigStore();
18
26
  const connections = new ConnectionService(store);
19
27
  const runtime = new RuntimeService(rootDir);
20
28
  const clients = new ClientIntegrationService(rootDir);
29
+ const guard = { token, port, extraHosts: extraAllowedHosts(host) };
30
+ let markShutdown = () => undefined;
31
+ const shutdownRequested = new Promise(resolve => { markShutdown = resolve; });
32
+ const requestShutdown = () => {
33
+ markShutdown(true);
34
+ server.close();
35
+ server.closeAllConnections();
36
+ };
21
37
  const server = http.createServer(async (req, res) => {
22
38
  try {
23
- await routeRequest(req, res, { store, connections, runtime, clients });
39
+ if (!checkRequest(req, res, guard))
40
+ return;
41
+ await routeRequest(req, res, { store, connections, runtime, clients, token, requestShutdown });
24
42
  }
25
43
  catch (err) {
26
- sendJson(res, 500, { error: err?.message || String(err) });
44
+ if (res.headersSent) {
45
+ res.destroy();
46
+ return;
47
+ }
48
+ const status = err instanceof ControlPlaneError ? err.status : 500;
49
+ if (status === 413)
50
+ res.setHeader("Connection", "close");
51
+ sendJson(res, status, { error: err?.message || String(err) });
27
52
  }
28
53
  });
29
54
  await new Promise((resolve, reject) => {
30
55
  server.once("error", reject);
31
56
  server.listen(port, host, () => resolve());
32
57
  });
58
+ guard.port = server.address().port;
59
+ const closed = new Promise(resolve => server.once("close", () => {
60
+ markShutdown(false);
61
+ resolve();
62
+ }));
33
63
  return {
34
64
  host,
35
- port,
65
+ port: guard.port,
66
+ token,
67
+ closed,
68
+ shutdownRequested,
36
69
  close: () => new Promise((resolve, reject) => {
37
70
  server.close(err => (err ? reject(err) : resolve()));
71
+ server.closeIdleConnections();
38
72
  })
39
73
  };
40
74
  }
75
+ /**
76
+ * Rejects requests that did not come from the local Web UI or CLI: a wrong Host
77
+ * header (DNS rebinding), a foreign Origin, a missing token on /api routes,
78
+ * non-JSON bodies, and bodies over the size limit.
79
+ */
80
+ function checkRequest(req, res, guard) {
81
+ if (!isAllowedHostHeader(req.headers.host, guard.port, guard.extraHosts)) {
82
+ rejectRequest(req, res, 421, "Host not allowed");
83
+ return false;
84
+ }
85
+ const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
86
+ if (!isAllowedOrigin(origin, guard.port, guard.extraHosts) || req.headers["sec-fetch-site"] === "cross-site") {
87
+ rejectRequest(req, res, 403, "Cross-origin requests are not allowed");
88
+ return false;
89
+ }
90
+ const pathname = (req.url || "/").split("?")[0];
91
+ if (!pathname.startsWith("/api/"))
92
+ return true;
93
+ // /api/health answers without a token so launchers can probe liveness,
94
+ // but it only returns details when the token is present.
95
+ const isHealth = pathname === "/api/health" && (req.method === "GET" || req.method === "HEAD");
96
+ if (!isHealth && !tokensMatch(guard.token, req.headers[CONTROL_TOKEN_HEADER.toLowerCase()])) {
97
+ rejectRequest(req, res, 401, `Missing or invalid ${CONTROL_TOKEN_HEADER} header`);
98
+ return false;
99
+ }
100
+ const method = req.method || "GET";
101
+ if (method !== "GET" && method !== "HEAD" && method !== "DELETE") {
102
+ const contentType = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
103
+ if (contentType !== "application/json") {
104
+ rejectRequest(req, res, 415, "Content-Type must be application/json");
105
+ return false;
106
+ }
107
+ }
108
+ const declared = Number(req.headers["content-length"]);
109
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
110
+ rejectRequest(req, res, 413, `Request body exceeds ${MAX_BODY_BYTES} bytes`);
111
+ return false;
112
+ }
113
+ return true;
114
+ }
115
+ function rejectRequest(req, res, status, error) {
116
+ // Do not read a rejected body; close the connection once the reply is sent.
117
+ res.setHeader("Connection", "close");
118
+ sendJson(res, status, { error });
119
+ req.resume();
120
+ }
121
+ function extraAllowedHosts(bindHost) {
122
+ const lower = bindHost.trim().toLowerCase();
123
+ if (!lower || lower === "0.0.0.0" || lower === "::" || lower === "[::]")
124
+ return [];
125
+ return [lower];
126
+ }
41
127
  async function routeRequest(req, res, services) {
42
128
  const method = req.method || "GET";
43
129
  const url = new URL(req.url || "/", "http://127.0.0.1");
44
130
  const pathname = url.pathname;
45
131
  if (method === "GET" && pathname === "/") {
46
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
47
- res.end(renderControlPlaneHtml());
132
+ res.writeHead(200, {
133
+ "Content-Type": "text/html; charset=utf-8",
134
+ "Cache-Control": "no-store",
135
+ "X-Frame-Options": "DENY",
136
+ "Content-Security-Policy": "frame-ancestors 'none'",
137
+ "Referrer-Policy": "no-referrer",
138
+ "X-Content-Type-Options": "nosniff"
139
+ });
140
+ res.end(renderControlPlaneHtml(services.token));
48
141
  return;
49
142
  }
50
143
  if (method === "GET" && pathname === "/api/health") {
144
+ if (!tokensMatch(services.token, req.headers[CONTROL_TOKEN_HEADER.toLowerCase()])) {
145
+ sendJson(res, 200, { ok: true, service: HEALTH_SERVICE_ID });
146
+ return;
147
+ }
51
148
  const credentialStore = await getCredentialStoreStatus();
52
- sendJson(res, 200, { ok: true, keychainAvailable: credentialStore.keychainAvailable, credentialStore });
149
+ sendJson(res, 200, { ok: true, service: HEALTH_SERVICE_ID, keychainAvailable: credentialStore.keychainAvailable, credentialStore });
53
150
  return;
54
151
  }
55
152
  if (method === "GET" && pathname === "/api/settings") {
@@ -84,10 +181,20 @@ async function routeRequest(req, res, services) {
84
181
  sendJson(res, 200, { settings });
85
182
  return;
86
183
  }
184
+ if (method === "POST" && pathname === "/api/shutdown") {
185
+ // Answer first, then stop listening and drop the remaining connections.
186
+ res.once("finish", () => setImmediate(services.requestShutdown));
187
+ sendJson(res, 200, { stopping: true });
188
+ return;
189
+ }
87
190
  if (method === "GET" && pathname === "/api/sessions") {
88
191
  sendJson(res, 200, { snapshot: await readSessionSnapshot() });
89
192
  return;
90
193
  }
194
+ if (method === "GET" && pathname === "/api/tn5250/live") {
195
+ sendJson(res, 200, { views: await readLiveViews() });
196
+ return;
197
+ }
91
198
  if (method === "GET" && pathname === "/api/runtime/status") {
92
199
  sendJson(res, 200, { jobs: services.runtime.getStatus() });
93
200
  return;
@@ -161,6 +268,18 @@ async function routeRequest(req, res, services) {
161
268
  sendJson(res, 201, { connection: created });
162
269
  return;
163
270
  }
271
+ if (method === "POST" && pathname === "/api/connections/test") {
272
+ const body = await readJson(req);
273
+ const result = await services.connections.testDetails(body);
274
+ sendJson(res, 200, { result });
275
+ return;
276
+ }
277
+ const matchTest = pathname.match(/^\/api\/connections\/([^/]+)\/test$/);
278
+ if (matchTest && method === "POST") {
279
+ const result = await services.connections.testSaved(decodeURIComponent(matchTest[1]));
280
+ sendJson(res, 200, { result });
281
+ return;
282
+ }
164
283
  const matchConn = pathname.match(/^\/api\/connections\/([^/]+)$/);
165
284
  if (matchConn) {
166
285
  const name = decodeURIComponent(matchConn[1]);
@@ -227,20 +346,33 @@ function sendNoContent(res) {
227
346
  }
228
347
  async function readJson(req) {
229
348
  const chunks = [];
349
+ let total = 0;
230
350
  for await (const chunk of req) {
231
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
351
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
352
+ total += buf.length;
353
+ if (total > MAX_BODY_BYTES) {
354
+ throw new ControlPlaneError(413, `Request body exceeds ${MAX_BODY_BYTES} bytes`);
355
+ }
356
+ chunks.push(buf);
232
357
  }
233
358
  const raw = Buffer.concat(chunks).toString("utf8");
234
359
  if (!raw.trim())
235
360
  return {};
361
+ let parsed;
236
362
  try {
237
- return JSON.parse(raw);
363
+ parsed = JSON.parse(raw);
238
364
  }
239
365
  catch {
240
- throw new Error("Invalid JSON body");
366
+ throw new ControlPlaneError(400, "Invalid JSON body");
367
+ }
368
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
369
+ throw new ControlPlaneError(400, "JSON body must be an object");
241
370
  }
371
+ return parsed;
242
372
  }
243
- function normalizePort(port) {
373
+ function normalizePort(port, allowEphemeral = false) {
374
+ if (allowEphemeral && port === 0)
375
+ return 0;
244
376
  if (!Number.isFinite(port) || port <= 0 || port > 65535)
245
377
  return 3980;
246
378
  return Math.floor(port);
@@ -264,6 +396,41 @@ function parseNumberSetting(input, min, max, key) {
264
396
  }
265
397
  return normalized;
266
398
  }
399
+ /**
400
+ * Live 5250 views: each MCP process that serves one writes a file with its
401
+ * address and sessions. Entries whose server no longer answers are dropped.
402
+ */
403
+ async function readLiveViews() {
404
+ const dir = path.join(getConfigDir(), "tn5250-live");
405
+ let names = [];
406
+ try {
407
+ names = (await fsp.readdir(dir)).filter(name => name.endsWith(".json"));
408
+ }
409
+ catch {
410
+ return [];
411
+ }
412
+ const views = [];
413
+ for (const name of names) {
414
+ const file = path.join(dir, name);
415
+ try {
416
+ const entry = JSON.parse(await fsp.readFile(file, "utf8"));
417
+ if (!entry || typeof entry.url !== "string" || typeof entry.port !== "number")
418
+ throw new Error("bad entry");
419
+ const controller = new AbortController();
420
+ const timer = setTimeout(() => controller.abort(), 1000);
421
+ const alive = await fetch(`http://127.0.0.1:${entry.port}/api/sessions?t=${new URL(entry.url).searchParams.get("t")}`, { signal: controller.signal })
422
+ .then(r => r.ok).catch(() => false);
423
+ clearTimeout(timer);
424
+ if (!alive)
425
+ throw new Error("gone");
426
+ views.push({ pid: entry.pid, port: entry.port, url: entry.url, sessions: entry.sessions || [], updatedAt: entry.updatedAt });
427
+ }
428
+ catch {
429
+ await fsp.rm(file, { force: true }).catch(() => undefined);
430
+ }
431
+ }
432
+ return views;
433
+ }
267
434
  async function readSessionSnapshot() {
268
435
  const snapshotPath = path.join(getConfigDir(), "session-state.json");
269
436
  try {
@@ -1,16 +1,35 @@
1
- export function getControlPlaneUrl() {
2
- const host = process.env.MCP_FOR_I_CONTROL_HOST || "127.0.0.1";
1
+ import { CONTROL_TOKEN_HEADER, readControlToken } from "./auth.js";
2
+ /** Marker returned by /api/health so callers know they reached this service. */
3
+ export const HEALTH_SERVICE_ID = "mcp-for-i-control";
4
+ export function getControlPlanePort() {
3
5
  const portRaw = Number(process.env.MCP_FOR_I_CONTROL_PORT || 3980);
4
- const port = Number.isFinite(portRaw) && portRaw > 0 && portRaw <= 65535 ? Math.floor(portRaw) : 3980;
5
- return `http://${host}:${port}`;
6
+ return Number.isFinite(portRaw) && portRaw > 0 && portRaw <= 65535 ? Math.floor(portRaw) : 3980;
7
+ }
8
+ export function getControlPlaneUrl() {
9
+ const configured = (process.env.MCP_FOR_I_CONTROL_HOST || "").trim();
10
+ // A wildcard bind address is not a valid Host for the server's checks.
11
+ const host = !configured || ["0.0.0.0", "::", "[::]"].includes(configured) ? "127.0.0.1" : configured;
12
+ const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
13
+ return `http://${urlHost}:${getControlPlanePort()}`;
14
+ }
15
+ /** Headers the CLI sends to the control plane (includes the per-install token). */
16
+ export function getControlPlaneAuthHeaders() {
17
+ const token = readControlToken();
18
+ return token ? { [CONTROL_TOKEN_HEADER]: token } : {};
6
19
  }
7
20
  export async function isControlPlaneRunning(baseUrl = getControlPlaneUrl()) {
8
21
  try {
9
22
  const controller = new AbortController();
10
23
  const timeout = setTimeout(() => controller.abort(), 1500);
11
- const res = await fetch(`${baseUrl}/api/health`, { signal: controller.signal });
24
+ const res = await fetch(`${baseUrl}/api/health`, {
25
+ signal: controller.signal,
26
+ headers: getControlPlaneAuthHeaders()
27
+ });
12
28
  clearTimeout(timeout);
13
- return res.ok;
29
+ if (!res.ok)
30
+ return false;
31
+ const body = await res.json().catch(() => null);
32
+ return Boolean(body?.ok);
14
33
  }
15
34
  catch {
16
35
  return false;
@@ -4,7 +4,7 @@ import path from "path";
4
4
  import { spawn } from "child_process";
5
5
  import { getConfigDir } from "../config/store.js";
6
6
  import { getPlatformDescriptor } from "./platform.js";
7
- import { isControlPlaneRunning } from "./serverShared.js";
7
+ import { getControlPlaneAuthHeaders, getControlPlanePort, getControlPlaneUrl, isControlPlaneRunning } from "./serverShared.js";
8
8
  const SERVICE_ID = "mcp-for-i-controlplane";
9
9
  const MAC_LABEL = "com.mcpfori.controlplane";
10
10
  const RC_BEGIN = "# >>> mcp-for-i control >>>";
@@ -87,6 +87,40 @@ export async function disableBackgroundService(scriptPath, nodePath = process.ex
87
87
  }
88
88
  return getServiceStatus(scriptPath, nodePath);
89
89
  }
90
+ export async function stopControlPlane(scriptPath, nodePath = process.execPath, timeoutMs = 10000) {
91
+ const status = await getServiceStatus(scriptPath, nodePath).catch(() => undefined);
92
+ const base = { serviceEnabled: Boolean(status?.enabled), managedBy: status?.managedBy };
93
+ if (!(await isControlPlaneRunning()))
94
+ return { ...base, wasRunning: false, stopped: true };
95
+ if (process.platform === "darwin" && (await pathExists(getMacLaunchAgentPath()))) {
96
+ await runQuiet("launchctl", ["unload", getMacLaunchAgentPath()]);
97
+ }
98
+ else if (process.platform === "linux" && (await canUseSystemdUser()) && (await isSystemdActive())) {
99
+ await runQuiet("systemctl", ["--user", "stop", SERVICE_ID]);
100
+ }
101
+ let detail;
102
+ if (await isControlPlaneRunning()) {
103
+ try {
104
+ const res = await fetch(`${getControlPlaneUrl()}/api/shutdown`, {
105
+ method: "POST",
106
+ headers: { ...getControlPlaneAuthHeaders(), "Content-Type": "application/json" },
107
+ body: "{}"
108
+ });
109
+ if (!res.ok)
110
+ detail = `shutdown request failed with HTTP ${res.status}`;
111
+ }
112
+ catch {
113
+ // the server may drop the connection while it stops
114
+ }
115
+ }
116
+ const deadline = Date.now() + timeoutMs;
117
+ while (Date.now() < deadline) {
118
+ if (!(await isControlPlaneRunning()))
119
+ return { ...base, wasRunning: true, stopped: true };
120
+ await wait(250);
121
+ }
122
+ return { ...base, wasRunning: true, stopped: false, detail };
123
+ }
90
124
  export async function ensureControlPlaneRunning(scriptPath, nodePath = process.execPath, timeoutMs = 15000) {
91
125
  if (await isControlPlaneRunning()) {
92
126
  return { started: false };
@@ -121,6 +155,7 @@ async function enableWindowsStartup(scriptPath, nodePath) {
121
155
  const startupScript = [
122
156
  "@echo off",
123
157
  "setlocal",
158
+ `set "MCP_FOR_I_CONTROL_PORT=${getControlPlanePort()}"`,
124
159
  `start \"\" /MIN ${command}`,
125
160
  "exit /b 0"
126
161
  ].join("\r\n");
@@ -133,28 +168,28 @@ async function enableMacLaunchAgent(scriptPath, nodePath) {
133
168
  const errPath = path.join(configDir, "controlplane.err.log");
134
169
  await fs.mkdir(path.dirname(agentPath), { recursive: true });
135
170
  await fs.mkdir(configDir, { recursive: true });
136
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
137
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
138
- <plist version="1.0">
139
- <dict>
140
- <key>Label</key>
141
- <string>${MAC_LABEL}</string>
142
- <key>ProgramArguments</key>
143
- <array>
144
- <string>${escapeXml(nodePath)}</string>
145
- <string>${escapeXml(scriptPath)}</string>
146
- <string>serve</string>
147
- </array>
148
- <key>RunAtLoad</key>
149
- <true/>
150
- <key>KeepAlive</key>
151
- <true/>
152
- <key>StandardOutPath</key>
153
- <string>${escapeXml(outPath)}</string>
154
- <key>StandardErrorPath</key>
155
- <string>${escapeXml(errPath)}</string>
156
- </dict>
157
- </plist>
171
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
172
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
173
+ <plist version="1.0">
174
+ <dict>
175
+ <key>Label</key>
176
+ <string>${MAC_LABEL}</string>
177
+ <key>ProgramArguments</key>
178
+ <array>
179
+ <string>${escapeXml(nodePath)}</string>
180
+ <string>${escapeXml(scriptPath)}</string>
181
+ <string>serve</string>
182
+ </array>
183
+ <key>RunAtLoad</key>
184
+ <true/>
185
+ <key>KeepAlive</key>
186
+ <true/>
187
+ <key>StandardOutPath</key>
188
+ <string>${escapeXml(outPath)}</string>
189
+ <key>StandardErrorPath</key>
190
+ <string>${escapeXml(errPath)}</string>
191
+ </dict>
192
+ </plist>
158
193
  `;
159
194
  await fs.writeFile(agentPath, plist, "utf8");
160
195
  await runQuiet("launchctl", ["unload", agentPath]);
@@ -170,20 +205,21 @@ async function enableLinuxSystemdService(scriptPath, nodePath) {
170
205
  const configDir = getConfigDir();
171
206
  await fs.mkdir(path.dirname(unitPath), { recursive: true });
172
207
  await fs.mkdir(configDir, { recursive: true });
173
- const unit = `[Unit]
174
- Description=MCP-for-i control plane
175
- After=network.target
176
-
177
- [Service]
178
- Type=simple
179
- ExecStart=${systemdQuote(nodePath)} ${systemdQuote(scriptPath)} serve
180
- Restart=on-failure
181
- RestartSec=3
182
- WorkingDirectory=${systemdQuote(path.dirname(scriptPath))}
183
- Environment=MCP_FOR_I_CONTROL_HOST=127.0.0.1
184
-
185
- [Install]
186
- WantedBy=default.target
208
+ const unit = `[Unit]
209
+ Description=MCP-for-i control plane
210
+ After=network.target
211
+
212
+ [Service]
213
+ Type=simple
214
+ ExecStart=${systemdQuote(nodePath)} ${systemdQuote(scriptPath)} serve
215
+ Restart=on-failure
216
+ RestartSec=3
217
+ WorkingDirectory=${systemdQuote(path.dirname(scriptPath))}
218
+ Environment=MCP_FOR_I_CONTROL_HOST=127.0.0.1
219
+ Environment=MCP_FOR_I_CONTROL_PORT=${getControlPlanePort()}
220
+
221
+ [Install]
222
+ WantedBy=default.target
187
223
  `;
188
224
  await fs.writeFile(unitPath, unit, "utf8");
189
225
  await runOrThrow("systemctl", ["--user", "daemon-reload"]);
@@ -214,73 +250,84 @@ async function disableBashRcHook() {
214
250
  }
215
251
  }
216
252
  async function writeBashLauncher(launcherPath, scriptPath, nodePath) {
217
- const configDir = getConfigDir();
218
- const logFile = path.join(configDir, "controlplane.log");
219
- const pythonHealthUrl = JSON.stringify("http://127.0.0.1:3980/api/health");
220
- const script = `#!/usr/bin/env bash
221
- set -euo pipefail
222
-
223
- URL="http://127.0.0.1:3980/api/health"
224
- LOG_FILE=${shellQuote(logFile)}
225
- LOCK_DIR="\${XDG_RUNTIME_DIR:-/tmp}/${SERVICE_ID}.lock"
226
-
227
- mkdir -p "$(dirname "$LOG_FILE")"
228
-
229
- health_check() {
230
- if command -v curl >/dev/null 2>&1; then
231
- curl -fsS --max-time 1 "$URL" >/dev/null 2>&1
232
- return $?
233
- fi
234
-
235
- if command -v wget >/dev/null 2>&1; then
236
- wget -q -T 1 -O - "$URL" >/dev/null 2>&1
237
- return $?
238
- fi
239
-
240
- if command -v python3 >/dev/null 2>&1; then
241
- python3 - <<'PY' >/dev/null 2>&1
242
- import sys, urllib.request
243
- try:
244
- with urllib.request.urlopen(${pythonHealthUrl}, timeout=1) as response:
245
- sys.exit(0 if response.status == 200 else 1)
246
- except Exception:
247
- sys.exit(1)
248
- PY
249
- return $?
250
- fi
251
-
252
- return 1
253
- }
254
-
255
- if health_check; then
256
- exit 0
257
- fi
258
-
259
- if mkdir "$LOCK_DIR" 2>/dev/null; then
260
- trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT
261
- else
262
- exit 0
263
- fi
264
-
265
- if health_check; then
266
- exit 0
267
- fi
268
-
269
- nohup ${shellQuote(nodePath)} ${shellQuote(scriptPath)} serve >>"$LOG_FILE" 2>&1 </dev/null &
270
- `;
253
+ const logFile = path.join(getConfigDir(), "controlplane.log");
254
+ const script = buildBashLauncherScript({ scriptPath, nodePath, logFile, port: getControlPlanePort() });
271
255
  await fs.writeFile(launcherPath, script, { encoding: "utf8", mode: 0o755 });
272
256
  }
257
+ /**
258
+ * Builds the Bash launcher. The port defaults to the one configured when the
259
+ * service was enabled and can be overridden with MCP_FOR_I_CONTROL_PORT.
260
+ */
261
+ export function buildBashLauncherScript(opts) {
262
+ const { scriptPath, nodePath, logFile } = opts;
263
+ const port = Number.isInteger(opts.port) && opts.port > 0 && opts.port <= 65535 ? opts.port : 3980;
264
+ return `#!/usr/bin/env bash
265
+ set -euo pipefail
266
+
267
+ PORT="\${MCP_FOR_I_CONTROL_PORT:-${port}}"
268
+ case "$PORT" in
269
+ ""|*[!0-9]*) PORT=${port} ;;
270
+ esac
271
+ URL="http://127.0.0.1:\${PORT}/api/health"
272
+ LOG_FILE=${shellQuote(logFile)}
273
+ LOCK_DIR="\${XDG_RUNTIME_DIR:-/tmp}/${SERVICE_ID}.lock"
274
+
275
+ mkdir -p "$(dirname "$LOG_FILE")"
276
+
277
+ health_check() {
278
+ if command -v curl >/dev/null 2>&1; then
279
+ curl -fsS --max-time 1 "$URL" >/dev/null 2>&1
280
+ return $?
281
+ fi
282
+
283
+ if command -v wget >/dev/null 2>&1; then
284
+ wget -q -T 1 -O - "$URL" >/dev/null 2>&1
285
+ return $?
286
+ fi
287
+
288
+ if command -v python3 >/dev/null 2>&1; then
289
+ HEALTH_URL="$URL" python3 - <<'PY' >/dev/null 2>&1
290
+ import os, sys, urllib.request
291
+ try:
292
+ with urllib.request.urlopen(os.environ["HEALTH_URL"], timeout=1) as response:
293
+ sys.exit(0 if response.status == 200 else 1)
294
+ except Exception:
295
+ sys.exit(1)
296
+ PY
297
+ return $?
298
+ fi
299
+
300
+ return 1
301
+ }
302
+
303
+ if health_check; then
304
+ exit 0
305
+ fi
306
+
307
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
308
+ trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT
309
+ else
310
+ exit 0
311
+ fi
312
+
313
+ if health_check; then
314
+ exit 0
315
+ fi
316
+
317
+ MCP_FOR_I_CONTROL_PORT="$PORT" nohup ${shellQuote(nodePath)} ${shellQuote(scriptPath)} serve >>"$LOG_FILE" 2>&1 </dev/null &
318
+ `;
319
+ }
273
320
  function createBashRcBlock(launcherPath, wslOnly) {
274
321
  const guard = wslOnly ? 'if [ -n "${WSL_DISTRO_NAME:-}" ]' : "if true";
275
- return `${RC_BEGIN}
276
- case $- in
277
- *i*) ;;
278
- *) return ;;
279
- esac
280
-
281
- ${guard} && [ -x ${shellQuote(launcherPath)} ]; then
282
- ${shellQuote(launcherPath)} >/dev/null 2>&1 &
283
- fi
322
+ return `${RC_BEGIN}
323
+ case $- in
324
+ *i*) ;;
325
+ *) return ;;
326
+ esac
327
+
328
+ ${guard} && [ -x ${shellQuote(launcherPath)} ]; then
329
+ ${shellQuote(launcherPath)} >/dev/null 2>&1 &
330
+ fi
284
331
  ${RC_END}`;
285
332
  }
286
333
  async function hasManagedRcBlock(rcPath) {