@alfe.ai/mcp-bundler 0.3.2 → 0.4.1

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/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { dirname, isAbsolute, join } from "node:path";
2
+ import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, watch, writeFileSync } from "node:fs";
3
+ import { createHash, randomUUID } from "node:crypto";
3
4
  import { homedir } from "node:os";
4
5
  //#region src/tool-naming.ts
5
6
  /**
@@ -19,11 +20,15 @@ function sanitizeNameSegment(value) {
19
20
  return value.replace(DISALLOWED, "_");
20
21
  }
21
22
  function buildNamespacedToolName(server, tool) {
22
- const base = `${sanitizeNameSegment(server)}${SEPARATOR}${sanitizeNameSegment(tool)}`;
23
+ const sanitizedServer = sanitizeNameSegment(server);
24
+ const sanitizedTool = sanitizeNameSegment(tool);
25
+ const serverBudget = MAX_LEN - 2 - 1;
26
+ const boundedServer = sanitizedServer.slice(0, serverBudget);
27
+ const base = `${boundedServer}${SEPARATOR}${sanitizedTool}`;
23
28
  if (base.length <= MAX_LEN) return base;
24
- const reservedForServer = sanitizeNameSegment(server).length + 2;
29
+ const reservedForServer = boundedServer.length + 2;
25
30
  const toolBudget = Math.max(1, MAX_LEN - reservedForServer);
26
- return `${sanitizeNameSegment(server)}${SEPARATOR}${sanitizeNameSegment(tool).slice(0, toolBudget)}`;
31
+ return `${boundedServer}${SEPARATOR}${sanitizedTool.slice(0, toolBudget)}`;
27
32
  }
28
33
  /**
29
34
  * Disambiguate a candidate name against an existing set by appending `-2`, `-3`, etc.
@@ -32,16 +37,231 @@ function buildNamespacedToolName(server, tool) {
32
37
  */
