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
@@ -4,6 +4,7 @@ import os from "os";
4
4
  import path from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { getPlatformDescriptor } from "./platform.js";
7
+ import { ControlPlaneError } from "./errors.js";
7
8
  import { disableBackgroundService, enableBackgroundService, getServiceStatus } from "./serviceManager.js";
8
9
  export class RuntimeService {
9
10
  rootDir;
@@ -13,6 +14,7 @@ export class RuntimeService {
13
14
  enableService: makeJob("enableService"),
14
15
  disableService: makeJob("disableService")
15
16
  };
17
+ pending = new Map();
16
18
  constructor(rootDir) {
17
19
  this.rootDir = rootDir;
18
20
  }
@@ -20,6 +22,7 @@ export class RuntimeService {
20
22
  return this.state;
21
23
  }
22
24
  async getVersions(options) {
25
+ const source = resolveSkillsSource(options);
23
26
  const mode = await this.detectExecutionModeQuiet();
24
27
  const checkedAt = new Date().toISOString();
25
28
  const mcpInstalled = await this.readLocalPackageVersion(this.rootDir);
@@ -27,8 +30,7 @@ export class RuntimeService {
27
30
  const mcpStatus = mcpInstalled && mcpLatest
28
31
  ? (mcpInstalled === mcpLatest ? "latest" : "update-available")
29
32
  : "unknown";
30
- const configuredRepo = sanitizeRepoUrl(options?.repoUrl) || process.env.MCP_FOR_I_SKILLS_REPO || DEFAULT_SKILLS_REPO;
31
- const configuredBranch = sanitizeBranch(options?.branch) || process.env.MCP_FOR_I_SKILLS_BRANCH || "main";
33
+ const { repoUrl: configuredRepo, branch: configuredBranch } = source;
32
34
  const skillsDir = path.join(this.rootDir, "skills");
33
35
  const skillsGit = path.join(skillsDir, ".git");
34
36
  const skills = {
@@ -89,14 +91,14 @@ export class RuntimeService {
89
91
  });
90
92
  }
91
93
  async updateSkills(options) {
94
+ // Validate before starting the job so bad input is a 400, not a failed job.
95
+ const { repoUrl, branch } = resolveSkillsSource(options);
92
96
  return this.runJob("updateSkills", async (push) => {
93
- const repoUrl = sanitizeRepoUrl(options?.repoUrl) || process.env.MCP_FOR_I_SKILLS_REPO || DEFAULT_SKILLS_REPO;
94
- const branch = sanitizeBranch(options?.branch) || process.env.MCP_FOR_I_SKILLS_BRANCH || "main";
95
97
  const skillsDir = path.join(this.rootDir, "skills");
96
98
  const hasSkills = await pathExists(skillsDir);
97
99
  push(`Using skills repository: ${repoUrl} (${branch})`);
98
100
  if (!hasSkills) {
99
- await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, repoUrl, skillsDir], this.rootDir, push);
101
+ await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, "--", repoUrl, skillsDir], this.rootDir, push);
100
102
  return;
101
103
  }
102
104
  const skillsGit = path.join(skillsDir, ".git");
@@ -104,7 +106,7 @@ export class RuntimeService {
104
106
  const currentRemote = await this.getGitRemoteUrl(skillsDir, push);
105
107
  if (currentRemote && currentRemote !== repoUrl) {
106
108
  push(`Switching skills remote from ${currentRemote} to ${repoUrl}`);
107
- await this.runCommand("git", ["remote", "set-url", "origin", repoUrl], skillsDir, push);
109
+ await this.runCommand("git", ["remote", "set-url", "--", "origin", repoUrl], skillsDir, push);
108
110
  }
109
111
  await this.runCommand("git", ["fetch", "origin", branch], skillsDir, push);
110
112
  await this.runCommand("git", ["checkout", branch], skillsDir, push);
@@ -115,7 +117,7 @@ export class RuntimeService {
115
117
  push(`Existing skills directory is not a git repository. Backing it up to ${backupDir}`);
116
118
  await fs.rename(skillsDir, backupDir);
117
119
  try {
118
- await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, repoUrl, skillsDir], this.rootDir, push);
120
+ await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, "--", repoUrl, skillsDir], this.rootDir, push);
119
121
  }
120
122
  catch (error) {
121
123
  push("Clone failed. Restoring previous skills directory.");
@@ -152,6 +154,15 @@ export class RuntimeService {
152
154
  push(`Target: ${status.target}`);
153
155
  });
154
156
  }
