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
package/README.md CHANGED
@@ -199,6 +199,23 @@ OTP is only needed when the npm account has two-factor authentication enabled fo
199
199
  - Tool schemas are strict (`additionalProperties: false`) with runtime argument validation, including nested connection/action/filter/profile payloads.
200
200
  - QSYS object names and source settings are validated before command execution to reduce interpolation and injection risk.
201
201
 
202
+ ## Fast Source Investigation
203
+
204
+ The existing member tools remain compatible: an unchanged `ibmi.qsys.members.list` call still returns an array, and an unchanged `ibmi.qsys.members.read` call still returns source text. New callers can opt into faster, bounded workflows:
205
+
206
+ - `ibmi.session.bootstrap` returns connection, release, library-list, policy, transport, and capability information in one call.
207
+ - `ibmi.source.resolve` finds an exact member across likely source files without returning an entire source-file inventory.
208
+ - `ibmi.source.inspect` performs up to eight metadata, outline, search, range-read, and dependency operations over one consistent member snapshot.
209
+ - `ibmi.qsys.members.list` supports exact/prefix/contains/source-type/change-time filtering and keyset paging with `responseFormat: "page"`.
210
+ - `ibmi.qsys.members.read` supports ranges, context, metadata, SHA-256, and version checks with `responseFormat: "structured"`.
211
+ - `ibmi.qsys.members.write` accepts `expectedVersion` or `expectedSha256` to prevent a stale analysis from overwriting newer source.
212
+
213
+ Source-date reads use an ephemeral `QTEMP` alias in the same database job. If that path is unavailable, the server falls back to a complete UTF-8 `CPYTOSTMF` read; it never reconstructs a member from search matches.
214
+
215
+ ## IBM i Documentation
216
+
217
+ `ibmi.docs.search` and `ibmi.docs.open` provide a release-aware, cached catalog of allowlisted official IBM documentation. The same catalog and topic content are exposed as MCP resources under `ibmi-docs://`. Documentation results include their official canonical URL, content version/checksum, and observation time. When local coverage is insufficient, search results explicitly recommend a web fallback instead of presenting cached material as exhaustive.
218
+
202
219
  ## Links
203
220
 