33
38
  function disambiguateAgainst(candidate, taken) {
34
39
  if (!taken.has(candidate)) return candidate;
35
- for (let i = 2; i < 1e3; i += 1) {
40
+ for (let i = 2; i <= taken.size + 2; i += 1) {
36
41
  const suffix = `-${i.toString()}`;
37
42
  const room = MAX_LEN - suffix.length;
38
43
  const next = `${candidate.length > room ? candidate.slice(0, room) : candidate}${suffix}`;
39
44
  if (!taken.has(next)) return next;
40
45
  }
41
- return `${candidate.slice(0, MAX_LEN - 6)}-x${(taken.size % 1e3).toString().padStart(3, "0")}`;
46
+ throw new Error("unable to disambiguate MCP tool name");
47
+ }
48
+ //#endregion
49
+ //#region src/validation.ts
50
+ const MAX_SERVERS = 256;
51
+ const MAX_TOOLS_PER_SERVER = 512;
52
+ const MAX_SCHEMA_BYTES = 512 * 1024;
53
+ const MAX_ARGUMENT_BYTES = 2 * 1024 * 1024;
54
+ const MAX_RESULT_BYTES = 5 * 1024 * 1024;
55
+ const MAX_JSON_DEPTH = 64;
56
+ const MAX_JSON_NODES = 5e4;
57
+ const MAX_DESCRIPTION_LENGTH = 2e4;
58
+ const SERVER_ID = /^[A-Za-z0-9][A-Za-z0-9._:@#-]{0,127}$/;
59
+ const TOOL_NAME = /^[A-Za-z0-9_.:-]{1,128}$/;
60
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
61
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
62
+ const UNSAFE_KEYS = new Set([
63
+ "__proto__",
64
+ "prototype",
65
+ "constructor"
66
+ ]);
67
+ function validateServerId(id, label = "server id") {
68
+ if (typeof id !== "string" || !SERVER_ID.test(id) || UNSAFE_KEYS.has(id)) throw new Error(`${label} must match ${SERVER_ID.source}`);
69
+ return id;
70
+ }
71
+ function validateServerCount(count) {
72
+ if (count > MAX_SERVERS) throw new Error(`MCP store exceeds the ${MAX_SERVERS.toString()} server limit`);
73
+ }
74
+ function validateServerConfig(value, label = "server config") {
75
+ const config = requireRecord(value, label);
76
+ const hasCommand = Object.hasOwn(config, "command");
77
+ if (hasCommand === Object.hasOwn(config, "url")) throw new Error(`${label} must contain exactly one of command or url`);
78
+ return hasCommand ? validateStdioConfig(config, label) : validateRemoteConfig(config, label);
79
+ }
80
+ function normalizeChildCatalog(server, advertised) {
81
+ validateServerId(server);
82
+ if (!Array.isArray(advertised)) throw new Error(`server "${server}" returned a non-array tool catalog`);
83
+ if (advertised.length > MAX_TOOLS_PER_SERVER) throw new Error(`server "${server}" advertised ${advertised.length.toString()} tools; limit is ${MAX_TOOLS_PER_SERVER.toString()}`);
84
+ const originals = /* @__PURE__ */ new Set();
85
+ return advertised.map((raw, index) => {
86
+ const tool = requireRecord(raw, `server "${server}" tool ${index.toString()}`);
87
+ const name = tool.name;
88
+ if (typeof name !== "string" || !TOOL_NAME.test(name)) throw new Error(`server "${server}" advertised an invalid tool name at index ${index.toString()}`);
89
+ if (originals.has(name)) throw new Error(`server "${server}" advertised duplicate tool "${name}"`);
90
+ originals.add(name);
91
+ const description = tool.description === void 0 ? "" : requireBoundedString(tool.description, `server "${server}" tool "${name}" description`, MAX_DESCRIPTION_LENGTH);
92
+ const parameters = cloneBoundedJson(tool.inputSchema, `server "${server}" tool "${name}" input schema`, MAX_SCHEMA_BYTES);
93
+ if (!isRecord$1(parameters)) throw new Error(`server "${server}" tool "${name}" input schema must be an object`);
94
+ return {
95
+ prefixed: buildNamespacedToolName(server, name),
96
+ server,
97
+ original: name,
98
+ label: (description || name).slice(0, 80),
99
+ description,
100
+ parameters
101
+ };
102
+ });
103
+ }
104
+ function validateToolArguments(args) {
105
+ if (args === void 0) return void 0;
106
+ const cloned = cloneBoundedJson(args, "MCP tool arguments", MAX_ARGUMENT_BYTES);
107
+ if (!isRecord$1(cloned)) throw new Error("MCP tool arguments must be an object");
108
+ return cloned;
109
+ }
110
+ function normalizeToolResult(result) {
111
+ const cloned = cloneBoundedJson(result, "MCP tool result", MAX_RESULT_BYTES);
112
+ if (!isRecord$1(cloned) || !Array.isArray(cloned.content)) throw new Error("MCP tool result must be an object with a content array");
113
+ for (const [index, item] of cloned.content.entries()) if (!isRecord$1(item)) throw new Error(`MCP tool result content[${index.toString()}] must be an object`);
114
+ if (cloned.isError !== void 0 && typeof cloned.isError !== "boolean") throw new Error("MCP tool result isError must be a boolean when present");
115
+ if (cloned.structuredContent !== void 0 && !isRecord$1(cloned.structuredContent)) throw new Error("MCP tool result structuredContent must be an object when present");
116
+ return cloned;
117
+ }
118
+ function redactErrorResult(result, config) {
119
+ if (!result.isError) return result;
120
+ return {
121
+ ...result,
122
+ content: result.content.map((item) => item.type === "text" && typeof item.text === "string" ? {
123
+ ...item,
124
+ text: redactSensitiveText(item.text, config)
125
+ } : item)
126
+ };
127
+ }
128
+ function cloneToolDescriptor(tool) {
129
+ return {
130
+ ...tool,
131
+ parameters: cloneBoundedJson(tool.parameters, `tool "${tool.prefixed}" schema`, MAX_SCHEMA_BYTES)
132
+ };
133
+ }
134
+ function redactSensitiveText(text, config) {
135
+ let output = String(text).slice(0, MAX_DESCRIPTION_LENGTH).replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]").replace(/\b(api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password)\s*[=:]\s*[^\s,;]+/gi, "$1=[REDACTED]").replace(/\balfe_[A-Za-z0-9_-]{8,}/g, "[REDACTED]");
136
+ for (const secret of configSecrets(config)) {
137
+ if (secret.length < 4) continue;
138
+ output = output.split(secret).join("[REDACTED]");
139
+ }
140
+ return output;
141
+ }
142
+ function validateStdioConfig(config, label) {
143
+ const command = requireBoundedString(config.command, `${label}.command`, 4096);
144
+ rejectControlCharacters(command, `${label}.command`);
145
+ const result = { command };
146
+ if (config.args !== void 0) {
147
+ if (!Array.isArray(config.args) || config.args.length > 256) throw new Error(`${label}.args must be an array with at most 256 entries`);
148
+ result.args = config.args.map((arg, index) => {
149
+ const checked = requireBoundedString(arg, `${label}.args[${index.toString()}]`, 32 * 1024);
150
+ rejectNul(checked, `${label}.args[${index.toString()}]`);
151
+ return checked;
152
+ });
153
+ }
154
+ if (config.env !== void 0) result.env = validateStringMap(config.env, `${label}.env`, ENV_NAME, 256, 64 * 1024);
155
+ if (config.cwd !== void 0) {
156
+ const cwd = requireBoundedString(config.cwd, `${label}.cwd`, 4096);
157
+ rejectNul(cwd, `${label}.cwd`);
158
+ if (!isAbsolute(cwd)) throw new Error(`${label}.cwd must be an absolute path`);
159
+ result.cwd = cwd;
160
+ }
161
+ if (config.connectionTimeoutMs !== void 0) result.connectionTimeoutMs = validateTimeout(config.connectionTimeoutMs, `${label}.connectionTimeoutMs`);
162
+ return result;
163
+ }
164
+ function validateRemoteConfig(config, label) {
165
+ const rawUrl = requireBoundedString(config.url, `${label}.url`, 4096);
166
+ let parsed;
167
+ try {
168
+ parsed = new URL(rawUrl);
169
+ } catch {
170
+ throw new Error(`${label}.url must be an absolute URL`);
171
+ }
172
+ if (!["http:", "https:"].includes(parsed.protocol)) throw new Error(`${label}.url must use http or https`);
173
+ if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) throw new Error(`${label}.url must use https unless it targets loopback`);
174
+ if (parsed.username || parsed.password) throw new Error(`${label}.url must not contain credentials`);
175
+ if (parsed.hash) throw new Error(`${label}.url must not contain a fragment`);
176
+ const result = { url: parsed.toString() };
177
+ if (config.transport !== void 0) {
178
+ if (config.transport !== "sse" && config.transport !== "streamable-http") throw new Error(`${label}.transport must be sse or streamable-http`);
179
+ result.transport = config.transport;
180
+ }
181
+ if (config.headers !== void 0) result.headers = validateStringMap(config.headers, `${label}.headers`, HEADER_NAME, 64, 32 * 1024, true);
182
+ if (config.connectionTimeoutMs !== void 0) result.connectionTimeoutMs = validateTimeout(config.connectionTimeoutMs, `${label}.connectionTimeoutMs`);
183
+ return result;
184
+ }
185
+ function validateStringMap(value, label, keyPattern, maxEntries, maxValueLength, rejectControls = false) {
186
+ const record = requireRecord(value, label);
187
+ const entries = Object.entries(record);
188
+ if (entries.length > maxEntries) throw new Error(`${label} has too many entries`);
189
+ const result = {};
190
+ for (const [key, raw] of entries) {
191
+ if (!keyPattern.test(key) || UNSAFE_KEYS.has(key)) throw new Error(`${label} contains invalid key "${key}"`);
192
+ const checked = requireBoundedString(raw, `${label}.${key}`, maxValueLength);
193
+ rejectNul(checked, `${label}.${key}`);
194
+ if (rejectControls) rejectControlCharacters(checked, `${label}.${key}`);
195
+ result[key] = checked;
196
+ }
197
+ return result;
198
+ }
199
+ function validateTimeout(value, label) {
200
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 3e5) throw new Error(`${label} must be an integer from 0 to 300000`);
201
+ return value;
202
+ }
203
+ function cloneBoundedJson(value, label, maxBytes) {
204
+ let nodes = 0;
205
+ const seen = /* @__PURE__ */ new WeakSet();
206
+ const visit = (item, depth) => {
207
+ nodes += 1;
208
+ if (nodes > MAX_JSON_NODES) throw new Error(`${label} exceeds the JSON node limit`);
209
+ if (depth > MAX_JSON_DEPTH) throw new Error(`${label} exceeds the JSON depth limit`);
210
+ if (item === null || typeof item === "boolean" || typeof item === "number") {
211
+ if (typeof item === "number" && !Number.isFinite(item)) throw new Error(`${label} contains a non-finite number`);
212
+ return;
213
+ }
214
+ if (typeof item === "string") {
215
+ if (Buffer.byteLength(item, "utf8") > maxBytes) throw new Error(`${label} contains an oversized string`);
216
+ return;
217
+ }
218
+ if (typeof item !== "object") throw new Error(`${label} contains a non-JSON value`);
219
+ if (seen.has(item)) throw new Error(`${label} contains a cycle`);
220
+ seen.add(item);
221
+ if (!Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) throw new Error(`${label} contains a non-plain object`);
222
+ for (const [key, child] of Object.entries(item)) {
223
+ if (UNSAFE_KEYS.has(key)) throw new Error(`${label} contains unsafe key "${key}"`);
224
+ visit(child, depth + 1);
225
+ }
226
+ };
227
+ visit(value, 0);
228
+ const serialized = JSON.stringify(value);
229
+ if (Buffer.byteLength(serialized, "utf8") > maxBytes) throw new Error(`${label} exceeds the byte limit`);
230
+ return JSON.parse(serialized);
231
+ }
232
+ function requireRecord(value, label) {
233
+ if (!isRecord$1(value)) throw new Error(`${label} must be an object`);
234
+ for (const key of Object.keys(value)) if (UNSAFE_KEYS.has(key)) throw new Error(`${label} contains unsafe key "${key}"`);
235
+ return value;
236
+ }
237
+ function isRecord$1(value) {
238
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
239
+ const prototype = Object.getPrototypeOf(value);
240
+ return prototype === Object.prototype || prototype === null;
241
+ }
242
+ function requireBoundedString(value, label, maxLength) {
243
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength) throw new Error(`${label} must be a non-empty string of at most ${maxLength.toString()} characters`);
244
+ return value;
245
+ }
246
+ function rejectNul(value, label) {
247
+ if (value.includes("\0")) throw new Error(`${label} must not contain NUL`);
248
+ }
249
+ function rejectControlCharacters(value, label) {
250
+ for (const char of value) {
251
+ const code = char.charCodeAt(0);
252
+ if (code <= 31 || code === 127) throw new Error(`${label} must not contain control characters`);
253
+ }
254
+ }
255
+ function configSecrets(config) {
256
+ if (!config) return [];
257
+ return "command" in config ? Object.values(config.env ?? {}) : [...Object.values(config.headers ?? {}), new URL(config.url).password].filter(Boolean);
258
+ }
259
+ function isLoopbackHost(hostname) {
260
+ return hostname === "localhost" || hostname === "[::1]" || /^127(?:\.[0-9]{1,3}){3}$/.test(hostname);
42
261
  }
43
262
  //#endregion
44
263
  //#region src/connection.ts
264
+ const MAX_STDERR_LINE_LENGTH = 16 * 1024;
45
265
  /** Env keys OpenClaw rejects from stdio MCP env blocks. Filter them out before spawning. */