157
+ /** Resolves when the given job has finished (immediately if it is not running). */
158
+ async waitForJob(kind) {
159
+ await this.pending.get(kind);
160
+ return this.state[kind];
161
+ }
162
+ /**
163
+ * Starts a job in the background and returns it right away with status
164
+ * "running". Progress is read through getStatus() (GET /api/runtime/status).
165
+ */
155
166
  async runJob(kind, fn) {
156
167
  const current = this.state[kind];
157
168
  if (current.status === "running") {
@@ -167,19 +178,23 @@ export class RuntimeService {
167
178
  job.output.splice(0, job.output.length - 300);
168
179
  }
169
180
  };
170
- try {
171
- await fn(push);
172
- job.status = "success";
173
- }
174
- catch (err) {
175
- job.status = "failed";
176
- job.error = err?.message || String(err);
177
- push(`ERROR: ${job.error}`);
178
- }
179
- finally {
180
- job.finishedAt = new Date().toISOString();
181
- }
182
- return job;
181
+ const done = (async () => {
182
+ try {
183
+ await fn(push);
184
+ job.status = "success";
185
+ }
186
+ catch (err) {
187
+ job.status = "failed";
188
+ job.error = err?.message || String(err);
189
+ push(`ERROR: ${job.error}`);
190
+ }
191
+ finally {
192
+ job.finishedAt = new Date().toISOString();
193
+ this.pending.delete(kind);
194
+ }
195
+ })();
196
+ this.pending.set(kind, done);
197
+ return { ...job, output: [...job.output] };
183
198
  }
