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