@alfe.ai/mcp-bundler 0.4.0 → 0.4.2

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",
@@ -94,8 +314,8 @@ var Connection = class Connection {
94
314
  /** Whether any connect has ever been attempted — drives the eager retry sweep. */
95
315
  connectAttempted = false;
96
316
  constructor(params) {
97
- this.name = params.name;
98
- this.config = params.config;
317
+ this.name = validateServerId(params.name);
318
+ this.config = validateServerConfig(params.config);
99
319
  this.deps = params.deps;
100
320
  this.logger = params.logger;
101
321
  this.onUnexpectedClose = params.onUnexpectedClose;
@@ -104,7 +324,7 @@ var Connection = class Connection {
104
324
  }
105
325
  /** Returns the most recent known tool list. May be empty if the server hasn't connected yet. */
106
326
  snapshotTools() {
107
- return this.tools;
327
+ return this.tools.map(cloneToolDescriptor);
108
328
  }
109
329
  /** Whether an MCP child process / remote connection has been established. */
110
330
  isConnected() {
@@ -177,14 +397,7 @@ var Connection = class Connection {
177
397
  client.onClose?.(() => {
178
398
  this.handleUnexpectedClose(client);
179
399
  });
180
- this.tools = advertised.map((t) => ({
181
- prefixed: buildNamespacedToolName(this.name, t.name),
182
- server: this.name,
183
- original: t.name,
184
- label: (t.description ?? t.name).slice(0, 80),
185
- description: t.description ?? "",
186
- parameters: t.inputSchema
187
- }));
400
+ this.tools = normalizeChildCatalog(this.name, advertised);
188
401
  this.lastUsedAt = Date.now();
189
402
  this.consecutiveFailures = 0;
190
403
  this.lastError = void 0;
@@ -193,7 +406,7 @@ var Connection = class Connection {
193
406
  } catch (err) {
194
407
  attempt.then(({ client }) => client.close()).catch(() => void 0);
195
408
  this.consecutiveFailures += 1;
196
- this.lastError = err instanceof Error ? err.message : String(err);
409
+ this.lastError = redactSensitiveText(err instanceof Error ? err.message : String(err), this.config);
197
410
  const backoff = Math.min(Connection.RECONNECT_BACKOFF_BASE_MS * 2 ** (this.consecutiveFailures - 1), Connection.RECONNECT_BACKOFF_MAX_MS);
198
411
  this.reconnectBlockedUntilMs = Date.now() + backoff;
199
412
  throw err;
@@ -226,6 +439,7 @@ var Connection = class Connection {
226
439
  this.logger?.warn(`[mcp-bundler] server "${this.name}" connection closed unexpectedly; will re-spawn on next use`);
227
440
  this.client = void 0;
228
441
  this.tools = [];
442
+ handle.close().catch(() => void 0);
229
443
  try {
230
444
  this.onUnexpectedClose?.();
231
445
  } catch {}
@@ -243,14 +457,10 @@ var Connection = class Connection {
243
457
  }
244
458
  this.refreshInFlight = true;
245
459
  try {
246
- this.tools = (await this.client.listTools()).map((t) => ({
247
- prefixed: buildNamespacedToolName(this.name, t.name),
248
- server: this.name,
249
- original: t.name,
250
- label: (t.description ?? t.name).slice(0, 80),
251
- description: t.description ?? "",
252
- parameters: t.inputSchema
253
- }));
460
+ const handle = this.client;
461
+ const advertised = await handle.listTools();
462
+ if (this.client !== handle) return;
463
+ this.tools = normalizeChildCatalog(this.name, advertised);
254
464
  this.logger?.debug(`[mcp-bundler] server "${this.name}" refreshed, ${this.tools.length.toString()} tool(s)`);
255
465
  } finally {
256
466
  this.refreshInFlight = false;
@@ -266,7 +476,8 @@ var Connection = class Connection {
266
476
  await this.ensureConnected();
267
477
  if (!this.client) throw new Error(`server "${this.name}" failed to connect`);
268
478
  this.lastUsedAt = Date.now();
269
- 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));
270
481
  }
271
482
  /**
272
483
  * Close the underlying transport. Idempotent. If a connect is in flight
@@ -315,16 +526,26 @@ async function defaultConnect(server, ctx) {
315
526
  });
316
527
  if (onStderrLine) {
317
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
+ };
318
536
  transport.stderr?.on("data", (chunk) => {
319
537
  const parts = (carry + chunk.toString()).split("\n");
320
538
  carry = parts.pop() ?? "";
321
- for (const line of parts) {
322
- if (line.trim() === "") continue;
323
- try {
324
- onStderrLine(line);
325
- } catch {}
539
+ for (const line of parts) emitLine(line);
540
+ if (carry.length > MAX_STDERR_LINE_LENGTH) {
541
+ emitLine(carry);
542
+ carry = "";
326
543
  }
327
544
  });
545
+ transport.stderr?.on("end", () => {
546
+ emitLine(carry);
547
+ carry = "";
548
+ });
328
549
  }
329
550
  await client.connect(transport);
330
551
  } else {
@@ -347,7 +568,7 @@ async function defaultConnect(server, ctx) {
347
568
  closeHandler?.();
348
569
  };
349
570
  client.onclose = fireClose;
350
- client.onerror = fireClose;
571
+ client.onerror = () => void 0;
351
572
  return {
352
573
  async listTools() {
353
574
  return (await client.listTools()).tools.map((t) => ({
@@ -410,10 +631,10 @@ var McpBundler = class {
410
631
  reconcileLatch = Promise.resolve();
411
632
  constructor(opts = {}, deps) {
412
633
  this.logger = opts.logger;
413
- this.idleTtlMs = opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
414
- this.idleSweepIntervalMs = opts.idleSweepIntervalMs ?? DEFAULT_IDLE_SWEEP_INTERVAL_MS;
415
- this.retrySweepIntervalMs = opts.retrySweepIntervalMs ?? DEFAULT_RETRY_SWEEP_INTERVAL_MS;
416
- this.connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_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);
417
638
  this.retryNeverConnected = opts.retryNeverConnected ?? false;
418
639
  this.deps = deps ?? { connect: defaultConnect };
419
640
  this.onToolError = opts.onToolError;
@@ -436,7 +657,7 @@ var McpBundler = class {
436
657
  crash(name);
437
658
  } } : {},
438
659
  ...stderr ? { onStderrLine: (line) => {
439
- stderr(name, line);
660
+ stderr(name, redactSensitiveText(line, config));
440
661
  } } : {}
441
662
  });
442
663
  }
@@ -463,7 +684,11 @@ var McpBundler = class {
463
684
  }
464
685
  async doReconcile(desired) {
465
686
  if (this.disposed) throw new Error("McpBundler: disposed");
466
- 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));
467
692
  const currentNames = new Set(this.connections.keys());
468
693
  const added = [];
469
694
  const removed = [];
@@ -475,7 +700,7 @@ var McpBundler = class {
475
700
  if (conn) await conn.close();
476
701
  removed.push(name);
477
702
  }
478
- for (const [name, config] of Object.entries(desired)) {
703
+ for (const [name, config] of Object.entries(checkedDesired)) {
479
704
  const existing = this.connections.get(name);
480
705
  if (!existing) {
481
706
  this.connections.set(name, this.buildConnection(name, config));
@@ -511,17 +736,7 @@ var McpBundler = class {
511
736
  * so cross-server name clashes never produce duplicate registrations.
512
737
  */
513
738
  listTools() {
514
- const seen = /* @__PURE__ */ new Set();
515
- const out = [];
516
- for (const conn of this.connections.values()) for (const tool of conn.snapshotTools()) {
517
- const finalName = disambiguateAgainst(tool.prefixed, seen);
518
- seen.add(finalName);
519
- out.push(finalName === tool.prefixed ? tool : {
520
- ...tool,
521
- prefixed: finalName
522
- });
523
- }
524
- return out;
739
+ return this.resolvedCatalog().map(({ descriptor }) => cloneToolDescriptor(descriptor));
525
740
  }
526
741
  /**
527
742
  * Eagerly connect to every configured server and discover tools. Used by
@@ -603,10 +818,11 @@ var McpBundler = class {
603
818
  if (this.disposed) return void 0;
604
819
  const conn = this.connections.get(name);
605
820
  if (!conn) return void 0;
821
+ const checkedTimeout = timeoutMs === void 0 ? void 0 : checkedDuration(timeoutMs, "warmServer timeoutMs", 3e5);
606
822
  const connect = conn.ensureConnected();
607
823
  connect.catch(() => void 0);
608
824
  try {
609
- 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);
610
826
  else await connect;
611
827
  } catch (err) {
612
828
  const status = this.statusOf(conn);
@@ -648,7 +864,7 @@ var McpBundler = class {
648
864
  isError: true,
649
865
  content: [{
650
866
  type: "text",
651
- text: `unknown tool: ${prefixed}`
867
+ text: `unknown MCP tool: ${prefixed.slice(0, 128)}`
652
868
  }]
653
869
  };
654
870
  try {
@@ -658,11 +874,11 @@ var McpBundler = class {
658
874
  tool: route.original,
659
875
  prefixed,
660
876
  kind: "result-error",
661
- message: extractErrorText(result)
877
+ message: redactSensitiveText(extractErrorText(result), route.connection.config)
662
878
  });
663
- return result;
879
+ return redactErrorResult(result, route.connection.config);
664
880
  } catch (err) {
665
- const msg = err instanceof Error ? err.message : String(err);
881
+ const msg = redactSensitiveText(err instanceof Error ? err.message : String(err), route.connection.config);
666
882
  this.logger?.error(`[mcp-bundler] tool call failed for "${prefixed}"`, { err: msg });
667
883
  this.reportToolError({
668
884
  server: route.connection.name,
@@ -685,10 +901,32 @@ var McpBundler = class {
685
901
  * tool name. Returns undefined if the tool is not currently advertised.
686
902
  */
687
903
  routeToolName(prefixed) {
688
- for (const conn of this.connections.values()) for (const tool of conn.snapshotTools()) if (tool.prefixed === prefixed) return {
689
- connection: conn,
690
- original: tool.original
691
- };
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;
692
930
  }
693
931
  /**
694
932
  * Tear down all connections and stop background tasks. Idempotent.
@@ -747,6 +985,10 @@ var McpBundler = class {
747
985
  await Promise.allSettled(targets.map((c) => c.close()));
748
986
  }
749
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
+ }
750
992
  //#endregion
751
993
  //#region src/store.ts
752
994
  const DEFAULT_STORE_PATH = (0, node_path.join)((0, node_path.join)((0, node_os.homedir)(), ".alfe", "mcp"), "servers.json");
@@ -754,6 +996,7 @@ const DEFAULT_STORE_PATH = (0, node_path.join)((0, node_path.join)((0, node_os.h
754
996
  const LOCK_WAIT_MS = 5e3;
755
997
  const LOCK_RETRY_INTERVAL_MS = 25;
756
998
  const LOCK_STALE_MS = 1e4;
999
+ const MAX_STORE_BYTES = 1024 * 1024;
757
1000
  /**
758
1001
  * On-disk source of truth for the bundler's configured servers.
759
1002
  *
@@ -772,22 +1015,36 @@ var Store = class {
772
1015
  rewatchTimer;
773
1016
  constructor(opts = {}) {
774
1017
  this.storePath = opts.path ?? DEFAULT_STORE_PATH;
1018
+ if (!(0, node_path.isAbsolute)(this.storePath)) throw new Error("Store path must be absolute");
775
1019
  this.logger = opts.logger;
776
1020
  }
777
1021
  get path() {
778
1022
  return this.storePath;
779
1023
  }
780
1024
  read() {
781
- if (!(0, node_fs.existsSync)(this.storePath)) return cloneEmpty();
1025
+ let fd = -1;
782
1026
  try {
783
- 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");
784
1039
  return normalize(JSON.parse(raw));
785
1040
  } catch (err) {
786
- this.logger?.warn("[mcp-bundler/store] failed to read store; returning empty", {
1041
+ this.logger?.warn("[mcp-bundler/store] rejected unreadable or invalid store", {
787
1042
  err: errMsg$1(err),
788
1043
  path: this.storePath
789
1044
  });
790
- 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);
791
1048
  }
792
1049
  }
793
1050
  /**
@@ -800,8 +1057,8 @@ var Store = class {
800
1057
  * The lock guards the read-then-rename window so two processes
801
1058
  * (e.g. two `alfe mcp add` shells, or the CLI racing the daemon)
802
1059
  * can't drop each other's writes. The lock file is at
803
- * `<storePath>.lock`; stale locks (older than `LOCK_STALE_MS`) are
804
- * 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.
805
1062
  *
806
1063
  * Pure-function shape (instead of a `read()` then `write(next)`
807
1064
  * pair) intentionally — it keeps the read-modify-write contract
@@ -809,18 +1066,27 @@ var Store = class {
809
1066
  * other's partial state.
810
1067
  */
811
1068
  update(fn) {
812
- (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
+ });
813
1073
  const release = this.acquireLock();
814
1074
  try {
815
- const next = fn(this.read());
816
- const tempPath = `${this.storePath}.${String(process.pid)}.${String(Date.now())}.tmp`;
817
- (0, node_fs.writeFileSync)(tempPath, JSON.stringify(next, null, 2), {
818
- encoding: "utf8",
819
- mode: 384
820
- });
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;
821
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;
822
1086
  (0, node_fs.renameSync)(tempPath, this.storePath);
1087
+ this.fsyncParentDirectory();
823
1088
  } catch (err) {
1089
+ if (tempFd >= 0) (0, node_fs.closeSync)(tempFd);
824
1090
  try {
825
1091
  (0, node_fs.unlinkSync)(tempPath);
826
1092
  } catch {}
@@ -833,11 +1099,11 @@ var Store = class {
833
1099
  }
834
1100
  /**
835
1101
  * Acquire an inter-process file lock by atomically creating a
836
- * sentinel via `openSync(lockPath, 'wx')`. Spins with bounded
837
- * backoff up to `LOCK_WAIT_MS`. If the lock file is older than
838
- * `LOCK_STALE_MS` it's assumed orphaned (writer crashed mid-update)
839
- * and stolen the write window is sub-second in practice, so
840
- * 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.
841
1107
  *
842
1108
  * Returns the release function. Single-process callers are
843
1109
  * unaffected — re-entering the same process spins briefly while
@@ -847,20 +1113,33 @@ var Store = class {
847
1113
  const lockPath = `${this.storePath}.lock`;
848
1114
  const deadline = Date.now() + LOCK_WAIT_MS;
849
1115
  let fd = -1;
1116
+ let heldDevice = -1;
1117
+ let heldInode = -1;
850
1118
  for (;;) try {
851
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;
852
1130
  break;
853
1131
  } catch (err) {
854
1132
  if (err.code !== "EEXIST") throw err;
855
- if (this.lockIsStale(lockPath)) {
1133
+ const stale = this.inspectStaleLock(lockPath);
1134
+ if (stale) {
856
1135
  try {
857
- (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);
858
1138
  } catch {}
859
1139
  continue;
860
1140
  }
861
1141
  if (Date.now() >= deadline) throw new Error(`Store.update: timed out waiting for ${lockPath} (held by another writer or stale lock)`);
862
- const sleepUntil = Date.now() + LOCK_RETRY_INTERVAL_MS;
863
- while (Date.now() < sleepUntil);
1142
+ sleepSync(LOCK_RETRY_INTERVAL_MS);
864
1143
  }
865
1144
  const held = fd;
866
1145
  return () => {
@@ -868,16 +1147,56 @@ var Store = class {
868
1147
  (0, node_fs.closeSync)(held);
869
1148
  } catch {}
870
1149
  try {
871
- (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);
872
1152
  } catch {}
873
1153
  };
874
1154
  }
875
- lockIsStale(lockPath) {
1155
+ inspectStaleLock(lockPath) {
1156
+ let fd = -1;
1157
+ let modifiedAt;
1158
+ let device;
1159
+ let inode;
876
1160
  try {
877
- const st = (0, node_fs.statSync)(lockPath);
878
- 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;
879
1184
  } catch {
880
- 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);
881
1200
  }
882
1201
  }
883
1202
  /**
@@ -901,7 +1220,10 @@ var Store = class {
901
1220
  }
902
1221
  ensureWatcher() {
903
1222
  if (this.watcher) return;
904
- (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
+ });
905
1227
  const dir = (0, node_path.dirname)(this.storePath);
906
1228
  const basename = this.storePath.slice(dir.length + 1);
907
1229
  let pending;
@@ -950,43 +1272,60 @@ var Store = class {
950
1272
  function defaultStorePath() {
951
1273
  return DEFAULT_STORE_PATH;
952
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
+ }
953
1287
  /** Pull the runtime config (transport + transport-specific fields) out of a stored entry. */
954
1288
  function toServerConfig(entry) {
955
1289
  if (entry.transport === "stdio") {
956
- const { command, args, env, cwd } = entry;
1290
+ const { command, args, env, cwd, connectionTimeoutMs } = entry;
957
1291
  const cfg = { command };
958
- if (args) cfg.args = args;
959
- if (env) cfg.env = env;
1292
+ if (args) cfg.args = [...args];
1293
+ if (env) cfg.env = { ...env };
960
1294
  if (cwd) cfg.cwd = cwd;
961
- return cfg;
1295
+ if (connectionTimeoutMs !== void 0) cfg.connectionTimeoutMs = connectionTimeoutMs;
1296
+ return validateServerConfig(cfg);
962
1297
  }
963
1298
  const { url, transport, headers, connectionTimeoutMs } = entry;
964
1299
  const cfg = {
965
1300
  url,
966
1301
  transport
967
1302
  };
968
- if (headers) cfg.headers = headers;
1303
+ if (headers) cfg.headers = { ...headers };
969
1304
  if (connectionTimeoutMs !== void 0) cfg.connectionTimeoutMs = connectionTimeoutMs;
970
- return cfg;
1305
+ return validateServerConfig(cfg);
971
1306
  }
972
1307
  /** Build a stored entry from a runtime config + ownership metadata. */
973
1308
  function toStoredEntry(config, meta) {
1309
+ validateOwner(meta.owner);
1310
+ const checked = validateServerConfig(config);
974
1311
  const addedAt = meta.addedAt ?? (/* @__PURE__ */ new Date()).toISOString();
975
- if ("command" in config) return {
1312
+ validateTimestamp(addedAt, "addedAt");
1313
+ if (meta.version !== void 0) validateVersion(meta.version);
1314
+ if ("command" in checked) return {
976
1315
  transport: "stdio",
977
1316
  owner: meta.owner,
978
1317
  addedAt,
979
1318
  ...meta.version !== void 0 ? { version: meta.version } : {},
980
- ...config
1319
+ ...checked
981
1320
  };
982
- const transport = meta.transport ?? config.transport ?? "sse";
1321
+ const transport = meta.transport ?? checked.transport ?? "sse";
983
1322
  if (transport === "stdio") throw new Error("toStoredEntry: transport=stdio specified but config is remote-shaped");
984
1323
  return {
985
- transport,
986
1324
  owner: meta.owner,
987
1325
  addedAt,
988
1326
  ...meta.version !== void 0 ? { version: meta.version } : {},
989
- ...config
1327
+ ...checked,
1328
+ transport
990
1329
  };
991
1330
  }
992
1331
  function cloneEmpty() {
@@ -997,17 +1336,90 @@ function cloneEmpty() {
997
1336
  };
998
1337
  }
999
1338
  function normalize(raw) {
1000
- 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");
1001
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);
1002
1380
  return {
1003
- servers: r.servers && typeof r.servers === "object" ? r.servers : {},
1004
- config: r.config && typeof r.config === "object" ? r.config : {},
1005
- _ownedOpenclawKeys: Array.isArray(r._ownedOpenclawKeys) ? r._ownedOpenclawKeys.slice() : []
1381
+ servers,
1382
+ config,
1383
+ _ownedOpenclawKeys: owned
1006
1384
  };
1007
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
+ }
1008
1412
  function errMsg$1(err) {
1009
1413
  return err instanceof Error ? err.message : String(err);
1010
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
+ }
1011
1423
  //#endregion
1012
1424
  //#region src/manager.ts
1013
1425
  /**
@@ -1027,11 +1439,13 @@ function errMsg$1(err) {
1027
1439
  */
1028
1440
  var Manager = class {
1029
1441
  store;
1442
+ ownsStore;
1030
1443
  logger;
1031
1444
  bundler;
1032
1445
  changeListeners = /* @__PURE__ */ new Set();
1033
1446
  storeUnsubscribe;
1034
1447
  constructor(opts = {}) {
1448
+ this.ownsStore = opts.store === void 0;
1035
1449
  this.store = opts.store ?? new Store({ logger: opts.logger });
1036
1450
  this.logger = opts.logger;
1037
1451
  }
@@ -1042,14 +1456,14 @@ var Manager = class {
1042
1456
  /**
1043
1457
  * Register or overwrite a server entry. Mutation lands in the store
1044
1458
  * synchronously; if a bundler has been attached via `loadIntoBundler`,
1045
- * it is re-reconciled BEFORE `onChange` listeners fire (errors logged,
1046
- * never thrown the store is the source of truth, the bundler is
1047
- * derived). The ordering is load-bearing: the daemon's `onChange`
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`
1048
1462
  * handler warms the bundler's current connections, so the just-added
1049
1463
  * server must already have its Connection object or it is never warmed.
1050
1464
  */
1051
1465
  async addServer(config, opts) {
1052
- if (!opts.id) throw new Error("Manager.addServer: id is required");
1466
+ validateServerId(opts.id, "Manager.addServer id");
1053
1467
  const owner = opts.owner ?? "manual";
1054
1468
  this.store.update((cur) => {
1055
1469
  const previousAddedAt = lookupAddedAt(cur.servers, opts.id);
@@ -1067,8 +1481,10 @@ var Manager = class {
1067
1481
  }
1068
1482
  };
1069
1483
  });
1070
- await this.reconcileBundlerLogged();
1071
- this.fireChange();
1484
+ if (opts.strictReconcile) {
1485
+ await this.reconcileBundler();
1486
+ this.fireChange();
1487
+ } else if (await this.reconcileBundlerLogged()) this.fireChange();
1072
1488
  }
1073
1489
  /**
1074
1490
  * Remove a single server entry. No-op if the id isn't in the store.
@@ -1077,16 +1493,24 @@ var Manager = class {
1077
1493
  * accidentally clobbering integration- or cli-owned entries.
1078
1494
  */
1079
1495
  async removeServer(id, opts = {}) {
1080
- const existing = lookupEntry(this.store.read().servers, id);
1081
- if (!existing) return false;
1082
- if (opts.expectedOwner && existing.owner !== opts.expectedOwner) throw new Error(`Manager.removeServer: server "${id}" is owned by "${existing.owner}", not "${opts.expectedOwner}"`);
1083
- this.store.update((cur) => ({
1084
- ...cur,
1085
- servers: Object.fromEntries(Object.entries(cur.servers).filter(([k]) => k !== id))
1086
- }));
1087
- await this.reconcileBundlerLogged();
1088
- this.fireChange();
1089
- return true;
1496
+ validateServerId(id, "Manager.removeServer id");
1497
+ const outcome = { removed: false };
1498
+ this.store.update((cur) => {
1499
+ const existing = lookupEntry(cur.servers, id);
1500
+ if (!existing) return cur;
1501
+ if (opts.expectedOwner && existing.owner !== opts.expectedOwner) throw new Error(`Manager.removeServer: server "${id}" is owned by "${existing.owner}", not "${opts.expectedOwner}"`);
1502
+ outcome.removed = true;
1503
+ return {
1504
+ ...cur,
1505
+ servers: Object.fromEntries(Object.entries(cur.servers).filter(([key]) => key !== id))
1506
+ };
1507
+ });
1508
+ if (!outcome.removed && !opts.strictReconcile) return false;
1509
+ if (opts.strictReconcile) {
1510
+ await this.reconcileBundler();
1511
+ this.fireChange();
1512
+ } else if (await this.reconcileBundlerLogged()) this.fireChange();
1513
+ return outcome.removed;
1090
1514
  }
1091
1515
  /** Drop every entry whose owner matches — used by integration uninstall. */
1092
1516
  async removeServersByOwner(owner) {
@@ -1102,8 +1526,7 @@ var Manager = class {
1102
1526
  };
1103
1527
  });
1104
1528
  if (removed.length > 0) {
1105
- await this.reconcileBundlerLogged();
1106
- this.fireChange();
1529
+ if (await this.reconcileBundlerLogged()) this.fireChange();
1107
1530
  }
1108
1531
  return removed;
1109
1532
  }
@@ -1136,8 +1559,14 @@ var Manager = class {
1136
1559
  */
1137
1560
  async warmServer(id, timeoutMs) {
1138
1561
  if (!this.bundler) return null;
1139
- await this.reconcileBundler();
1140
- return await this.bundler.warmServer(id, timeoutMs) ?? null;
1562
+ try {
1563
+ validateServerId(id, "Manager.warmServer id");
1564
+ await this.reconcileBundler();
1565
+ return await this.bundler.warmServer(id, timeoutMs) ?? null;
1566
+ } catch (err) {
1567
+ this.logger?.warn("[mcp-bundler/manager] warm reconcile failed", { err: errMsg(err) });
1568
+ return null;
1569
+ }
1141
1570
  }
1142
1571
  /**
1143
1572
  * Push the current store contents into a bundler instance (which owns
@@ -1162,31 +1591,33 @@ var Manager = class {
1162
1591
  }
1163
1592
  /**
1164
1593
  * Detach from the bundler and stop watching the store. Safe to call
1165
- * multiple times. Does not dispose the underlying `Store` so the
1166
- * shared instance survives multi-manager environments (rare).
1594
+ * multiple times. An injected Store remains owned by its caller; a Store
1595
+ * constructed by this manager is disposed here.
1167
1596
  */
1168
1597
  async dispose() {
1169
1598
  if (this.storeUnsubscribe) {
1170
1599
  this.storeUnsubscribe();
1171
1600
  this.storeUnsubscribe = void 0;
1172
1601
  }
1173
- this.store.dispose();
1602
+ if (this.ownsStore) this.store.dispose();
1174
1603
  this.changeListeners.clear();
1175
1604
  this.bundler = void 0;
1176
1605
  return Promise.resolve();
1177
1606
  }
1178
1607
  /**
1179
1608
  * Awaited by every mutator so `onChange` listeners observe a bundler
1180
- * that already contains the mutation. Reconcile failures are logged,
1181
- * never thrown a mutation must not fail because the derived bundler
1182
- * hiccuped.
1609
+ * that already contains the mutation. Reconcile failures are logged and
1610
+ * return false so callers preserve the durable mutation without notifying
1611
+ * listeners against stale derived state.
1183
1612
  */
1184
1613
  async reconcileBundlerLogged() {
1185
- if (!this.bundler) return;
1614
+ if (!this.bundler) return true;
1186
1615
  try {
1187
1616
  await this.reconcileBundler();
1617
+ return true;
1188
1618
  } catch (err) {
1189
1619
  this.logger?.warn("[mcp-bundler/manager] bundler reconcile failed", { err: errMsg(err) });
1620
+ return false;
1190
1621
  }
1191
1622
  }
1192
1623
  async reconcileBundler() {
@@ -1234,15 +1665,17 @@ function lookupAddedAt(servers, id) {
1234
1665
  */
1235
1666
  function checkPatternA(tools, options) {
1236
1667
  const selectorNames = typeof options.selector === "string" ? [options.selector] : options.selector;
1668
+ 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");
1237
1669
  const exempt = new Set(options.exempt ?? []);
1238
1670
  const violations = [];
1239
1671
  for (const tool of tools) {
1240
1672
  if (exempt.has(tool.name)) continue;
1241
- const schema = tool.parameters;
1242
- const properties = schema.properties ?? {};
1243
- const required = schema.required ?? [];
1244
- const matched = selectorNames.find((name) => name in properties);
1245
- if (!matched) {
1673
+ const schema = isRecord(tool.parameters) ? tool.parameters : {};
1674
+ const rawProperties = schema.properties;
1675
+ const properties = isRecord(rawProperties) ? rawProperties : {};
1676
+ const required = Array.isArray(schema.required) ? schema.required.filter((value) => typeof value === "string") : [];
1677
+ const present = selectorNames.filter((name) => Object.hasOwn(properties, name));
1678
+ if (present.length === 0) {
1246
1679
  violations.push({
1247
1680
  tool: tool.name,
1248
1681
  reason: "missing-selector-property",
@@ -1250,20 +1683,29 @@ function checkPatternA(tools, options) {
1250
1683
  });
1251
1684
  continue;
1252
1685
  }
1253
- if (!required.includes(matched)) {
1686
+ const requiredSelectors = present.filter((name) => required.includes(name));
1687
+ if (requiredSelectors.length === 0) {
1254
1688
  violations.push({
1255
1689
  tool: tool.name,
1256
1690
  reason: "selector-not-required",
1257
- detail: `selector "${matched}" present in properties but missing from inputSchema.required`
1691
+ detail: `selector [${present.join(", ")}] present in properties but missing from inputSchema.required`
1258
1692
  });
1259
1693
  continue;
1260
1694
  }
1261
- const type = properties[matched].type;
1262
- if (type !== "string") violations.push({
1263
- tool: tool.name,
1264
- reason: "selector-property-not-string",
1265
- detail: `selector "${matched}" must be JSON Schema type=string (found ${JSON.stringify(type)})`
1266
- });
1695
+ if (!requiredSelectors.find((name) => {
1696
+ const property = properties[name];
1697
+ return isRecord(property) && property.type === "string";
1698
+ })) {
1699
+ const foundTypes = requiredSelectors.map((name) => {
1700
+ const property = properties[name];
1701
+ return `${name}=${JSON.stringify(isRecord(property) ? property.type : void 0)}`;
1702
+ });
1703
+ violations.push({
1704
+ tool: tool.name,
1705
+ reason: "selector-property-not-string",
1706
+ detail: `at least one required selector must be JSON Schema type=string (found ${foundTypes.join(", ")})`
1707
+ });
1708
+ }
1267
1709
  }
1268
1710
  return violations;
1269
1711
  }
@@ -1274,7 +1716,7 @@ function checkPatternA(tools, options) {
1274
1716
  function assertPatternA(tools, options) {
1275
1717
  const violations = checkPatternA(tools, options);
1276
1718
  if (violations.length === 0) return;
1277
- const lines = violations.map((v) => ` - [${v.reason}] ${v.tool}: ${v.detail}`);
1719
+ const lines = violations.map((violation) => ` - [${violation.reason}] ${violation.tool}: ${violation.detail}`);
1278
1720
  throw new Error(`Pattern A validation failed for ${String(violations.length)} tool(s):\n${lines.join("\n")}`);
1279
1721
  }
1280
1722
  /**
@@ -1289,6 +1731,9 @@ function fromMcpDescriptor(descriptor) {
1289
1731
  parameters: descriptor.parameters
1290
1732
  };
1291
1733
  }
1734
+ function isRecord(value) {
1735
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1736
+ }
1292
1737
  //#endregion
1293
1738
  exports.Connection = Connection;
1294
1739
  exports.Manager = Manager;
@@ -1304,5 +1749,6 @@ exports.disambiguateAgainst = disambiguateAgainst;
1304
1749
  exports.fromMcpDescriptor = fromMcpDescriptor;
1305
1750
  exports.sanitizeNameSegment = sanitizeNameSegment;
1306
1751
  exports.sanitizeStdioEnv = sanitizeStdioEnv;
1752
+ exports.serverLaunchFingerprint = serverLaunchFingerprint;
1307
1753
  exports.toServerConfig = toServerConfig;
1308
1754
  exports.toStoredEntry = toStoredEntry;