204
221
  - Main repo: [ashishthomas2202/mcp-for-i](https://github.com/ashishthomas2202/mcp-for-i)
@@ -1,7 +1,10 @@
1
1
  import fs from "fs/promises";
2
+ import { constants as fsConstants } from "fs";
2
3
  import path from "path";
3
4
  import os from "os";
4
5
  import { setPassword } from "../security/credentialStore.js";
6
+ import { readFileWithRetry, sleep, writeFileAtomic } from "../util/atomicWrite.js";
7
+ import { withFileLock } from "../util/fileLock.js";
5
8
  const defaultSettings = {
6
9
  readOnlyMode: false,
7
10
  tempLibrary: "ILEDITOR",
@@ -22,138 +25,236 @@ const defaultSettings = {
22
25
  sessionPingSeconds: 15,
23
26
  sessionReconnectAttempts: 2
24
27
  };
28
+ export class ConfigCorruptError extends Error {
29
+ configPath;
30
+ backupPath;
31
+ detail;
32
+ constructor(configPath, backupPath, detail) {
33
+ super(`Config file ${configPath} could not be parsed (${detail}). ` +
34
+ (backupPath ? `A copy was saved to ${backupPath}. ` : "") +
35
+ "The file was left unchanged; fix or remove it and try again.");
36
+ this.configPath = configPath;
37
+ this.backupPath = backupPath;
38
+ this.detail = detail;
39
+ this.name = "ConfigCorruptError";
40
+ }
41
+ }
42
+ // configPath -> { raw content already backed up, backup path }
43
+ const backedUpCorruptContent = new Map();
44
+ const plaintextWarnings = new Set();
25
45
  export class ConfigStore {
26
46
  configDir;
27
47
  configPath;
48
+ lockPath;
28
49
  data = null;
29
- saveQueue = Promise.resolve();
30
- constructor() {
31
- this.configDir = getConfigDir();
50
+ constructor(options = {}) {
51
+ this.configDir = options.configDir || getConfigDir();
32
52
  this.configPath = path.join(this.configDir, "config.json");
53
+ this.lockPath = `${this.configPath}.lock`;
33
54
  }
55
+ getConfigPath() {
56
+ return this.configPath;
57
+ }
58
+ /**
59
+ * Reads the config from disk. Defaults are created only when the file does not
60
+ * exist. A file that exists but cannot be read or parsed is never overwritten:
61
+ * a backup copy is made and ConfigCorruptError is thrown.
62
+ */
34
63
  async load() {
35
- await ensureDir(this.configDir);
36
- let loadedFromDisk = true;
37
- try {
38
- const raw = await fs.readFile(this.configPath, "utf8");
39
- this.data = JSON.parse(raw);
40
- }
41
- catch {
42
- loadedFromDisk = false;
43
- this.data = { connections: [], settings: defaultSettings, actions: [] };
44
- }
45
- const migrated = await migrateConfigFile(this.data);
46
- this.data = migrated.config;
47
- if (!loadedFromDisk || migrated.changed) {
48
- await this.save();
64
+ const disk = await this.readFromDisk();
65
+ if (disk.exists) {
66
+ const migrated = await migrateConfigFile(disk.parsed);
67
+ if (!migrated.changed) {
68
+ this.data = migrated.config;
69
+ return this.data;
70
+ }
49
71
  }
72
+ // Missing file or a migration is needed: redo the read under the lock so a
73
+ // concurrent writer's changes are not overwritten.
74
+ await this.mutate(() => undefined);
50
75
  return this.data;
51
76
  }
77
+ /**
78
+ * Writes the in-memory config as-is (last writer wins). Prefer the specific
79
+ * update methods or mutate(), which re-read the file under the lock first.
80
+ */
52
81
  async save() {
53
82
  if (!this.data)
54
83
  await this.load();
55
- const payload = JSON.stringify(this.data, null, 2);
56
- await this.enqueueSave(async () => {
57
- const tempPath = `${this.configPath}.${process.pid}.${Date.now()}.tmp`;
58
- await writeFileWithRetry(tempPath, payload);
59
- try {
60
- await fs.rename(tempPath, this.configPath);
61
- }
62
- catch {
63
- await writeFileWithRetry(this.configPath, payload);
64
- await fs.rm(tempPath, { force: true });
65
- }
66
- });
84
+ const payload = serializeConfig(this.data);
85
+ await withFileLock(this.lockPath, () => this.writePayload(payload));
67
86
  }
68
87
  async listConnections() {
69
88
  const cfg = await this.load();
70
- return cfg.connections;
89
+ return cfg.connections.map(publicConnection);
71
90
  }
72
91
  async getConnection(name) {
73
92
  const cfg = await this.load();
74
- return cfg.connections.find(c => c.name === name);
93
+ const found = cfg.connections.find(c => c.name === name);
94
+ return found ? publicConnection(found) : undefined;
75
95
  }
76
96
  async upsertConnection(connection) {
77
- const cfg = await this.load();
78
- const sanitized = await sanitizeConnection(connection);
79
- const idx = cfg.connections.findIndex(c => c.name === sanitized.name);
80
- if (idx >= 0) {
81
- cfg.connections[idx] = { ...cfg.connections[idx], ...sanitized };
82
- }
83
- else {
84
- cfg.connections.push(sanitized);
85
- }
86
- await this.save();
97
+ const { sanitized, password, persisted } = await sanitizeConnection(connection);
98
+ await this.mutate(cfg => {
99
+ const idx = cfg.connections.findIndex(c => c.name === sanitized.name);
100
+ if (idx >= 0) {
101
+ const merged = { ...cfg.connections[idx], ...sanitized };
102
+ // A legacy plaintext password stays only while it is still the password in
103
+ // use and the keychain could not take it.
104
+ if (password !== undefined && (persisted || merged.password !== password)) {
105
+ delete merged.password;
106
+ }
107
+ cfg.connections[idx] = merged;
108
+ }
109
+ else {
110
+ cfg.connections.push(sanitized);
111
+ }
112
+ });
87
113
  }
88
114
  async deleteConnection(name) {
89
- const cfg = await this.load();
90
- cfg.connections = cfg.connections.filter(c => c.name !== name);
91
- await this.save();
115
+ await this.mutate(cfg => {
116
+ cfg.connections = cfg.connections.filter(c => c.name !== name);
117
+ });
92
118
  }
93
119
  async getSettings() {
94
120
  const cfg = await this.load();
95
121
  return cfg.settings;
96
122
  }
97
123
  async updateSettings(update) {
98
- const cfg = await this.load();
99
- cfg.settings = { ...cfg.settings, ...update };
100
- await this.save();
124
+ await this.mutate(cfg => {
125
+ cfg.settings = { ...cfg.settings, ...update };
126
+ });
101
127
  }
102
128
  async listActions() {
103
129
  const cfg = await this.load();
104
130
  return cfg.actions || [];
105
131
  }
106
132
  async upsertAction(action) {
107
- const cfg = await this.load();
108
- const idx = cfg.actions.findIndex(a => a.name === action.name);
109
- if (idx >= 0)
110
- cfg.actions[idx] = action;
111
- else
112
- cfg.actions.push(action);
113
- await this.save();
133
+ await this.mutate(cfg => {
134
+ const idx = cfg.actions.findIndex(a => a.name === action.name);
135
+ if (idx >= 0)
136
+ cfg.actions[idx] = action;
137
+ else
138
+ cfg.actions.push(action);
139
+ });
114
140
  }
115
141
  async deleteAction(name) {
116
- const cfg = await this.load();
117
- cfg.actions = cfg.actions.filter(a => a.name !== name);
118
- await this.save();
142
+ await this.mutate(cfg => {
143
+ cfg.actions = cfg.actions.filter(a => a.name !== name);
144
+ });
119
145
  }
120
146
  async listProfiles(connectionName) {
121
147
  const conn = await this.getConnection(connectionName);
122
148
  return conn?.profiles || [];
123
149
  }
124
150
  async saveProfile(connectionName, profile) {
125
- const conn = await this.getConnection(connectionName);
126
- if (!conn)
127
- throw new Error(`Connection ${connectionName} not found`);
128
- const profiles = conn.profiles || [];
129
- const idx = profiles.findIndex(p => p.name === profile.name);
130
- if (idx >= 0)
131
- profiles[idx] = profile;
132
- else
133
- profiles.push(profile);
134
- conn.profiles = profiles;
135
- await this.upsertConnection(conn);
151
+ await this.mutateConnection(connectionName, conn => {
152
+ const profiles = conn.profiles || [];
153
+ const idx = profiles.findIndex(p => p.name === profile.name);
154
+ if (idx >= 0)
155
+ profiles[idx] = profile;
156
+ else
157
+ profiles.push(profile);
158
+ conn.profiles = profiles;
159
+ });
136
160
  }
137
161
  async deleteProfile(connectionName, profileName) {
138
- const conn = await this.getConnection(connectionName);
139
- if (!conn)
140
- throw new Error(`Connection ${connectionName} not found`);
141
- conn.profiles = (conn.profiles || []).filter(p => p.name !== profileName);
142
- if (conn.currentProfile === profileName) {
143
- conn.currentProfile = undefined;
144
- }
145
- await this.upsertConnection(conn);
162
+ await this.mutateConnection(connectionName, conn => {
163
+ conn.profiles = (conn.profiles || []).filter(p => p.name !== profileName);
164
+ if (conn.currentProfile === profileName) {
165
+ conn.currentProfile = undefined;
166
+ }
167
+ });
146
168
  }
147
169
  async setCurrentProfile(connectionName, profileName) {
148
- const conn = await this.getConnection(connectionName);
149
- if (!conn)
150
- throw new Error(`Connection ${connectionName} not found`);
151
- conn.currentProfile = profileName;
152
- await this.upsertConnection(conn);
170
+ await this.mutateConnection(connectionName, conn => {
171
+ conn.currentProfile = profileName;
172
+ });
173
+ }
174
+ /**
175
+ * Read-modify-write under the cross-process lock: the file is re-read (and
176
+ * migrated) while the lock is held, `fn` changes it, and the result is written
177
+ * atomically. Concurrent callers in this and other processes are serialized,
178
+ * so no update is lost. `fn` must not call other ConfigStore update methods.
179
+ */
180
+ async mutate(fn) {
181
+ return withFileLock(this.lockPath, async () => {
182
+ const disk = await this.readFromDisk();
183
+ const migrated = await migrateConfigFile(disk.exists ? disk.parsed : undefined);
184
+ const cfg = migrated.config;
185
+ const result = await fn(cfg);
186
+ const payload = serializeConfig(cfg);
187
+ if (!disk.exists || payload !== disk.raw) {
188
+ await this.writePayload(payload);
189
+ }
190
+ this.data = cfg;
191
+ return result;
192
+ });
153
193
  }
154
- enqueueSave(operation) {
155
- this.saveQueue = this.saveQueue.then(operation, operation);
156
- return this.saveQueue;
194
+ async mutateConnection(connectionName, fn) {
195
+ await this.mutate(cfg => {
196
+ const conn = cfg.connections.find(c => c.name === connectionName);
197
+ if (!conn)
198
+ throw new Error(`Connection ${connectionName} not found`);
199
+ fn(conn);
200
+ });
201
+ }
202
+ async readFromDisk() {
203
+ let raw;
204
+ try {
205
+ raw = await readFileWithRetry(this.configPath);
206
+ }
207
+ catch (err) {
208
+ if (err?.code === "ENOENT")
209
+ return { exists: false };
210
+ throw new Error(`Config file ${this.configPath} could not be read (${err?.code || err?.message || String(err)}); it was left unchanged.`);
211
+ }
212
+ if (raw.trim().length === 0) {
213
+ // An empty file holds nothing that could be lost; treat it like a missing one.
214
+ return { exists: false };
215
+ }
216
+ try {
217
+ return { exists: true, raw, parsed: JSON.parse(stripBom(raw)) };
218
+ }
219
+ catch {
220
+ // A writer that does not use atomic renames (an older version) may be mid-write.
221
+ await sleep(50);
222
+ }
223
+ try {
224
+ raw = await readFileWithRetry(this.configPath);
225
+ }
226
+ catch (err) {
227
+ if (err?.code === "ENOENT")
228
+ return { exists: false };
229
+ throw err;
230
+ }
231
+ try {
232
+ return { exists: true, raw, parsed: JSON.parse(stripBom(raw)) };
233
+ }
234
+ catch (err) {
235
+ const detail = err instanceof Error ? err.message : String(err);
236
+ const backupPath = await this.backupCorruptFile(raw);
237
+ throw new ConfigCorruptError(this.configPath, backupPath, detail);
238
+ }
239
+ }
240
+ async backupCorruptFile(raw) {
241
+ const previous = backedUpCorruptContent.get(this.configPath);
242
+ if (previous && previous.raw === raw)
243
+ return previous.backupPath;
244
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
245
+ const backupPath = `${this.configPath}.corrupt-${stamp}`;
246
+ try {
247
+ await fs.copyFile(this.configPath, backupPath, fsConstants.COPYFILE_EXCL);
248
+ backedUpCorruptContent.set(this.configPath, { raw, backupPath });
249
+ return backupPath;
250
+ }
251
+ catch {
252
+ return undefined;
253
+ }
254
+ }
255
+ async writePayload(payload) {
256
+ await fs.mkdir(this.configDir, { recursive: true });
257
+ await writeFileAtomic(this.configPath, payload, { mode: 0o600 });
157
258
  }
158
259
  }
159
260
  export function getConfigDir() {
@@ -166,6 +267,16 @@ export function getConfigDir() {
166
267
  }
167
268
  return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "mcp-for-i");
168
269
  }
270
+ function serializeConfig(cfg) {
271
+ return JSON.stringify(cfg, null, 2);
272
+ }
273
+ function stripBom(raw) {
274
+ return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
275
+ }
276
+ function publicConnection(connection) {
277
+ const { password: _password, ...rest } = connection;
278
+ return rest;
279
+ }
169
280
  async function migrateConfigFile(input) {
170
281
  let changed = false;
171
282
  const root = isPlainObject(input) ? input : {};
@@ -218,9 +329,24 @@ async function normalizeConnections(value) {
218
329
  changed = true;
219
330
  seenNames.add(uniqueName);
220
331
  const password = toNonEmptyString(entry.password);
332
+ let keepPlaintext = false;
221
333
  if (password) {
222
- await setPassword(uniqueName, password);
223
- changed = true;
334
+ // The plaintext is dropped only once the keychain confirms it holds the password.
335
+ let persistent = false;
336
+ let reason;
337
+ try {
338
+ persistent = (await setPassword(uniqueName, password, { keepExistingSessionValue: true })).persistent;
339
+ }
340
+ catch (err) {
341
+ reason = err instanceof Error ? err.message : String(err);
342
+ }
343
+ if (persistent) {
344
+ changed = true;
345
+ }
346
+ else {
347
+ keepPlaintext = true;
348
+ warnPlaintextKept(uniqueName, reason);
349
+ }
224
350
  }
225
351
  const host = toNonEmptyString(entry.host) || "";
226
352
  const username = toNonEmptyString(entry.username) || "";
@@ -255,6 +381,8 @@ async function normalizeConnections(value) {
255
381
  normalized.readyTimeout = readyTimeout;
256
382
  if (sshDebug !== undefined)
257
383
  normalized.sshDebug = sshDebug;
384
+ if (keepPlaintext)
385
+ normalized.password = password;
258
386
  if (JSON.stringify(normalized) !== JSON.stringify(entry)) {
259
387
  changed = true;
260
388
  }
@@ -262,6 +390,14 @@ async function normalizeConnections(value) {
262
390
  }
263
391
  return { connections, changed };
264
392
  }
393
+ function warnPlaintextKept(connectionName, reason) {
394
+ if (plaintextWarnings.has(connectionName))
395
+ return;
396
+ plaintextWarnings.add(connectionName);
397
+ process.stderr.write(`[mcp-for-i] warning: no persistent keychain is available, so the plaintext password for connection ` +
398
+ `"${connectionName}" was left in config.json${reason ? ` (${reason})` : ""}. ` +
399
+ "It will be moved to the keychain once one is available.\n");
400
+ }
265
401
  function normalizeSettings(value) {
266
402
  if (!isPlainObject(value)) {
267
403
  return { value: { ...defaultSettings }, changed: true };
@@ -298,19 +434,23 @@ function normalizePolicy(value) {
298
434
  delete policy.allowPaths;
299
435
  if (!Array.isArray(policy.denyPaths))
300
436
  delete policy.denyPaths;
437
+ if (policy.approvalMode !== "prompt" && policy.approvalMode !== "agent")
438
+ delete policy.approvalMode;
301
439
  return policy;
302
440
  }
303
441
  async function sanitizeConnection(connection) {
304
442
  const raw = connection;
305
443
  const password = toNonEmptyString(raw.password);
444
+ let persisted = false;
306
445
  if (password) {
307
- await setPassword(connection.name, password);
446
+ persisted = (await setPassword(connection.name, password)).persistent;
308
447
  }
309
448
  const { password: _password, storePassword: _storePassword, ...rest } = raw;
310
449
  const normalizedPort = normalizePort(rest.port);
311
450
  return {
312
- ...rest,
313
- port: normalizedPort
451
+ sanitized: { ...rest, port: normalizedPort },
452
+ password,
453
+ persisted
314
454
  };
315
455
  }
316
456
  function uniqueConnectionName(baseName, seen) {
@@ -346,23 +486,3 @@ function toNonEmptyString(value) {
346
486
  function isPlainObject(value) {
347
487
  return typeof value === "object" && value !== null && !Array.isArray(value);
348
488
  }
349
- async function ensureDir(dir) {
350
- await fs.mkdir(dir, { recursive: true });
351
- }
352
- async function writeFileWithRetry(filePath, content, retries = 5, delayMs = 40) {
353
- for (let attempt = 0; attempt <= retries; attempt++) {
354
- try {
355
- await fs.writeFile(filePath, content, "utf8");
356
- return;
357
- }
358
- catch (err) {
359
- const transient = err?.code === "EBUSY" || err?.code === "EPERM";
360
- if (!transient || attempt === retries)
361
- throw err;
362
- await wait(delayMs * (attempt + 1));
363
- }
364
- }
365
- }
366
- async function wait(ms) {
367
- await new Promise(resolve => setTimeout(resolve, ms));
368
- }
@@ -0,0 +1,128 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { getConfigDir } from "../config/store.js";
5
+ /** Header the Web UI and the CLI send with every control-plane API call. */
6
+ export const CONTROL_TOKEN_HEADER = "X-MCP-For-I-Token";
7
+ const TOKEN_FILE = "controlplane.token";
8
+ const TOKEN_PATTERN = /^[a-f0-9]{64}$/;
9
+ export function getControlTokenPath(configDir = getConfigDir()) {
10
+ return path.join(configDir, TOKEN_FILE);
11
+ }
12
+ /** Reads the per-install token, or returns undefined when none exists yet. */
13
+ export function readControlToken(configDir = getConfigDir()) {
14
+ try {
15
+ const raw = fs.readFileSync(getControlTokenPath(configDir), "utf8").trim();
16
+ return TOKEN_PATTERN.test(raw) ? raw : undefined;
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ /**
23
+ * Returns the per-install token, creating it on first use. The file is written
24
+ * with mode 0600 (owner only) where the platform supports it.
25
+ */
26
+ export function ensureControlToken(configDir = getConfigDir()) {
27
+ const existing = readControlToken(configDir);
28
+ if (existing)
29
+ return existing;
30
+ fs.mkdirSync(configDir, { recursive: true });
31
+ const tokenPath = getControlTokenPath(configDir);
32
+ const token = crypto.randomBytes(32).toString("hex");
33
+ try {
34
+ fs.writeFileSync(tokenPath, `${token}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
35
+ return token;
36
+ }
37
+ catch (err) {
38
+ if (err?.code !== "EEXIST")
39
+ throw err;
40
+ }
41
+ // Another process created it first, or the existing file is unreadable/invalid.
42
+ const raced = readControlToken(configDir);
43
+ if (raced)
44
+ return raced;
45
+ fs.writeFileSync(tokenPath, `${token}\n`, { encoding: "utf8", mode: 0o600 });
46
+ try {
47
+ fs.chmodSync(tokenPath, 0o600);
48
+ }
49
+ catch {
50
+ // best effort on platforms without POSIX modes
51
+ }
52
+ return token;
53
+ }
54
+ /** Constant-time token comparison. */
55
+ export function tokensMatch(expected, provided) {
56
+ if (typeof provided !== "string" || !provided)
57
+ return false;
58
+ const a = Buffer.from(expected, "utf8");
59
+ const b = Buffer.from(provided.trim(), "utf8");
60
+ if (a.length !== b.length)
61
+ return false;
62
+ return crypto.timingSafeEqual(a, b);
63
+ }
64
+ /** Hostnames the control plane answers to (blocks DNS rebinding). */
65
+ export function isAllowedHostHeader(hostHeader, port, extraHosts = []) {
66
+ if (!hostHeader)
67
+ return false;
68
+ const parsed = parseHostPort(hostHeader.trim().toLowerCase());
69
+ if (!parsed)
70
+ return false;
71
+ const allowed = new Set(["localhost", "127.0.0.1", "[::1]", ...extraHosts.map(normalizeHostName)]);
72
+ if (!allowed.has(parsed.host))
73
+ return false;
74
+ const effectivePort = parsed.port ?? 80;
75
+ return effectivePort === port;
76
+ }
77
+ /** An Origin header, when present, must point at this same local server. */
78
+ export function isAllowedOrigin(origin, port, extraHosts = []) {
79
+ if (origin === undefined)
80
+ return true;
81
+ let url;
82
+ try {
83
+ url = new URL(origin);
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ if (url.protocol !== "http:")
89
+ return false;
90
+ const host = url.port ? `${url.hostname}:${url.port}` : url.hostname;
91
+ return isAllowedHostHeader(host, port, extraHosts);
92
+ }
93
+ function parseHostPort(value) {
94
+ let host;
95
+ let portText;
96
+ if (value.startsWith("[")) {
97
+ const end = value.indexOf("]");
98
+ if (end < 0)
99
+ return undefined;
100
+ host = value.slice(0, end + 1);
101
+ const rest = value.slice(end + 1);
102
+ if (rest) {
103
+ if (!rest.startsWith(":"))
104
+ return undefined;
105
+ portText = rest.slice(1);
106
+ }
107
+ }
108
+ else {
109
+ const parts = value.split(":");
110
+ if (parts.length > 2)
111
+ return undefined;
112
+ host = parts[0];
113
+ portText = parts[1];
114
+ }
115
+ if (!host)
116
+ return undefined;
117
+ if (portText === undefined)
118
+ return { host };
119
+ if (!/^\d{1,5}$/.test(portText))
120
+ return undefined;
121
+ return { host, port: Number(portText) };
122
+ }
123
+ function normalizeHostName(host) {
124
+ const lower = host.trim().toLowerCase();
125
+ if (lower.includes(":") && !lower.startsWith("["))
126
+ return `[${lower}]`;
127
+ return lower;
128
+ }