46
266
  const STDIO_ENV_DENYLIST = new Set([
47
267
  "NODE_OPTIONS",
@@ -89,17 +309,21 @@ var Connection = class Connection {
89
309
  static RECONNECT_BACKOFF_MAX_MS = 3e4;
90
310
  onUnexpectedClose;
91
311
  onStderrLine;
312
+ connectTimeoutMs;
313
+ /** Whether any connect has ever been attempted — drives the eager retry sweep. */
314
+ connectAttempted = false;
92
315
  constructor(params) {
93
- this.name = params.name;
94
- this.config = params.config;
316
+ this.name = validateServerId(params.name);
317
+ this.config = validateServerConfig(params.config);
95
318
  this.deps = params.deps;
96
319
  this.logger = params.logger;
97
320
  this.onUnexpectedClose = params.onUnexpectedClose;
98
321
  this.onStderrLine = params.onStderrLine;
322
+ this.connectTimeoutMs = params.connectTimeoutMs ?? 0;
99
323
  }
100
324
  /** Returns the most recent known tool list. May be empty if the server hasn't connected yet. */
101
325
  snapshotTools() {
102
- return this.tools;
326
+ return this.tools.map(cloneToolDescriptor);
103
327
  }
104
328
  /** Whether an MCP child process / remote connection has been established. */
105
329
  isConnected() {
@@ -117,6 +341,15 @@ var Connection = class Connection {
117
341
  lastErrorMessage() {
118
342
  return this.lastError;
119
343
  }
344
+ /**
345
+ * Whether a connect was ever attempted (success or failure). A reconciled
346
+ * connection that was never warmed has neither a client nor a `lastError` —
347
+ * this flag lets an eager host's retry sweep find it without also
348
+ * resurrecting cleanly-closed (idle-reaped) connections.
349
+ */
350
+ hasConnectAttempted() {
351
+ return this.connectAttempted;
352
+ }
120
353
  /** Idle timestamp for reaping. */
121
354
  idleSinceMs() {
122
355
  return Date.now() - this.lastUsedAt;
@@ -135,46 +368,66 @@ var Connection = class Connection {
135
368
  return this.connectInFlight;
136
369
  }
137
370
  async connectAndDiscover() {
371
+ this.connectAttempted = true;
138
372
  const safeConfig = "command" in this.config ? {
139
373
  ...this.config,
140
374
  env: sanitizeStdioEnv(this.config.env)
141
375
  } : this.config;
142
376
  this.logger?.debug(`[mcp-bundler] connecting server "${this.name}"`);
143
- let client;
144
- try {
145
- client = await this.deps.connect(safeConfig, {
377
+ const attempt = (async () => {
378
+ const c = await this.deps.connect(safeConfig, {
146
379
  serverName: this.name,
147
380
  onStderrLine: this.onStderrLine
148
381
  });
149
- const advertised = await client.listTools();
150
- const connected = client;
151
- this.client = connected;
382
+ try {
383
+ return {
384
+ client: c,
385
+ advertised: await c.listTools()
386
+ };
387
+ } catch (err) {
388
+ await c.close().catch(() => void 0);
389
+ throw err;
390
+ }
391
+ })();
392
+ try {
393
+ const { client, advertised } = await this.raceConnectTimeout(attempt);
394
+ this.client = client;
152
395
  this.closing = false;
153
- connected.onClose?.(() => {
154
- this.handleUnexpectedClose(connected);
396
+ client.onClose?.(() => {
397
+ this.handleUnexpectedClose(client);
155
398
  });
156
- this.tools = advertised.map((t) => ({
157
- prefixed: buildNamespacedToolName(this.name, t.name),
158
- server: this.name,
159
- original: t.name,
160
- label: (t.description ?? t.name).slice(0, 80),
161
- description: t.description ?? "",
162
- parameters: t.inputSchema
163
- }));
399
+ this.tools = normalizeChildCatalog(this.name, advertised);
164
400
  this.lastUsedAt = Date.now();
165
401
  this.consecutiveFailures = 0;
166
402
  this.lastError = void 0;
167
403
  this.reconnectBlockedUntilMs = 0;
168
404
  this.logger?.info(`[mcp-bundler] server "${this.name}" connected, ${this.tools.length.toString()} tool(s)`);
169
405
  } catch (err) {
170
- if (client) await client.close().catch(() => void 0);
406
+ attempt.then(({ client }) => client.close()).catch(() => void 0);
171
407
  this.consecutiveFailures += 1;
172
- this.lastError = err instanceof Error ? err.message : String(err);
408
+ this.lastError = redactSensitiveText(err instanceof Error ? err.message : String(err), this.config);
173
409
  const backoff = Math.min(Connection.RECONNECT_BACKOFF_BASE_MS * 2 ** (this.consecutiveFailures - 1), Connection.RECONNECT_BACKOFF_MAX_MS);
174
410
  this.reconnectBlockedUntilMs = Date.now() + backoff;
175
411
  throw err;
176
412
  }
177
413
  }
414
+ /** Race the attempt against `connectTimeoutMs`; 0 disables the bound. */
415
+ async raceConnectTimeout(attempt) {
416
+ const ms = this.connectTimeoutMs;
417
+ if (ms <= 0) return attempt;
418
+ let timer;
419
+ const timeout = new Promise((_, reject) => {
420
+ timer = setTimeout(() => {
421
+ reject(/* @__PURE__ */ new Error(`server "${this.name}" connect timed out after ${ms.toString()}ms — spawned but did not complete the MCP handshake/discovery`));
422
+ }, ms);
423
+ if (typeof timer === "object" && "unref" in timer) timer.unref();
424
+ });
425
+ try {
426
+ return await Promise.race([attempt, timeout]);
427
+ } finally {
428
+ clearTimeout(timer);
429
+ }
430
+ }
178
431
  /**
179
432
  * Handle an unexpected transport close (crash / network drop). Clears the
180
433
  * dead client + tools so the next `ensureConnected` re-spawns. No-op if we
@@ -185,6 +438,7 @@ var Connection = class Connection {
185
438
  this.logger?.warn(`[mcp-bundler] server "${this.name}" connection closed unexpectedly; will re-spawn on next use`);
186
439
  this.client = void 0;
187
440
  this.tools = [];
441
+ handle.close().catch(() => void 0);
188
442
  try {
189
443
  this.onUnexpectedClose?.();
190
444
  } catch {}
@@ -202,14 +456,10 @@ var Connection = class Connection {
202
456
  }
203
457
  this.refreshInFlight = true;
204
458
  try {
205
- this.tools = (await this.client.listTools()).map((t) => ({
206
- prefixed: buildNamespacedToolName(this.name, t.name),
207
- server: this.name,
208
- original: t.name,
209
- label: (t.description ?? t.name).slice(0, 80),
210
- description: t.description ?? "",
211
- parameters: t.inputSchema
212
- }));
459
+ const handle = this.client;
460
+ const advertised = await handle.listTools();
461
+ if (this.client !== handle) return;
462
+ this.tools = normalizeChildCatalog(this.name, advertised);
213
463
  this.logger?.debug(`[mcp-bundler] server "${this.name}" refreshed, ${this.tools.length.toString()} tool(s)`);
214
464
  } finally {
215
465
  this.refreshInFlight = false;
@@ -225,7 +475,8 @@ var Connection = class Connection {
225
475
  await this.ensureConnected();
226
476
  if (!this.client) throw new Error(`server "${this.name}" failed to connect`);
227
477
  this.lastUsedAt = Date.now();
228
- return this.client.callTool(originalName, args, signal ? { signal } : void 0);
478
+ const checkedArgs = validateToolArguments(args);
479
+ return normalizeToolResult(await this.client.callTool(originalName, checkedArgs, signal ? { signal } : void 0));
229
480
  }
230
481
  /**
231
482
  * Close the underlying transport. Idempotent. If a connect is in flight
@@ -274,16 +525,26 @@ async function defaultConnect(server, ctx) {
274
525
  });
275
526
  if (onStderrLine) {
276
527
  let carry = "";
528
+ const emitLine = (raw) => {
529
+ if (raw.trim() === "") return;
530
+ const line = raw.length > MAX_STDERR_LINE_LENGTH ? `${raw.slice(0, MAX_STDERR_LINE_LENGTH)}…[truncated]` : raw;
531
+ try {
532
+ onStderrLine(line);
533
+ } catch {}
534
+ };
277
535
  transport.stderr?.on("data", (chunk) => {
278
536
  const parts = (carry + chunk.toString()).split("\n");
279
537
  carry = parts.pop() ?? "";
280
- for (const line of parts) {
281
- if (line.trim() === "") continue;
282
- try {
283
- onStderrLine(line);
284
- } catch {}
538
+ for (const line of parts) emitLine(line);
539
+ if (carry.length > MAX_STDERR_LINE_LENGTH) {
540
+ emitLine(carry);
541
+ carry = "";
285
542
  }
286
543
  });
544
+ transport.stderr?.on("end", () => {
545
+ emitLine(carry);
546
+ carry = "";
547
+ });
287
548
  }
288
549
  await client.connect(transport);
289
550
  } else {
@@ -306,7 +567,7 @@ async function defaultConnect(server, ctx) {
306
567
  closeHandler?.();
307
568
  };
308
569
  client.onclose = fireClose;
309
- client.onerror = fireClose;
570
+ client.onerror = () => void 0;
310
571
  return {
311
572
  async listTools() {
312
573
  return (await client.listTools()).tools.map((t) => ({
@@ -335,6 +596,7 @@ async function defaultConnect(server, ctx) {
335
596
  const DEFAULT_IDLE_TTL_MS = 600 * 1e3;
336
597
  const DEFAULT_IDLE_SWEEP_INTERVAL_MS = 60 * 1e3;
337
598
  const DEFAULT_RETRY_SWEEP_INTERVAL_MS = 60 * 1e3;
599
+ const DEFAULT_CONNECT_TIMEOUT_MS = 120 * 1e3;
338
600
  /** First text content of an error result, for host error reporting. */
339
601
  function extractErrorText(result) {
340
602
  for (const item of result.content) if (item.type === "text" && typeof item.text === "string") return item.text.slice(0, 500);
@@ -358,6 +620,8 @@ var McpBundler = class {
358
620
  retrySweepIntervalMs;
359
621
  retrySweepTimer;
360
622
  retrySweepInFlight = false;
623
+ connectTimeoutMs;
624
+ retryNeverConnected;
361
625
  deps;
362
626
  onToolError;
363
627
  onServerCrash;
@@ -366,9 +630,11 @@ var McpBundler = class {
366
630
  reconcileLatch = Promise.resolve();
367
631
  constructor(opts = {}, deps) {
368
632
  this.logger = opts.logger;
369
- this.idleTtlMs = opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
370
- this.idleSweepIntervalMs = opts.idleSweepIntervalMs ?? DEFAULT_IDLE_SWEEP_INTERVAL_MS;
371
- this.retrySweepIntervalMs = opts.retrySweepIntervalMs ?? DEFAULT_RETRY_SWEEP_INTERVAL_MS;
633
+ this.idleTtlMs = checkedDuration(opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS, "idleTtlMs");
634
+ this.idleSweepIntervalMs = checkedDuration(opts.idleSweepIntervalMs ?? DEFAULT_IDLE_SWEEP_INTERVAL_MS, "idleSweepIntervalMs");
635
+ this.retrySweepIntervalMs = checkedDuration(opts.retrySweepIntervalMs ?? DEFAULT_RETRY_SWEEP_INTERVAL_MS, "retrySweepIntervalMs");
636
+ this.connectTimeoutMs = checkedDuration(opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, "connectTimeoutMs", 3e5);
637
+ this.retryNeverConnected = opts.retryNeverConnected ?? false;
372
638
  this.deps = deps ?? { connect: defaultConnect };
373
639
  this.onToolError = opts.onToolError;
374
640
  this.onServerCrash = opts.onServerCrash;
@@ -385,11 +651,12 @@ var McpBundler = class {
385
651
  config,
386
652
  deps: this.deps,
387
653
  logger: this.logger,
654
+ connectTimeoutMs: config.connectionTimeoutMs ?? this.connectTimeoutMs,
388
655
  ...crash ? { onUnexpectedClose: () => {
389
656
  crash(name);
390
657
  } } : {},
391
658
  ...stderr ? { onStderrLine: (line) => {
392
- stderr(name, line);
659
+ stderr(name, redactSensitiveText(line, config));
393
660
  } } : {}
394
661
  });
395
662
  }
@@ -416,7 +683,11 @@ var McpBundler = class {
416
683
  }
417
684
  async doReconcile(desired) {
418
685
  if (this.disposed) throw new Error("McpBundler: disposed");
419
- const desiredNames = new Set(Object.keys(desired));
686
+ const desiredEntries = Object.entries(desired);
687
+ validateServerCount(desiredEntries.length);
688
+ const checkedDesired = {};
689
+ for (const [name, config] of desiredEntries) checkedDesired[validateServerId(name)] = validateServerConfig(config, `server "${name}" config`);
690
+ const desiredNames = new Set(Object.keys(checkedDesired));
420
691
  const currentNames = new Set(this.connections.keys());
421
692
  const added = [];
422
693
  const removed = [];
@@ -428,7 +699,7 @@ var McpBundler = class {
428
699
  if (conn) await conn.close();
429
700
  removed.push(name);
430
701
  }
431
- for (const [name, config] of Object.entries(desired)) {
702
+ for (const [name, config] of Object.entries(checkedDesired)) {
432
703
  const existing = this.connections.get(name);
433
704
  if (!existing) {
434
705
  this.connections.set(name, this.buildConnection(name, config));
@@ -464,17 +735,7 @@ var McpBundler = class {
464
735
  * so cross-server name clashes never produce duplicate registrations.
465
736
  */
466
737
  listTools() {
467
- const seen = /* @__PURE__ */ new Set();
468
- const out = [];
469
- for (const conn of this.connections.values()) for (const tool of conn.snapshotTools()) {
470
- const finalName = disambiguateAgainst(tool.prefixed, seen);
471
- seen.add(finalName);
472
- out.push(finalName === tool.prefixed ? tool : {
473
- ...tool,
474
- prefixed: finalName
475
- });
476
- }
477
- return out;
738
+ return this.resolvedCatalog().map(({ descriptor }) => cloneToolDescriptor(descriptor));
478
739
  }
479
740
  /**
480
741
  * Eagerly connect to every configured server and discover tools. Used by
@@ -503,15 +764,20 @@ var McpBundler = class {
503
764
  * 500ms → 30s cap). A server that keeps `exit(1)`-ing fast-fails while in
504
765
  * backoff, so repeated sweeps are cheap and never become a tight crash-loop
505
766
  * — the backoff widens with each failure. Servers that connect on their
506
- * first warm, and lazily-added servers that never warmed (no `lastError`),
507
- * are left alone.
767
+ * first warm are left alone, as are cleanly-closed (idle-reaped) ones.
768
+ *
769
+ * Lazily-added servers that were NEVER attempted (no client, no
770
+ * `lastError`) are also left alone by default — but with
771
+ * `retryNeverConnected` (the daemon's eager mode) the sweep picks them up,
772
+ * so a server added after the host's warmup pass self-heals within one
773
+ * sweep instead of stranding its tools until a restart.
508
774
  *
509
775
  * Returns the statuses of the servers it attempted (empty if none needed a
510
776
  * retry). Never throws — per-server failures are reflected in the status.
511
777
  */
512
778
  async retryFailed() {
513
779
  if (this.disposed) return [];
514
- const targets = Array.from(this.connections.values()).filter((conn) => !conn.isConnected() && conn.lastErrorMessage() !== void 0);
780
+ const targets = Array.from(this.connections.values()).filter((conn) => !conn.isConnected() && (conn.lastErrorMessage() !== void 0 || this.retryNeverConnected && !conn.hasConnectAttempted()));
515
781
  if (targets.length === 0) return [];
516
782
  return (await Promise.allSettled(targets.map(async (conn) => {
517
783
  try {
@@ -551,10 +817,11 @@ var McpBundler = class {
551
817
  if (this.disposed) return void 0;
552
818
  const conn = this.connections.get(name);
553
819
  if (!conn) return void 0;
820
+ const checkedTimeout = timeoutMs === void 0 ? void 0 : checkedDuration(timeoutMs, "warmServer timeoutMs", 3e5);
554
821
  const connect = conn.ensureConnected();
555
822
  connect.catch(() => void 0);
556
823
  try {
557
- if (timeoutMs !== void 0 && timeoutMs > 0) await this.raceTimeout(connect, timeoutMs, name);
824
+ if (checkedTimeout !== void 0 && checkedTimeout > 0) await this.raceTimeout(connect, checkedTimeout, name);
558
825
  else await connect;
559
826
  } catch (err) {
560
827
  const status = this.statusOf(conn);
@@ -596,7 +863,7 @@ var McpBundler = class {
596
863
  isError: true,
597
864
  content: [{
598
865
  type: "text",
599
- text: `unknown tool: ${prefixed}`
866
+ text: `unknown MCP tool: ${prefixed.slice(0, 128)}`
600
867
  }]
601
868
  };
602
869
  try {
@@ -606,11 +873,11 @@ var McpBundler = class {
606
873
  tool: route.original,
607
874
  prefixed,
608
875
  kind: "result-error",
609
- message: extractErrorText(result)
876
+ message: redactSensitiveText(extractErrorText(result), route.connection.config)
610
877
  });
611
- return result;
878
+ return redactErrorResult(result, route.connection.config);
612
879
  } catch (err) {
613
- const msg = err instanceof Error ? err.message : String(err);
880
+ const msg = redactSensitiveText(err instanceof Error ? err.message : String(err), route.connection.config);
614
881
  this.logger?.error(`[mcp-bundler] tool call failed for "${prefixed}"`, { err: msg });
615
882
  this.reportToolError({
616
883
  server: route.connection.name,
@@ -633,10 +900,32 @@ var McpBundler = class {
633
900
  * tool name. Returns undefined if the tool is not currently advertised.
634
901
  */
635
902
  routeToolName(prefixed) {
636
- for (const conn of this.connections.values()) for (const tool of conn.snapshotTools()) if (tool.prefixed === prefixed) return {
637
- connection: conn,
638
- original: tool.original
639
- };
903
+ const match = this.resolvedCatalog().find(({ descriptor }) => descriptor.prefixed === prefixed);
904
+ return match ? {
905
+ connection: match.connection,
906
+ original: match.descriptor.original
907
+ } : void 0;
908
+ }
909
+ /**
910
+ * Build one collision-resolved catalog used by BOTH registration and
911
+ * routing. Keeping these views together prevents an advertised `-2` name
912
+ * from becoming uncallable when two child names sanitize to the same key.
913
+ */
914
+ resolvedCatalog() {
915
+ const seen = /* @__PURE__ */ new Set();
916
+ const resolved = [];
917
+ for (const conn of this.connections.values()) for (const tool of conn.snapshotTools()) {
918
+ const finalName = disambiguateAgainst(tool.prefixed, seen);
919
+ seen.add(finalName);
920
+ resolved.push({
921
+ connection: conn,
922
+ descriptor: finalName === tool.prefixed ? tool : {
923
+ ...tool,
924
+ prefixed: finalName
925
+ }
926
+ });
927
+ }
928
+ return resolved;
640
929
  }
641
930
  /**
642
931
  * Tear down all connections and stop background tasks. Idempotent.
@@ -695,6 +984,10 @@ var McpBundler = class {
695
984
  await Promise.allSettled(targets.map((c) => c.close()));
696
985
  }
697
986
  };
987
+ function checkedDuration(value, label, max = 864e5) {
988
+ if (!Number.isSafeInteger(value) || value < 0 || value > max) throw new Error(`McpBundler ${label} must be an integer from 0 to ${max.toString()}`);
989
+ return value;
990
+ }
698
991
  //#endregion
699
992
  //#region src/store.ts
700
993
  const DEFAULT_STORE_PATH = join(join(homedir(), ".alfe", "mcp"), "servers.json");
@@ -702,6 +995,7 @@ const DEFAULT_STORE_PATH = join(join(homedir(), ".alfe", "mcp"), "servers.json")
702
995
  const LOCK_WAIT_MS = 5e3;
703
996
  const LOCK_RETRY_INTERVAL_MS = 25;
704
997
  const LOCK_STALE_MS = 1e4;
998
+ const MAX_STORE_BYTES = 1024 * 1024;
705
999
  /**
706
1000
  * On-disk source of truth for the bundler's configured servers.
707
1001
  *
@@ -720,22 +1014,36 @@ var Store = class {
720
1014
  rewatchTimer;
721
1015
  constructor(opts = {}) {
722
1016
  this.storePath = opts.path ?? DEFAULT_STORE_PATH;
1017
+ if (!isAbsolute(this.storePath)) throw new Error("Store path must be absolute");
723
1018
  this.logger = opts.logger;
724
1019
  }
725
1020
  get path() {
726
1021
  return this.storePath;
727
1022
  }
728
1023
  read() {
729
- if (!existsSync(this.storePath)) return cloneEmpty();
1024
+ let fd = -1;
730
1025
  try {
731
- const raw = readFileSync(this.storePath, "utf8");
1026
+ try {
1027
+ fd = openSync(this.storePath, constants.O_RDONLY | constants.O_NOFOLLOW);
1028
+ } catch (err) {
1029
+ if (err.code === "ENOENT") return cloneEmpty();
1030
+ throw err;
1031
+ }
1032
+ const stat = fstatSync(fd);
1033
+ if (!stat.isFile()) throw new Error("store path is not a regular file");
1034
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error("store file is not owned by the current user");
1035
+ if ((stat.mode & 63) !== 0) fchmodSync(fd, 384);
1036
+ if (stat.size > MAX_STORE_BYTES) throw new Error(`store exceeds the ${MAX_STORE_BYTES.toString()} byte limit`);
1037
+ const raw = readFileSync(fd, "utf8");
732
1038
  return normalize(JSON.parse(raw));
733
1039
  } catch (err) {
734
- this.logger?.warn("[mcp-bundler/store] failed to read store; returning empty", {
1040
+ this.logger?.warn("[mcp-bundler/store] rejected unreadable or invalid store", {
735
1041
  err: errMsg$1(err),
736
1042
  path: this.storePath
737
1043
  });
738
- return cloneEmpty();
1044
+ throw new Error(`Store.read: rejected ${this.storePath}: ${errMsg$1(err)}`, { cause: err });
1045
+ } finally {
1046
+ if (fd >= 0) closeSync(fd);
739
1047
  }
740
1048
  }
741
1049
  /**
@@ -748,8 +1056,8 @@ var Store = class {
748
1056
  * The lock guards the read-then-rename window so two processes
749
1057
  * (e.g. two `alfe mcp add` shells, or the CLI racing the daemon)
750
1058
  * can't drop each other's writes. The lock file is at
751
- * `<storePath>.lock`; stale locks (older than `LOCK_STALE_MS`) are
752
- * stolen so a crashed writer doesn't wedge the store.
1059
+ * `<storePath>.lock`; locks whose recorded writer process is no longer
1060
+ * alive are reclaimed so a crashed writer doesn't wedge the store.
753
1061
  *
754
1062
  * Pure-function shape (instead of a `read()` then `write(next)`
755
1063
  * pair) intentionally — it keeps the read-modify-write contract
@@ -757,18 +1065,27 @@ var Store = class {
757
1065
  * other's partial state.
758
1066
  */
759
1067
  update(fn) {
760
- mkdirSync(dirname(this.storePath), { recursive: true });
1068
+ mkdirSync(dirname(this.storePath), {
1069
+ recursive: true,
1070
+ mode: 448
1071
+ });
761
1072
  const release = this.acquireLock();
762
1073
  try {
763
- const next = fn(this.read());
764
- const tempPath = `${this.storePath}.${String(process.pid)}.${String(Date.now())}.tmp`;
765
- writeFileSync(tempPath, JSON.stringify(next, null, 2), {
766
- encoding: "utf8",
767
- mode: 384
768
- });
1074
+ const next = normalize(fn(this.read()));
1075
+ const payload = JSON.stringify(next, null, 2);
1076
+ if (Buffer.byteLength(payload, "utf8") > MAX_STORE_BYTES) throw new Error(`Store.update: result exceeds the ${MAX_STORE_BYTES.toString()} byte limit`);
1077
+ const tempPath = `${this.storePath}.${String(process.pid)}.${randomUUID()}.tmp`;
1078
+ let tempFd = -1;
769
1079
  try {
1080
+ tempFd = openSync(tempPath, "wx", 384);
1081
+ writeFileSync(tempFd, payload, { encoding: "utf8" });
1082
+ fsyncSync(tempFd);
1083
+ closeSync(tempFd);
1084
+ tempFd = -1;
770
1085
  renameSync(tempPath, this.storePath);
1086
+ this.fsyncParentDirectory();
771
1087
  } catch (err) {
1088
+ if (tempFd >= 0) closeSync(tempFd);
772
1089
  try {
773
1090
  unlinkSync(tempPath);
774
1091
  } catch {}
@@ -781,11 +1098,11 @@ var Store = class {
781
1098
  }
782
1099
  /**
783
1100
  * Acquire an inter-process file lock by atomically creating a
784
- * sentinel via `openSync(lockPath, 'wx')`. Spins with bounded
785
- * backoff up to `LOCK_WAIT_MS`. If the lock file is older than
786
- * `LOCK_STALE_MS` it's assumed orphaned (writer crashed mid-update)
787
- * and stolen the write window is sub-second in practice, so
788
- * holding the lock for >5s means something went wrong.
1101
+ * sentinel via `openSync(lockPath, 'wx')`. Waits with bounded
1102
+ * backoff up to `LOCK_WAIT_MS`. A structured lock is reclaimed only when
1103
+ * its writer process is no longer alive; age alone cannot steal a lock
1104
+ * from a slow but valid writer. Old malformed/legacy locks retain a
1105
+ * conservative age fallback.
789
1106
  *
790
1107
  * Returns the release function. Single-process callers are
791
1108
  * unaffected — re-entering the same process spins briefly while
@@ -795,20 +1112,33 @@ var Store = class {
795
1112
  const lockPath = `${this.storePath}.lock`;
796
1113
  const deadline = Date.now() + LOCK_WAIT_MS;
797
1114
  let fd = -1;
1115
+ let heldDevice = -1;
1116
+ let heldInode = -1;
798
1117
  for (;;) try {
799
1118
  fd = openSync(lockPath, "wx", 384);
1119
+ const token = randomUUID();
1120
+ writeFileSync(fd, JSON.stringify({
1121
+ pid: process.pid,
1122
+ token,
1123
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1124
+ }), "utf8");
1125
+ fsyncSync(fd);
1126
+ const stat = fstatSync(fd);
1127
+ heldDevice = stat.dev;
1128
+ heldInode = stat.ino;
800
1129
  break;
801
1130
  } catch (err) {
802
1131
  if (err.code !== "EEXIST") throw err;
803
- if (this.lockIsStale(lockPath)) {
1132
+ const stale = this.inspectStaleLock(lockPath);
1133
+ if (stale) {
804
1134
  try {
805
- unlinkSync(lockPath);
1135
+ const current = lstatSync(lockPath);
1136
+ if (current.dev === stale.device && current.ino === stale.inode) unlinkSync(lockPath);
806
1137
  } catch {}
807
1138
  continue;
808
1139
  }
809
1140
  if (Date.now() >= deadline) throw new Error(`Store.update: timed out waiting for ${lockPath} (held by another writer or stale lock)`);
810
- const sleepUntil = Date.now() + LOCK_RETRY_INTERVAL_MS;
811
- while (Date.now() < sleepUntil);
1141
+ sleepSync(LOCK_RETRY_INTERVAL_MS);
812
1142
  }
813
1143
  const held = fd;
814
1144
  return () => {
@@ -816,16 +1146,56 @@ var Store = class {
816
1146
  closeSync(held);
817
1147
  } catch {}
818
1148
  try {
819
- unlinkSync(lockPath);
1149
+ const current = lstatSync(lockPath);
1150
+ if (current.dev === heldDevice && current.ino === heldInode) unlinkSync(lockPath);
820
1151
  } catch {}
821
1152
  };
822
1153
  }
823
- lockIsStale(lockPath) {
1154
+ inspectStaleLock(lockPath) {
1155
+ let fd = -1;
1156
+ let modifiedAt;
1157
+ let device;
1158
+ let inode;
824
1159
  try {
825
- const st = statSync(lockPath);
826
- return Date.now() - st.mtimeMs > LOCK_STALE_MS;
1160
+ fd = openSync(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
1161
+ const stat = fstatSync(fd);
1162
+ modifiedAt = stat.mtimeMs;
1163
+ device = stat.dev;
1164
+ inode = stat.ino;
1165
+ if (!stat.isFile() || stat.size > 4096) return Date.now() - stat.mtimeMs > LOCK_STALE_MS ? {
1166
+ device,
1167
+ inode
1168
+ } : void 0;
1169
+ const parsed = JSON.parse(readFileSync(fd, "utf8"));
1170
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return Date.now() - stat.mtimeMs > LOCK_STALE_MS ? {
1171
+ device,
1172
+ inode
1173
+ } : void 0;
1174
+ const pid = parsed.pid;
1175
+ if (!Number.isSafeInteger(pid) || pid <= 0) return Date.now() - stat.mtimeMs > LOCK_STALE_MS ? {
1176
+ device,
1177
+ inode
1178
+ } : void 0;
1179
+ return !processIsAlive(pid) ? {
1180
+ device,
1181
+ inode
1182
+ } : void 0;
827
1183
  } catch {
828
- return false;
1184
+ return modifiedAt !== void 0 && device !== void 0 && inode !== void 0 && Date.now() - modifiedAt > LOCK_STALE_MS ? {
1185
+ device,
1186
+ inode
1187
+ } : void 0;
1188
+ } finally {
1189
+ if (fd >= 0) closeSync(fd);
1190
+ }
1191
+ }
1192
+ fsyncParentDirectory() {
1193
+ let fd = -1;
1194
+ try {
1195
+ fd = openSync(dirname(this.storePath), constants.O_RDONLY);
1196
+ fsyncSync(fd);
1197
+ } catch {} finally {
1198
+ if (fd >= 0) closeSync(fd);
829
1199
  }
830
1200
  }
831
1201
  /**
@@ -849,7 +1219,10 @@ var Store = class {
849
1219
  }
850
1220
  ensureWatcher() {
851
1221
  if (this.watcher) return;
852
- mkdirSync(dirname(this.storePath), { recursive: true });
1222
+ mkdirSync(dirname(this.storePath), {
1223
+ recursive: true,
1224
+ mode: 448
1225
+ });
853
1226
  const dir = dirname(this.storePath);
854
1227
  const basename = this.storePath.slice(dir.length + 1);
855
1228
  let pending;
@@ -898,43 +1271,60 @@ var Store = class {
898
1271
  function defaultStorePath() {
899
1272
  return DEFAULT_STORE_PATH;
900
1273
  }
1274
+ /**
1275
+ * One-way launch-identity fingerprint for cache invalidation across IPC.
1276
+ * Store entries contain credentials in env/headers, so consumers that only
1277
+ * need equality receive this digest rather than the executable configuration.
1278
+ */
1279
+ function serverLaunchFingerprint(entry) {
1280
+ const identity = {
1281
+ config: toServerConfig(entry),
1282
+ version: entry.version ?? null
1283
+ };
1284
+ return `sha256:${createHash("sha256").update(stableJson(identity)).digest("hex")}`;
1285
+ }
901
1286
  /** Pull the runtime config (transport + transport-specific fields) out of a stored entry. */
902
1287
  function toServerConfig(entry) {
903
1288
  if (entry.transport === "stdio") {
904
- const { command, args, env, cwd } = entry;
1289
+ const { command, args, env, cwd, connectionTimeoutMs } = entry;
905
1290
  const cfg = { command };
906
- if (args) cfg.args = args;
907
- if (env) cfg.env = env;
1291
+ if (args) cfg.args = [...args];
1292
+ if (env) cfg.env = { ...env };
908
1293
  if (cwd) cfg.cwd = cwd;
909
- return cfg;
1294
+ if (connectionTimeoutMs !== void 0) cfg.connectionTimeoutMs = connectionTimeoutMs;
1295
+ return validateServerConfig(cfg);
910
1296
  }
911
1297
  const { url, transport, headers, connectionTimeoutMs } = entry;
912
1298
  const cfg = {
913
1299
  url,
914
1300
  transport
915
1301
  };
916
- if (headers) cfg.headers = headers;
1302
+ if (headers) cfg.headers = { ...headers };
917
1303
  if (connectionTimeoutMs !== void 0) cfg.connectionTimeoutMs = connectionTimeoutMs;
918
- return cfg;
1304
+ return validateServerConfig(cfg);
919
1305
  }
920
1306
  /** Build a stored entry from a runtime config + ownership metadata. */
921
1307
  function toStoredEntry(config, meta) {
1308
+ validateOwner(meta.owner);
1309
+ const checked = validateServerConfig(config);
922
1310
  const addedAt = meta.addedAt ?? (/* @__PURE__ */ new Date()).toISOString();
923
- if ("command" in config) return {
1311
+ validateTimestamp(addedAt, "addedAt");
1312
+ if (meta.version !== void 0) validateVersion(meta.version);
1313
+ if ("command" in checked) return {
924
1314
  transport: "stdio",
925
1315
  owner: meta.owner,
926
1316
  addedAt,
927
1317
  ...meta.version !== void 0 ? { version: meta.version } : {},
928
- ...config
1318
+ ...checked
929
1319
  };
930
- const transport = meta.transport ?? config.transport ?? "sse";
1320
+ const transport = meta.transport ?? checked.transport ?? "sse";
931
1321
  if (transport === "stdio") throw new Error("toStoredEntry: transport=stdio specified but config is remote-shaped");
932
1322
  return {
933
- transport,
934
1323
  owner: meta.owner,
935
1324
  addedAt,
936
1325
  ...meta.version !== void 0 ? { version: meta.version } : {},
937
- ...config
1326
+ ...checked,
1327
+ transport
938
1328
  };
939
1329
  }
940
1330
  function cloneEmpty() {
@@ -945,17 +1335,90 @@ function cloneEmpty() {
945
1335
  };
946
1336
  }
947
1337
  function normalize(raw) {
948
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return cloneEmpty();
1338
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("store root must be an object");
949
1339
  const r = raw;
1340
+ const rawServers = r.servers;
1341
+ if (!rawServers || typeof rawServers !== "object" || Array.isArray(rawServers)) throw new Error("store.servers must be an object");
1342
+ const serverEntries = Object.entries(rawServers);
1343
+ validateServerCount(serverEntries.length);
1344
+ const servers = {};
1345
+ for (const [id, rawEntry] of serverEntries) {
1346
+ validateServerId(id);
1347
+ if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) throw new Error(`store server "${id}" must be an object`);
1348
+ const entry = rawEntry;
1349
+ validateOwner(entry.owner);
1350
+ validateTimestamp(entry.addedAt, `store server "${id}" addedAt`);
1351
+ if (entry.version !== void 0) validateVersion(entry.version);
1352
+ if (![
1353
+ "stdio",
1354
+ "sse",
1355
+ "streamable-http"
1356
+ ].includes(String(entry.transport))) throw new Error(`store server "${id}" has an invalid transport`);
1357
+ servers[id] = toStoredEntry(entry.transport === "stdio" ? validateServerConfig(entry, `store server "${id}"`) : validateServerConfig({
1358
+ ...entry,
1359
+ transport: entry.transport
1360
+ }, `store server "${id}"`), {
1361
+ owner: entry.owner,
1362
+ transport: entry.transport,
1363
+ addedAt: entry.addedAt,
1364
+ version: entry.version
1365
+ });
1366
+ }
1367
+ const rawConfig = r.config ?? {};
1368
+ if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) throw new Error("store.config must be an object");
1369
+ const config = {};
1370
+ const ttl = rawConfig.sessionIdleTtlMs;
1371
+ if (ttl !== void 0) {
1372
+ if (typeof ttl !== "number" || !Number.isSafeInteger(ttl) || ttl < 0 || ttl > 864e5) throw new Error("store.config.sessionIdleTtlMs must be an integer from 0 to 86400000");
1373
+ config.sessionIdleTtlMs = ttl;
1374
+ }
1375
+ const rawOwned = r._ownedOpenclawKeys;
1376
+ if (rawOwned !== void 0 && !Array.isArray(rawOwned)) throw new Error("store._ownedOpenclawKeys must be an array");
1377
+ const owned = [...new Set((rawOwned ?? []).map((value) => validateServerId(value, "store._ownedOpenclawKeys entry")))];
1378
+ validateServerCount(owned.length);
950
1379
  return {
951
- servers: r.servers && typeof r.servers === "object" ? r.servers : {},
952
- config: r.config && typeof r.config === "object" ? r.config : {},
953
- _ownedOpenclawKeys: Array.isArray(r._ownedOpenclawKeys) ? r._ownedOpenclawKeys.slice() : []
1380
+ servers,
1381
+ config,
1382
+ _ownedOpenclawKeys: owned
954
1383
  };
955
1384
  }
1385
+ function validateOwner(owner) {
1386
+ if (owner === "cli" || owner === "manual") return;
1387
+ if (typeof owner === "string" && owner.startsWith("integration:")) {
1388
+ validateServerId(owner.slice(12), "integration owner id");
1389
+ return;
1390
+ }
1391
+ throw new Error("server owner must be cli, manual, or integration:<id>");
1392
+ }
1393
+ function validateTimestamp(value, label) {
1394
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new Error(`${label} must be an ISO timestamp`);
1395
+ }
1396
+ function validateVersion(value) {
1397
+ if (typeof value !== "string" || !/^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$/.test(value)) throw new Error("server version is invalid");
1398
+ }
1399
+ function processIsAlive(pid) {
1400
+ try {
1401
+ process.kill(pid, 0);
1402
+ return true;
1403
+ } catch (err) {
1404
+ return err.code !== "ESRCH";
1405
+ }
1406
+ }
1407
+ const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
1408
+ function sleepSync(ms) {
1409
+ Atomics.wait(sleepBuffer, 0, 0, ms);
1410
+ }
956
1411
  function errMsg$1(err) {
957
1412
  return err instanceof Error ? err.message : String(err);
958
1413
  }
1414
+ function stableJson(value) {
1415
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
1416
+ if (value !== null && typeof value === "object") {
1417
+ const record = value;
1418
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
1419
+ }
1420
+ return value === void 0 ? "null" : JSON.stringify(value);
1421
+ }
959
1422
  //#endregion
960
1423
  //#region src/manager.ts
961
1424
  /**
@@ -975,11 +1438,13 @@ function errMsg$1(err) {
975
1438
  */
976
1439
  var Manager = class {
977
1440
  store;
1441
+ ownsStore;
978
1442
  logger;
979
1443
  bundler;
980
1444
  changeListeners = /* @__PURE__ */ new Set();
981
1445
  storeUnsubscribe;
982
1446
  constructor(opts = {}) {
1447
+ this.ownsStore = opts.store === void 0;
983
1448
  this.store = opts.store ?? new Store({ logger: opts.logger });
984
1449
  this.logger = opts.logger;
985
1450
  }
@@ -990,11 +1455,14 @@ var Manager = class {
990
1455
  /**
991
1456
  * Register or overwrite a server entry. Mutation lands in the store
992
1457
  * synchronously; if a bundler has been attached via `loadIntoBundler`,
993
- * it gets re-reconciled in the background (errors logged, never
994
- * thrown the store is the source of truth, the bundler is derived).
1458
+ * it is re-reconciled BEFORE `onChange` listeners fire. A reconcile error is
1459
+ * logged and does not roll back the durable store mutation, but listeners do
1460
+ * not fire against stale live state. The ordering is load-bearing: the daemon's `onChange`
1461
+ * handler warms the bundler's current connections, so the just-added
1462
+ * server must already have its Connection object or it is never warmed.
995
1463
  */
996
1464
  async addServer(config, opts) {
997
- if (!opts.id) throw new Error("Manager.addServer: id is required");
1465
+ validateServerId(opts.id, "Manager.addServer id");
998
1466
  const owner = opts.owner ?? "manual";
999
1467
  this.store.update((cur) => {
1000
1468
  const previousAddedAt = lookupAddedAt(cur.servers, opts.id);
@@ -1012,9 +1480,7 @@ var Manager = class {
1012
1480
  }
1013
1481
  };
1014
1482
  });
1015
- this.scheduleBundlerReconcile();
1016
- this.fireChange();
1017
- return Promise.resolve();
1483
+ if (await this.reconcileBundlerLogged()) this.fireChange();
1018
1484
  }
1019
1485
  /**
1020
1486
  * Remove a single server entry. No-op if the id isn't in the store.
@@ -1022,17 +1488,22 @@ var Manager = class {
1022
1488
  * when supplied — the CLI uses this to guard `alfe mcp remove` from
1023
1489
  * accidentally clobbering integration- or cli-owned entries.
1024
1490
  */
1025
- removeServer(id, opts = {}) {
1026
- const existing = lookupEntry(this.store.read().servers, id);
1027
- if (!existing) return Promise.resolve(false);
1028
- if (opts.expectedOwner && existing.owner !== opts.expectedOwner) return Promise.reject(/* @__PURE__ */ new Error(`Manager.removeServer: server "${id}" is owned by "${existing.owner}", not "${opts.expectedOwner}"`));
1029
- this.store.update((cur) => ({
1030
- ...cur,
1031
- servers: Object.fromEntries(Object.entries(cur.servers).filter(([k]) => k !== id))
1032
- }));
1033
- this.scheduleBundlerReconcile();
1034
- this.fireChange();
1035
- return Promise.resolve(true);
1491
+ async removeServer(id, opts = {}) {
1492
+ validateServerId(id, "Manager.removeServer id");
1493
+ const outcome = { removed: false };
1494
+ this.store.update((cur) => {
1495
+ const existing = lookupEntry(cur.servers, id);
1496
+ if (!existing) return cur;
1497
+ if (opts.expectedOwner && existing.owner !== opts.expectedOwner) throw new Error(`Manager.removeServer: server "${id}" is owned by "${existing.owner}", not "${opts.expectedOwner}"`);
1498
+ outcome.removed = true;
1499
+ return {
1500
+ ...cur,
1501
+ servers: Object.fromEntries(Object.entries(cur.servers).filter(([key]) => key !== id))
1502
+ };
1503
+ });
1504
+ if (!outcome.removed) return false;
1505
+ if (await this.reconcileBundlerLogged()) this.fireChange();
1506
+ return true;
1036
1507
  }
1037
1508
  /** Drop every entry whose owner matches — used by integration uninstall. */
1038
1509
  async removeServersByOwner(owner) {
@@ -1048,10 +1519,9 @@ var Manager = class {
1048
1519
  };
1049
1520
  });
1050
1521
  if (removed.length > 0) {
1051
- this.scheduleBundlerReconcile();
1052
- this.fireChange();
1522
+ if (await this.reconcileBundlerLogged()) this.fireChange();
1053
1523
  }
1054
- return Promise.resolve(removed);
1524
+ return removed;
1055
1525
  }
1056
1526
  /** Read-only snapshot for `alfe mcp list` and similar UIs. */
1057
1527
  listServers() {
@@ -1082,8 +1552,14 @@ var Manager = class {
1082
1552
  */
1083
1553
  async warmServer(id, timeoutMs) {
1084
1554
  if (!this.bundler) return null;
1085
- await this.reconcileBundler();
1086
- return await this.bundler.warmServer(id, timeoutMs) ?? null;
1555
+ try {
1556
+ validateServerId(id, "Manager.warmServer id");
1557
+ await this.reconcileBundler();
1558
+ return await this.bundler.warmServer(id, timeoutMs) ?? null;
1559
+ } catch (err) {
1560
+ this.logger?.warn("[mcp-bundler/manager] warm reconcile failed", { err: errMsg(err) });
1561
+ return null;
1562
+ }
1087
1563
  }
1088
1564
  /**
1089
1565
  * Push the current store contents into a bundler instance (which owns
@@ -1108,24 +1584,34 @@ var Manager = class {
1108
1584
  }
1109
1585
  /**
1110
1586
  * Detach from the bundler and stop watching the store. Safe to call
1111
- * multiple times. Does not dispose the underlying `Store` so the
1112
- * shared instance survives multi-manager environments (rare).
1587
+ * multiple times. An injected Store remains owned by its caller; a Store
1588
+ * constructed by this manager is disposed here.
1113
1589
  */
1114
1590
  async dispose() {
1115
1591
  if (this.storeUnsubscribe) {
1116
1592
  this.storeUnsubscribe();
1117
1593
  this.storeUnsubscribe = void 0;
1118
1594
  }
1119
- this.store.dispose();
1595
+ if (this.ownsStore) this.store.dispose();
1120
1596
  this.changeListeners.clear();
1121
1597
  this.bundler = void 0;
1122
1598
  return Promise.resolve();
1123
1599
  }
1124
- scheduleBundlerReconcile() {
1125
- if (!this.bundler) return;
1126
- this.reconcileBundler().catch((err) => {
1600
+ /**
1601
+ * Awaited by every mutator so `onChange` listeners observe a bundler
1602
+ * that already contains the mutation. Reconcile failures are logged and
1603
+ * return false so callers preserve the durable mutation without notifying
1604
+ * listeners against stale derived state.
1605
+ */
1606
+ async reconcileBundlerLogged() {
1607
+ if (!this.bundler) return true;
1608
+ try {
1609
+ await this.reconcileBundler();
1610
+ return true;
1611
+ } catch (err) {
1127
1612
  this.logger?.warn("[mcp-bundler/manager] bundler reconcile failed", { err: errMsg(err) });
1128
- });
1613
+ return false;
1614
+ }
1129
1615
  }
1130
1616
  async reconcileBundler() {
1131
1617
  if (!this.bundler) return;
@@ -1172,15 +1658,17 @@ function lookupAddedAt(servers, id) {
1172
1658
  */
1173
1659
  function checkPatternA(tools, options) {
1174
1660
  const selectorNames = typeof options.selector === "string" ? [options.selector] : options.selector;
1661
+ if (selectorNames.length === 0 || selectorNames.some((name) => typeof name !== "string" || name.length === 0)) throw new Error("Pattern A selector must contain at least one non-empty property name");
1175
1662
  const exempt = new Set(options.exempt ?? []);
1176
1663
  const violations = [];
1177
1664
  for (const tool of tools) {
1178
1665
  if (exempt.has(tool.name)) continue;
1179
- const schema = tool.parameters;
1180
- const properties = schema.properties ?? {};
1181
- const required = schema.required ?? [];
1182
- const matched = selectorNames.find((name) => name in properties);
1183
- if (!matched) {
1666
+ const schema = isRecord(tool.parameters) ? tool.parameters : {};
1667
+ const rawProperties = schema.properties;
1668
+ const properties = isRecord(rawProperties) ? rawProperties : {};
1669
+ const required = Array.isArray(schema.required) ? schema.required.filter((value) => typeof value === "string") : [];
1670
+ const present = selectorNames.filter((name) => Object.hasOwn(properties, name));
1671
+ if (present.length === 0) {
1184
1672
  violations.push({
1185
1673
  tool: tool.name,
1186
1674
  reason: "missing-selector-property",
@@ -1188,20 +1676,29 @@ function checkPatternA(tools, options) {
1188
1676
  });
1189
1677
  continue;
1190
1678
  }
1191
- if (!required.includes(matched)) {
1679
+ const requiredSelectors = present.filter((name) => required.includes(name));
1680
+ if (requiredSelectors.length === 0) {
1192
1681
  violations.push({
1193
1682
  tool: tool.name,
1194
1683
  reason: "selector-not-required",
1195
- detail: `selector "${matched}" present in properties but missing from inputSchema.required`
1684
+ detail: `selector [${present.join(", ")}] present in properties but missing from inputSchema.required`
1196
1685
  });
1197
1686
  continue;
1198
1687
  }
1199
- const type = properties[matched].type;
1200
- if (type !== "string") violations.push({
1201
- tool: tool.name,
1202
- reason: "selector-property-not-string",
1203
- detail: `selector "${matched}" must be JSON Schema type=string (found ${JSON.stringify(type)})`
1204
- });
1688
+ if (!requiredSelectors.find((name) => {
1689
+ const property = properties[name];
1690
+ return isRecord(property) && property.type === "string";
1691
+ })) {
1692
+ const foundTypes = requiredSelectors.map((name) => {
1693
+ const property = properties[name];
1694
+ return `${name}=${JSON.stringify(isRecord(property) ? property.type : void 0)}`;
1695
+ });
1696
+ violations.push({
1697
+ tool: tool.name,
1698
+ reason: "selector-property-not-string",
1699
+ detail: `at least one required selector must be JSON Schema type=string (found ${foundTypes.join(", ")})`
1700
+ });
1701
+ }
1205
1702
  }
1206
1703
  return violations;
1207
1704
  }
@@ -1212,7 +1709,7 @@ function checkPatternA(tools, options) {
1212
1709
  function assertPatternA(tools, options) {
1213
1710
  const violations = checkPatternA(tools, options);
1214
1711
  if (violations.length === 0) return;
1215
- const lines = violations.map((v) => ` - [${v.reason}] ${v.tool}: ${v.detail}`);
1712
+ const lines = violations.map((violation) => ` - [${violation.reason}] ${violation.tool}: ${violation.detail}`);
1216
1713
  throw new Error(`Pattern A validation failed for ${String(violations.length)} tool(s):\n${lines.join("\n")}`);
1217
1714
  }
1218
1715
  /**
@@ -1227,7 +1724,10 @@ function fromMcpDescriptor(descriptor) {
1227
1724
  parameters: descriptor.parameters
1228
1725
  };
1229
1726
  }
1727
+ function isRecord(value) {
1728
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1729
+ }
1230
1730
  //#endregion
1231
- export { Connection, Manager, McpBundler, STDIO_ENV_DENYLIST, Store, assertPatternA, buildNamespacedToolName, checkPatternA, defaultConnect, defaultStorePath, disambiguateAgainst, fromMcpDescriptor, sanitizeNameSegment, sanitizeStdioEnv, toServerConfig, toStoredEntry };
1731
+ export { Connection, Manager, McpBundler, STDIO_ENV_DENYLIST, Store, assertPatternA, buildNamespacedToolName, checkPatternA, defaultConnect, defaultStorePath, disambiguateAgainst, fromMcpDescriptor, sanitizeNameSegment, sanitizeStdioEnv, serverLaunchFingerprint, toServerConfig, toStoredEntry };
1232
1732
 
1233
1733
  //# sourceMappingURL=index.js.map