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.
- package/README.md +27 -0
- package/dist/config/store.js +229 -109
- package/dist/controlplane/auth.js +128 -0
- package/dist/controlplane/clientIntegrationService.js +83 -4
- package/dist/controlplane/commands.js +52 -11
- package/dist/controlplane/connectionService.js +110 -5
- package/dist/controlplane/connectionTest.js +329 -0
- package/dist/controlplane/errors.js +12 -0
- package/dist/controlplane/runtimeService.js +98 -26
- package/dist/controlplane/server.js +178 -11
- package/dist/controlplane/serverShared.js +25 -6
- package/dist/controlplane/serviceManager.js +147 -100
- package/dist/controlplane/ui.js +1783 -920
- package/dist/ibmi/CompileTools.js +29 -1
- package/dist/ibmi/IBMiContent.js +435 -63
- package/dist/ibmi/IbmDocs.js +685 -0
- package/dist/ibmi/LocalLanguageActions.js +129 -3
- package/dist/ibmi/Search.js +158 -52
- package/dist/ibmi/Tools.js +7 -10
- package/dist/ibmi/client.js +220 -31
- package/dist/ibmi/evfevent.js +256 -0
- package/dist/ibmi/knownHosts.js +190 -0
- package/dist/ibmi/libraryList.js +93 -0
- package/dist/ibmi/sourceInspect.js +815 -0
- package/dist/ibmi/sqlJob.js +888 -0
- package/dist/ibmi/sqlParse.js +317 -0
- package/dist/ibmi/tn5250/certTrust.js +59 -0
- package/dist/ibmi/tn5250/client.js +334 -0
- package/dist/ibmi/tn5250/datastream.js +109 -0
- package/dist/ibmi/tn5250/ebcdic.js +93 -0
- package/dist/ibmi/tn5250/ebcdicTables.js +27 -0
- package/dist/ibmi/tn5250/index.js +6 -0
- package/dist/ibmi/tn5250/screen.js +1033 -0
- package/dist/ibmi/tn5250/telnet.js +186 -0
- package/dist/ibmi/variables.js +18 -7
- package/dist/index.js +79 -24
- package/dist/mcp/annotations.js +155 -0
- package/dist/mcp/audit.js +490 -133
- package/dist/mcp/confirm.js +48 -0
- package/dist/mcp/context.js +43 -5
- package/dist/mcp/deployMethods.js +192 -0
- package/dist/mcp/logger.js +5 -10
- package/dist/mcp/modules/bulkMembers.js +476 -0
- package/dist/mcp/modules/changedLines.js +189 -0
- package/dist/mcp/modules/compare.js +198 -0
- package/dist/mcp/modules/compile.js +515 -0
- package/dist/mcp/modules/ifsOps.js +123 -0
- package/dist/mcp/modules/index.js +38 -0
- package/dist/mcp/modules/inspect.js +811 -0
- package/dist/mcp/modules/libl.js +246 -0
- package/dist/mcp/modules/members.js +171 -0
- package/dist/mcp/modules/objects.js +339 -0
- package/dist/mcp/modules/system.js +266 -0
- package/dist/mcp/modules/tn5250.js +800 -0
- package/dist/mcp/modules/tn5250App.js +194 -0
- package/dist/mcp/modules/tn5250Font.js +8 -0
- package/dist/mcp/modules/tn5250Live.js +221 -0
- package/dist/mcp/modules/tn5250LivePage.js +480 -0
- package/dist/mcp/policy.js +412 -0
- package/dist/mcp/tools.js +1719 -698
- package/dist/security/credentialStore.js +62 -13
- package/dist/security/localPaths.js +138 -0
- package/dist/security/redact.js +79 -0
- package/dist/util/atomicWrite.js +101 -0
- package/dist/util/fileLock.js +206 -0
- package/docs/capabilities.md +264 -220
- package/docs/configuration.md +193 -60
- package/docs/examples.md +109 -73
- package/docs/overview.md +15 -15
- package/docs/security.md +101 -16
- package/docs/tools.md +367 -188
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
+
parsed = JSON.parse(raw);
|
|
238
364
|
}
|
|
239
365
|
catch {
|
|
240
|
-
throw new
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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`, {
|
|
24
|
+
const res = await fetch(`${baseUrl}/api/health`, {
|
|
25
|
+
signal: controller.signal,
|
|
26
|
+
headers: getControlPlaneAuthHeaders()
|
|
27
|
+
});
|
|
12
28
|
clearTimeout(timeout);
|
|
13
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
218
|
-
const
|
|
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) {
|