184
199
  runCommand(command, args, cwd, push) {
185
200
  return new Promise((resolve, reject) => {
@@ -379,7 +394,7 @@ export class RuntimeService {
379
394
  }
380
395
  async getRemoteBranchCommitQuiet(repoUrl, branch, short = false) {
381
396
  try {
382
- const out = await this.runCommandCaptureSilent("git", ["ls-remote", repoUrl, branch], this.rootDir);
397
+ const out = await this.runCommandCaptureSilent("git", ["ls-remote", "--", repoUrl, branch], this.rootDir);
383
398
  const firstLine = out.split(/\r?\n/).find(Boolean);
384
399
  if (!firstLine)
385
400
  return undefined;
@@ -453,22 +468,79 @@ async function resolveNpmLaunch() {
453
468
  }
454
469
  return { mode: "command", path: process.platform === "win32" ? "npm.cmd" : "npm" };
455
470
  }
456
- function sanitizeRepoUrl(value) {
457
- if (!value)
471
+ /**
472
+ * Picks the skills repository and branch (request, then environment, then
473
+ * defaults) and validates them. Throws a 400 error for unsafe values.
474
+ */
475
+ export function resolveSkillsSource(options) {
476
+ const repoUrl = sanitizeRepoUrl(options?.repoUrl)
477
+ || sanitizeRepoUrl(process.env.MCP_FOR_I_SKILLS_REPO)
478
+ || DEFAULT_SKILLS_REPO;
479
+ const branch = sanitizeBranch(options?.branch)
480
+ || sanitizeBranch(process.env.MCP_FOR_I_SKILLS_BRANCH)
481
+ || "main";
482
+ return { repoUrl, branch };
483
+ }
484
+ /**
485
+ * Accepts only https://, ssh:// and scp-style (user@host:path) git URLs.
486
+ * Anything that git could read as an option (leading "-") or that uses another
487
+ * transport (ext::, file://, local paths) is rejected.
488
+ */
489
+ export function sanitizeRepoUrl(value) {
490
+ if (typeof value !== "string")
458
491
  return undefined;
459
492
  const trimmed = value.trim();
460
493
  if (!trimmed)
461
494
  return undefined;
462
- return trimmed;
495
+ const invalid = () => new ControlPlaneError(400, "repoUrl must be an https://, ssh:// or user@host:path git URL");
496
+ if (trimmed.length > 2048 || trimmed.startsWith("-") || /[\s\x00-\x1f\x7f]/.test(trimmed)) {
497
+ throw invalid();
498
+ }
499
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
500
+ let parsed;
501
+ try {
502
+ parsed = new URL(trimmed);
503
+ }
504
+ catch {
505
+ throw invalid();
506
+ }
507
+ if (parsed.protocol !== "https:" && parsed.protocol !== "ssh:")
508
+ throw invalid();
509
+ if (!parsed.hostname || parsed.hostname.startsWith("-") || parsed.username.startsWith("-"))
510
+ throw invalid();
511
+ return trimmed;
512
+ }
513
+ // scp-like syntax: git@github.com:owner/repo.git
514
+ if (/^[A-Za-z0-9._-]+@[A-Za-z0-9][A-Za-z0-9.-]*:[A-Za-z0-9._~/-]+$/.test(trimmed) && !trimmed.includes("::")) {
515
+ return trimmed;
516
+ }
517
+ throw invalid();
463
518
  }
464
- function sanitizeBranch(value) {
465
- if (!value)
519
+ /** Validates a branch name using the git check-ref-format rules. */
520
+ export function sanitizeBranch(value) {
521
+ if (typeof value !== "string")
466
522
  return undefined;
467
523
  const trimmed = value.trim();
468
524
  if (!trimmed)
469
525
  return undefined;
526
+ if (!isSafeBranchName(trimmed)) {
527
+ throw new ControlPlaneError(400, `Invalid branch name: ${JSON.stringify(trimmed.slice(0, 100))}`);
528
+ }
470
529
  return trimmed;
471
530
  }
531
+ function isSafeBranchName(name) {
532
+ if (name.length > 255)
533
+ return false;
534
+ if (name.startsWith("-") || name === "@" || name === "HEAD")
535
+ return false;
536
+ if (/[\x00-\x20\x7f~^:?*[\\]/.test(name))
537
+ return false;
538
+ if (name.includes("..") || name.includes("@{") || name.includes("//"))
539
+ return false;
540
+ if (name.startsWith("/") || name.endsWith("/") || name.endsWith("."))
541
+ return false;
542
+ return name.split("/").every(part => part.length > 0 && !part.startsWith(".") && !part.endsWith(".lock"));
543
+ }
472
544
  async function safeRename(from, to) {
473
545
  try {
474
546
  await fs.rename(from, to);
@@ -10,46 +10,132 @@ 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) };
21
30
  const server = http.createServer(async (req, res) => {
22
31
  try {
23
- await routeRequest(req, res, { store, connections, runtime, clients });
32
+ if (!checkRequest(req, res, guard))
33
+ return;
34
+ await routeRequest(req, res, { store, connections, runtime, clients, token });
24
35
  }
25
36
  catch (err) {
26
- sendJson(res, 500, { error: err?.message || String(err) });
37
+ if (res.headersSent) {
38
+ res.destroy();
39
+ return;
40
+ }
41
+ const status = err instanceof ControlPlaneError ? err.status : 500;
42
+ if (status === 413)
43
+ res.setHeader("Connection", "close");
44
+ sendJson(res, status, { error: err?.message || String(err) });
27
45
  }
28
46
  });
29
47
  await new Promise((resolve, reject) => {
30
48
  server.once("error", reject);
31
49
  server.listen(port, host, () => resolve());
32
50
  });
51
+ guard.port = server.address().port;
52
+ const closed = new Promise(resolve => server.once("close", () => resolve()));
33
53
  return {
34
54
  host,
35
- port,
55
+ port: guard.port,
56
+ token,
57
+ closed,
36
58
  close: () => new Promise((resolve, reject) => {
37
59
  server.close(err => (err ? reject(err) : resolve()));
60
+ server.closeIdleConnections();
38
61
  })
39
62
  };
40
63
  }
64
+ /**
65
+ * Rejects requests that did not come from the local Web UI or CLI: a wrong Host
66
+ * header (DNS rebinding), a foreign Origin, a missing token on /api routes,
67
+ * non-JSON bodies, and bodies over the size limit.
68
+ */
69
+ function checkRequest(req, res, guard) {
70
+ if (!isAllowedHostHeader(req.headers.host, guard.port, guard.extraHosts)) {
71
+ rejectRequest(req, res, 421, "Host not allowed");
72
+ return false;
73
+ }
74
+ const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
75
+ if (!isAllowedOrigin(origin, guard.port, guard.extraHosts) || req.headers["sec-fetch-site"] === "cross-site") {
76
+ rejectRequest(req, res, 403, "Cross-origin requests are not allowed");
77
+ return false;
78
+ }
79
+ const pathname = (req.url || "/").split("?")[0];
80
+ if (!pathname.startsWith("/api/"))
81
+ return true;
82
+ // /api/health answers without a token so launchers can probe liveness,
83
+ // but it only returns details when the token is present.
84
+ const isHealth = pathname === "/api/health" && (req.method === "GET" || req.method === "HEAD");
85
+ if (!isHealth && !tokensMatch(guard.token, req.headers[CONTROL_TOKEN_HEADER.toLowerCase()])) {
86
+ rejectRequest(req, res, 401, `Missing or invalid ${CONTROL_TOKEN_HEADER} header`);
87
+ return false;
88
+ }
89
+ const method = req.method || "GET";
90
+ if (method !== "GET" && method !== "HEAD" && method !== "DELETE") {
91
+ const contentType = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
92
+ if (contentType !== "application/json") {
93
+ rejectRequest(req, res, 415, "Content-Type must be application/json");
94
+ return false;
95
+ }
96
+ }
97
+ const declared = Number(req.headers["content-length"]);
98
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
99
+ rejectRequest(req, res, 413, `Request body exceeds ${MAX_BODY_BYTES} bytes`);
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+ function rejectRequest(req, res, status, error) {
105
+ // Do not read a rejected body; close the connection once the reply is sent.
106
+ res.setHeader("Connection", "close");
107
+ sendJson(res, status, { error });
108
+ req.resume();
109
+ }
110
+ function extraAllowedHosts(bindHost) {
111
+ const lower = bindHost.trim().toLowerCase();
112
+ if (!lower || lower === "0.0.0.0" || lower === "::" || lower === "[::]")
113
+ return [];
114
+ return [lower];
115
+ }
41
116
  async function routeRequest(req, res, services) {
42
117
  const method = req.method || "GET";
43
118
  const url = new URL(req.url || "/", "http://127.0.0.1");
44
119
  const pathname = url.pathname;
45
120
  if (method === "GET" && pathname === "/") {
46
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
47
- res.end(renderControlPlaneHtml());
121
+ res.writeHead(200, {
122
+ "Content-Type": "text/html; charset=utf-8",
123
+ "Cache-Control": "no-store",
124
+ "X-Frame-Options": "DENY",
125
+ "Content-Security-Policy": "frame-ancestors 'none'",
126
+ "Referrer-Policy": "no-referrer",
127
+ "X-Content-Type-Options": "nosniff"
128
+ });
129
+ res.end(renderControlPlaneHtml(services.token));
48
130
  return;
49
131
  }
50
132
  if (method === "GET" && pathname === "/api/health") {
133
+ if (!tokensMatch(services.token, req.headers[CONTROL_TOKEN_HEADER.toLowerCase()])) {
134
+ sendJson(res, 200, { ok: true, service: HEALTH_SERVICE_ID });
135
+ return;
136
+ }
51
137
  const credentialStore = await getCredentialStoreStatus();
52
- sendJson(res, 200, { ok: true, keychainAvailable: credentialStore.keychainAvailable, credentialStore });
138
+ sendJson(res, 200, { ok: true, service: HEALTH_SERVICE_ID, keychainAvailable: credentialStore.keychainAvailable, credentialStore });
53
139
  return;
54
140
  }
55
141
  if (method === "GET" && pathname === "/api/settings") {
@@ -161,6 +247,18 @@ async function routeRequest(req, res, services) {
161
247
  sendJson(res, 201, { connection: created });
162
248
  return;
163
249
  }
250
+ if (method === "POST" && pathname === "/api/connections/test") {
251
+ const body = await readJson(req);
252
+ const result = await services.connections.testDetails(body);
253
+ sendJson(res, 200, { result });
254
+ return;
255
+ }
256
+ const matchTest = pathname.match(/^\/api\/connections\/([^/]+)\/test$/);
257
+ if (matchTest && method === "POST") {
258
+ const result = await services.connections.testSaved(decodeURIComponent(matchTest[1]));
259
+ sendJson(res, 200, { result });
260
+ return;
261
+ }
164
262
  const matchConn = pathname.match(/^\/api\/connections\/([^/]+)$/);
165
263
  if (matchConn) {
166
264
  const name = decodeURIComponent(matchConn[1]);
@@ -227,20 +325,33 @@ function sendNoContent(res) {
227
325
  }
228
326
  async function readJson(req) {
229
327
  const chunks = [];
328
+ let total = 0;
230
329
  for await (const chunk of req) {
231
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
330
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
331
+ total += buf.length;
332
+ if (total > MAX_BODY_BYTES) {
333
+ throw new ControlPlaneError(413, `Request body exceeds ${MAX_BODY_BYTES} bytes`);
334
+ }
335
+ chunks.push(buf);
232
336
  }
233
337
  const raw = Buffer.concat(chunks).toString("utf8");
234
338
  if (!raw.trim())
235
339
  return {};
340
+ let parsed;
236
341
  try {
237
- return JSON.parse(raw);
342
+ parsed = JSON.parse(raw);
238
343
  }
239
344
  catch {
240
- throw new Error("Invalid JSON body");
345
+ throw new ControlPlaneError(400, "Invalid JSON body");
346
+ }
347
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
348
+ throw new ControlPlaneError(400, "JSON body must be an object");
241
349
  }
350
+ return parsed;
242
351
  }
243
- function normalizePort(port) {
352
+ function normalizePort(port, allowEphemeral = false) {
353
+ if (allowEphemeral && port === 0)
354
+ return 0;
244
355
  if (!Number.isFinite(port) || port <= 0 || port > 65535)
245
356
  return 3980;
246
357
  return Math.floor(port);
@@ -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;