@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.js CHANGED
@@ -1,5 +1,6 @@
1
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { dirname, isAbsolute, join } from "node:path";
2
+ import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, watch, writeFileSync } from "node:fs";
3
+ import { createHash, randomUUID } from "node:crypto";
3
4
  import { homedir } from "node:os";
4
5
  //#region src/tool-naming.ts
5
6
  /**
@@ -19,11 +20,15 @@ function sanitizeNameSegment(value) {
19
20
  return value.replace(DISALLOWED, "_");
20
21
  }
21
22
  function buildNamespacedToolName(server, tool) {
22
- const base = `${sanitizeNameSegment(server)}${SEPARATOR}${sanitizeNameSegment(tool)}`;
23
+ const sanitizedServer = sanitizeNameSegment(server);
24
+ const sanitizedTool = sanitizeNameSegment(tool);
25
+ const serverBudget = MAX_LEN - 2 - 1;
26
+ const boundedServer = sanitizedServer.slice(0, serverBudget);
27
+ const base = `${boundedServer}${SEPARATOR}${sanitizedTool}`;
23
28
  if (base.length <= MAX_LEN) return base;
24
- const reservedForServer = sanitizeNameSegment(server).length + 2;
29
+ const reservedForServer = boundedServer.length + 2;
25
30
  const toolBudget = Math.max(1, MAX_LEN - reservedForServer);
26
- return `${sanitizeNameSegment(server)}${SEPARATOR}${sanitizeNameSegment(tool).slice(0, toolBudget)}`;
31
+ return `${boundedServer}${SEPARATOR}${sanitizedTool.slice(0, toolBudget)}`;
27
32
  }
28
33
  /**
29
34
  * Disambiguate a candidate name against an existing set by appending `-2`, `-3`, etc.
@@ -32,16 +37,231 @@ function buildNamespacedToolName(server, tool) {
32
37
  */
33
38
  function disambiguateAgainst(candidate, taken) {
34
39
  if (!taken.has(candidate)) return candidate;
35
- for (let i = 2; i < 1e3; i += 1) {
40
+ for (let i = 2; i <= taken.size + 2; i += 1) {
36
41
  const suffix = `-${i.toString()}`;
37
42
  const room = MAX_LEN - suffix.length;
38
43
  const next = `${candidate.length > room ? candidate.slice(0, room) : candidate}${suffix}`;
39
44
  if (!taken.has(next)) return next;
40
45
  }
41
- return `${candidate.slice(0, MAX_LEN - 6)}-x${(taken.size % 1e3).toString().padStart(3, "0")}`;
46
+ throw new Error("unable to disambiguate MCP tool name");
47
+ }
48
+ //#endregion
49
+ //#region src/validation.ts
50
+ const MAX_SERVERS = 256;
51
+ const MAX_TOOLS_PER_SERVER = 512;
52
+ const MAX_SCHEMA_BYTES = 512 * 1024;
53
+ const MAX_ARGUMENT_BYTES = 2 * 1024 * 1024;
54
+ const MAX_RESULT_BYTES = 5 * 1024 * 1024;
55
+ const MAX_JSON_DEPTH = 64;
56
+ const MAX_JSON_NODES = 5e4;
57
+ const MAX_DESCRIPTION_LENGTH = 2e4;
58
+ const SERVER_ID = /^[A-Za-z0-9][A-Za-z0-9._:@#-]{0,127}$/;
59
+ const TOOL_NAME = /^[A-Za-z0-9_.:-]{1,128}$/;
60
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
61
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
62
+ const UNSAFE_KEYS = new Set([
63
+ "__proto__",
64
+ "prototype",
65
+ "constructor"
66
+ ]);
67
+ function validateServerId(id, label = "server id") {
68
+ if (typeof id !== "string" || !SERVER_ID.test(id) || UNSAFE_KEYS.has(id)) throw new Error(`${label} must match ${SERVER_ID.source}`);
69
+ return id;
70
+ }
71
+ function validateServerCount(count) {
72
+ if (count > MAX_SERVERS) throw new Error(`MCP store exceeds the ${MAX_SERVERS.toString()} server limit`);
73
+ }
74
+ function validateServerConfig(value, label = "server config") {
75
+ const config = requireRecord(value, label);
76
+ const hasCommand = Object.hasOwn(config, "command");
77
+ if (hasCommand === Object.hasOwn(config, "url")) throw new Error(`${label} must contain exactly one of command or url`);
78
+ return hasCommand ? validateStdioConfig(config, label) : validateRemoteConfig(config, label);
79
+ }
80
+ function normalizeChildCatalog(server, advertised) {
81
+ validateServerId(server);
82
+ if (!Array.isArray(advertised)) throw new Error(`server "${server}" returned a non-array tool catalog`);
83
+ if (advertised.length > MAX_TOOLS_PER_SERVER) throw new Error(`server "${server}" advertised ${advertised.length.toString()} tools; limit is ${MAX_TOOLS_PER_SERVER.toString()}`);
84
+ const originals = /* @__PURE__ */ new Set();
85
+ return advertised.map((raw, index) => {
86
+ const tool = requireRecord(raw, `server "${server}" tool ${index.toString()}`);
87
+ const name = tool.name;
88
+ if (typeof name !== "string" || !TOOL_NAME.test(name)) throw new Error(`server "${server}" advertised an invalid tool name at index ${index.toString()}`);
89
+ if (originals.has(name)) throw new Error(`server "${server}" advertised duplicate tool "${name}"`);
90
+ originals.add(name);
91
+ const description = tool.description === void 0 ? "" : requireBoundedString(tool.description, `server "${server}" tool "${name}" description`, MAX_DESCRIPTION_LENGTH);
92
+ const parameters = cloneBoundedJson(tool.inputSchema, `server "${server}" tool "${name}" input schema`, MAX_SCHEMA_BYTES);
93
+ if (!isRecord$1(parameters)) throw new Error(`server "${server}" tool "${name}" input schema must be an object`);
94
+ return {
95
+ prefixed: buildNamespacedToolName(server, name),
96
+ server,
97
+ original: name,
98
+ label: (description || name).slice(0, 80),
99
+ description,
100
+ parameters
101
+ };
102
+ });
103
+ }
104
+ function validateToolArguments(args) {
105
+ if (args === void 0) return void 0;
106
+ const cloned = cloneBoundedJson(args, "MCP tool arguments", MAX_ARGUMENT_BYTES);
107
+ if (!isRecord$1(cloned)) throw new Error("MCP tool arguments must be an object");
108
+ return cloned;
109
+ }
110
+ function normalizeToolResult(result) {
111
+ const cloned = cloneBoundedJson(result, "MCP tool result", MAX_RESULT_BYTES);
112
+ if (!isRecord$1(cloned) || !Array.isArray(cloned.content)) throw new Error("MCP tool result must be an object with a content array");
113
+ for (const [index, item] of cloned.content.entries()) if (!isRecord$1(item)) throw new Error(`MCP tool result content[${index.toString()}] must be an object`);
114
+ if (cloned.isError !== void 0 && typeof cloned.isError !== "boolean") throw new Error("MCP tool result isError must be a boolean when present");
115
+ if (cloned.structuredContent !== void 0 && !isRecord$1(cloned.structuredContent)) throw new Error("MCP tool result structuredContent must be an object when present");
116
+ return cloned;
117
+ }
118
+ function redactErrorResult(result, config) {
119
+ if (!result.isError) return result;
120
+ return {
121
+ ...result,
122
+ content: result.content.map((item) => item.type === "text" && typeof item.text === "string" ? {
123
+ ...item,
124
+ text: redactSensitiveText(item.text, config)
125
+ } : item)
126
+ };
127
+ }
128
+ function cloneToolDescriptor(tool) {
129
+ return {
130
+ ...tool,
131
+ parameters: cloneBoundedJson(tool.parameters, `tool "${tool.prefixed}" schema`, MAX_SCHEMA_BYTES)
132
+ };
133
+ }
134
+ function redactSensitiveText(text, config) {
135
+ let output = String(text).slice(0, MAX_DESCRIPTION_LENGTH).replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]").replace(/\b(api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password)\s*[=:]\s*[^\s,;]+/gi, "$1=[REDACTED]").replace(/\balfe_[A-Za-z0-9_-]{8,}/g, "[REDACTED]");
136
+ for (const secret of configSecrets(config)) {
137
+ if (secret.length < 4) continue;
138
+ output = output.split(secret).join("[REDACTED]");
139
+ }
140
+ return output;
141
+ }
142
+ function validateStdioConfig(config, label) {
143
+ const command = requireBoundedString(config.command, `${label}.command`, 4096);
144
+ rejectControlCharacters(command, `${label}.command`);
145
+ const result = { command };
146
+ if (config.args !== void 0) {
147
+ if (!Array.isArray(config.args) || config.args.length > 256) throw new Error(`${label}.args must be an array with at most 256 entries`);
148
+ result.args = config.args.map((arg, index) => {
149
+ const checked = requireBoundedString(arg, `${label}.args[${index.toString()}]`, 32 * 1024);
150
+ rejectNul(checked, `${label}.args[${index.toString()}]`);
151
+ return checked;
152
+ });
153
+ }
154
+ if (config.env !== void 0) result.env = validateStringMap(config.env, `${label}.env`, ENV_NAME, 256, 64 * 1024);
155
+ if (config.cwd !== void 0) {
156
+ const cwd = requireBoundedString(config.cwd, `${label}.cwd`, 4096);
157
+ rejectNul(cwd, `${label}.cwd`);
158
+ if (!isAbsolute(cwd)) throw new Error(`${label}.cwd must be an absolute path`);
159
+ result.cwd = cwd;
160
+ }
161
+ if (config.connectionTimeoutMs !== void 0) result.connectionTimeoutMs = validateTimeout(config.connectionTimeoutMs, `${label}.connectionTimeoutMs`);
162
+ return result;
163
+ }
164
+ function validateRemoteConfig(config, label) {
165
+ const rawUrl = requireBoundedString(config.url, `${label}.url`, 4096);
166
+ let parsed;
167
+ try {
168
+ parsed = new URL(rawUrl);
169
+ } catch {
170
+ throw new Error(`${label}.url must be an absolute URL`);
171
+ }
172
+ if (!["http:", "https:"].includes(parsed.protocol)) throw new Error(`${label}.url must use http or https`);
173
+ if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) throw new Error(`${label}.url must use https unless it targets loopback`);
174
+ if (parsed.username || parsed.password) throw new Error(`${label}.url must not contain credentials`);
175
+ if (parsed.hash) throw new Error(`${label}.url must not contain a fragment`);
176
+ const result = { url: parsed.toString() };
177
+ if (config.transport !== void 0) {
178
+ if (config.transport !== "sse" && config.transport !== "streamable-http") throw new Error(`${label}.transport must be sse or streamable-http`);
179
+ result.transport = config.transport;
180
+ }
181
+ if (config.headers !== void 0) result.headers = validateStringMap(config.headers, `${label}.headers`, HEADER_NAME, 64, 32 * 1024, true);
182
+ if (config.connectionTimeoutMs !== void 0) result.connectionTimeoutMs = validateTimeout(config.connectionTimeoutMs, `${label}.connectionTimeoutMs`);
183
+ return result;
184
+ }
185
+ function validateStringMap(value, label, keyPattern, maxEntries, maxValueLength, rejectControls = false) {
186
+ const record = requireRecord(value, label);
187
+ const entries = Object.entries(record);
188
+ if (entries.length > maxEntries) throw new Error(`${label} has too many entries`);
189
+ const result = {};
190
+ for (const [key, raw] of entries) {
191
+ if (!keyPattern.test(key) || UNSAFE_KEYS.has(key)) throw new Error(`${label} contains invalid key "${key}"`);
192
+ const checked = requireBoundedString(raw, `${label}.${key}`, maxValueLength);
193
+ rejectNul(checked, `${label}.${key}`);
194
+ if (rejectControls) rejectControlCharacters(checked, `${label}.${key}`);
195
+ result[key] = checked;
196
+ }
197
+ return result;
198
+ }
199
+ function validateTimeout(value, label) {
200
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 3e5) throw new Error(`${label} must be an integer from 0 to 300000`);
201
+ return value;
202
+ }
203
+ function cloneBoundedJson(value, label, maxBytes) {
204
+ let nodes = 0;
205
+ const seen = /* @__PURE__ */ new WeakSet();
206
+ const visit = (item, depth) => {
207
+ nodes += 1;
208
+ if (nodes > MAX_JSON_NODES) throw new Error(`${label} exceeds the JSON node limit`);
209
+ if (depth > MAX_JSON_DEPTH) throw new Error(`${label} exceeds the JSON depth limit`);
210
+ if (item === null || typeof item === "boolean" || typeof item === "number") {
211
+ if (typeof item === "number" && !Number.isFinite(item)) throw new Error(`${label} contains a non-finite number`);
212
+ return;
213
+ }
214
+ if (typeof item === "string") {
215
+ if (Buffer.byteLength(item, "utf8") > maxBytes) throw new Error(`${label} contains an oversized string`);
216
+ return;
217
+ }
218
+ if (typeof item !== "object") throw new Error(`${label} contains a non-JSON value`);
219
+ if (seen.has(item)) throw new Error(`${label} contains a cycle`);
220
+ seen.add(item);
221
+ if (!Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) throw new Error(`${label} contains a non-plain object`);
222
+ for (const [key, child] of Object.entries(item)) {
223
+ if (UNSAFE_KEYS.has(key)) throw new Error(`${label} contains unsafe key "${key}"`);
224
+ visit(child, depth + 1);
225
+ }
226
+ };
227
+ visit(value, 0);
228
+ const serialized = JSON.stringify(value);
229
+ if (Buffer.byteLength(serialized, "utf8") > maxBytes) throw new Error(`${label} exceeds the byte limit`);
230
+ return JSON.parse(serialized);
231
+ }
232
+ function requireRecord(value, label) {
233
+ if (!isRecord$1(value)) throw new Error(`${label} must be an object`);
234
+ for (const key of Object.keys(value)) if (UNSAFE_KEYS.has(key)) throw new Error(`${label} contains unsafe key "${key}"`);
235
+ return value;
236
+ }
237
+ function isRecord$1(value) {
238
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
239
+ const prototype = Object.getPrototypeOf(value);
240
+ return prototype === Object.prototype || prototype === null;
241
+ }
242
+ function requireBoundedString(value, label, maxLength) {
243
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength) throw new Error(`${label} must be a non-empty string of at most ${maxLength.toString()} characters`);
244
+ return value;
245
+ }
246
+ function rejectNul(value, label) {
247
+ if (value.includes("\0")) throw new Error(`${label} must not contain NUL`);
248
+ }
249
+ function rejectControlCharacters(value, label) {
250
+ for (const char of value) {
251
+ const code = char.charCodeAt(0);
252
+ if (code <= 31 || code === 127) throw new Error(`${label} must not contain control characters`);
253
+ }
254
+ }
255
+ function configSecrets(config) {
256
+ if (!config) return [];
257
+ return "command" in config ? Object.values(config.env ?? {}) : [...Object.values(config.headers ?? {}), new URL(config.url).password].filter(Boolean);
258
+ }
259
+ function isLoopbackHost(hostname) {
260
+ return hostname === "localhost" || hostname === "[::1]" || /^127(?:\.[0-9]{1,3}){3}$/.test(hostname);
42
261
  }
43
262
  //#endregion
44
263
  //#region src/connection.ts
264
+ const MAX_STDERR_LINE_LENGTH = 16 * 1024;
45
265
  /** Env keys OpenClaw rejects from stdio MCP env blocks. Filter them out before spawning. */
46
266
  const STDIO_ENV_DENYLIST = new Set([
47
267
  "NODE_OPTIONS",
@@ -93,8 +313,8 @@ var Connection = class Connection {
93
313
  /** Whether any connect has ever been attempted — drives the eager retry sweep. */
94
314
  connectAttempted = false;
95
315
  constructor(params) {
96
- this.name = params.name;
97
- this.config = params.config;
316
+ this.name = validateServerId(params.name);
317
+ this.config = validateServerConfig(params.config);
98
318
  this.deps = params.deps;
99
319
  this.logger = params.logger;
100
320
  this.onUnexpectedClose = params.onUnexpectedClose;
@@ -103,7 +323,7 @@ var Connection = class Connection {
103
323
  }
104
324
  /** Returns the most recent known tool list. May be empty if the server hasn't connected yet. */
105
325
  snapshotTools() {
106
- return this.tools;
326
+ return this.tools.map(cloneToolDescriptor);
107
327
  }
108
328
  /** Whether an MCP child process / remote connection has been established. */
109
329
  isConnected() {
@@ -176,14 +396,7 @@ var Connection = class Connection {
176
396
  client.onClose?.(() => {
177
397
  this.handleUnexpectedClose(client);
178
398
  });
179
- this.tools = advertised.map((t) => ({
180
- prefixed: buildNamespacedToolName(this.name, t.name),
181
- server: this.name,
182
- original: t.name,
183
- label: (t.description ?? t.name).slice(0, 80),
184
- description: t.description ?? "",
185
- parameters: t.inputSchema
186
- }));
399
+ this.tools = normalizeChildCatalog(this.name, advertised);
187
400
  this.lastUsedAt = Date.now();
188
401
  this.consecutiveFailures = 0;
189
402
  this.lastError = void 0;
@@ -192,7 +405,7 @@ var Connection = class Connection {
192
405
  } catch (err) {
193
406
  attempt.then(({ client }) => client.close()).catch(() => void 0);
194
407
  this.consecutiveFailures += 1;
195
- this.lastError = err instanceof Error ? err.message : String(err);
408
+ this.lastError = redactSensitiveText(err instanceof Error ? err.message : String(err), this.config);
196
409
  const backoff = Math.min(Connection.RECONNECT_BACKOFF_BASE_MS * 2 ** (this.consecutiveFailures - 1), Connection.RECONNECT_BACKOFF_MAX_MS);
197
410
  this.reconnectBlockedUntilMs = Date.now() + backoff;
198
411
  throw err;
@@ -225,6 +438,7 @@ var Connection = class Connection {
225
438
  this.logger?.warn(`[mcp-bundler] server "${this.name}" connection closed unexpectedly; will re-spawn on next use`);
226
439
  this.client = void 0;
227
440
  this.tools = [];
441
+ handle.close().catch(() => void 0);
228
442
  try {
229
443
  this.onUnexpectedClose?.();
230
444
  } catch {}
@@ -242,14 +456,10 @@ var Connection = class Connection {
242
456
  }
243
457
  this.refreshInFlight = true;
244
458
  try {
245
- this.tools = (await this.client.listTools()).map((t) => ({
246
- prefixed: buildNamespacedToolName(this.name, t.name),
247
- server: this.name,
248
- original: t.name,
249
- label: (t.description ?? t.name).slice(0, 80),
250
- description: t.description ?? "",
251
- parameters: t.inputSchema
252
- }));
459
+ const handle = this.client;
460
+ const advertised = await handle.listTools();
461
+ if (this.client !== handle) return;
462
+ this.tools = normalizeChildCatalog(this.name, advertised);
253
463
  this.logger?.debug(`[mcp-bundler] server "${this.name}" refreshed, ${this.tools.length.toString()} tool(s)`);
254
464
  } finally {
255
465
  this.refreshInFlight = false;
@@ -265,7 +475,8 @@ var Connection = class Connection {
265
475
  await this.ensureConnected();
266
476
  if (!this.client) throw new Error(`server "${this.name}" failed to connect`);
267
477
  this.lastUsedAt = Date.now();
268
- return this.client.callTool(originalName, args, signal ? { signal } : void 0);
478
+ const checkedArgs = validateToolArguments(args);
479
+ return normalizeToolResult(await this.client.callTool(originalName, checkedArgs, signal ? { signal } : void 0));
269
480
  }
270
481
  /**
271
482
  * Close the underlying transport. Idempotent. If a connect is in flight
@@ -314,16 +525,26 @@ async function defaultConnect(server, ctx) {
314
525
  });
315
526
  if (onStderrLine) {
316
527
  let carry = "";
528
+ const emitLine = (raw) => {
529
+ if (raw.trim() === "") return;
530
+ const line = raw.length > MAX_STDERR_LINE_LENGTH ? `${raw.slice(0, MAX_STDERR_LINE_LENGTH)}…[truncated]` : raw;
531
+ try {
532
+ onStderrLine(line);
533
+ } catch {}
534
+ };
317
535
  transport.stderr?.on("data", (chunk) => {
318
536
  const parts = (carry + chunk.toString()).split("\n");
319
537
  carry = parts.pop() ?? "";
320
- for (const line of parts) {
321
- if (line.trim() === "") continue;
322
- try {
323
- onStderrLine(line);
324
- } catch {}
538
+ for (const line of parts) emitLine(line);
539
+ if (carry.length > MAX_STDERR_LINE_LENGTH) {
540
+ emitLine(carry);
541
+ carry = "";
325
542
  }
326
543
  });
544
+ transport.stderr?.on("end", () => {
545
+ emitLine(carry);
546
+ carry = "";
547
+ });
327
548
  }
328
549
  await client.connect(transport);
329
550
  } else {
@@ -346,7 +567,7 @@ async function defaultConnect(server, ctx) {
346
567
  closeHandler?.();
347
568
  };
348
569
  client.onclose = fireClose;
349
- client.onerror = fireClose;
570
+ client.onerror = () => void 0;
350
571
  return {
351
572
  async listTools() {
352
573
  return (await client.listTools()).tools.map((t) => ({
@@ -409,10 +630,10 @@ var McpBundler = class {
409
630
  reconcileLatch = Promise.resolve();
410
631
  constructor(opts = {}, deps) {
411
632
  this.logger = opts.logger;
412
- this.idleTtlMs = opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
413
- this.idleSweepIntervalMs = opts.idleSweepIntervalMs ?? DEFAULT_IDLE_SWEEP_INTERVAL_MS;
414
- this.retrySweepIntervalMs = opts.retrySweepIntervalMs ?? DEFAULT_RETRY_SWEEP_INTERVAL_MS;
415
- this.connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
633
+ this.idleTtlMs = checkedDuration(opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS, "idleTtlMs");
634
+ this.idleSweepIntervalMs = checkedDuration(opts.idleSweepIntervalMs ?? DEFAULT_IDLE_SWEEP_INTERVAL_MS, "idleSweepIntervalMs");
635
+ this.retrySweepIntervalMs = checkedDuration(opts.retrySweepIntervalMs ?? DEFAULT_RETRY_SWEEP_INTERVAL_MS, "retrySweepIntervalMs");
636
+ this.connectTimeoutMs = checkedDuration(opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, "connectTimeoutMs", 3e5);
416
637
  this.retryNeverConnected = opts.retryNeverConnected ?? false;
417
638
  this.deps = deps ?? { connect: defaultConnect };
418
639
  this.onToolError = opts.onToolError;
@@ -435,7 +656,7 @@ var McpBundler = class {
435
656
  crash(name);
436
657
  } } : {},
437
658
  ...stderr ? { onStderrLine: (line) => {
438
- stderr(name, line);
659
+ stderr(name, redactSensitiveText(line, config));
439
660
  } } : {}
440
661
  });
441
662
  }
@@ -462,7 +683,11 @@ var McpBundler = class {
462
683
  }
463
684
  async doReconcile(desired) {
464
685
  if (this.disposed) throw new Error("McpBundler: disposed");
465
- const desiredNames = new Set(Object.keys(desired));
686
+ const desiredEntries = Object.entries(desired);
687
+ validateServerCount(desiredEntries.length);
688
+ const checkedDesired = {};
689
+ for (const [name, config] of desiredEntries) checkedDesired[validateServerId(name)] = validateServerConfig(config, `server "${name}" config`);
690
+ const desiredNames = new Set(Object.keys(checkedDesired));
466
691
  const currentNames = new Set(this.connections.keys());
467
692
  const added = [];
468
693
  const removed = [];
@@ -474,7 +699,7 @@ var McpBundler = class {
474
699
  if (conn) await conn.close();
475
700
  removed.push(name);
476
701
  }
477
- for (const [name, config] of Object.entries(desired)) {
702
+ for (const [name, config] of Object.entries(checkedDesired)) {
478
703
  const existing = this.connections.get(name);
479
704
  if (!existing) {
480
705
  this.connections.set(name, this.buildConnection(name, config));
@@ -510,17 +735,7 @@ var McpBundler = class {
510
735
  * so cross-server name clashes never produce duplicate registrations.
511
736
  */
512
737
  listTools() {
513
- const seen = /* @__PURE__ */ new Set();
514
- const out = [];
515
- for (const conn of this.connections.values()) for (const tool of conn.snapshotTools()) {
516
- const finalName = disambiguateAgainst(tool.prefixed, seen);
517
- seen.add(finalName);
518
- out.push(finalName === tool.prefixed ? tool : {
519
- ...tool,
520
- prefixed: finalName
521
- });
522
- }
523
- return out;
738
+ return this.resolvedCatalog().map(({ descriptor }) => cloneToolDescriptor(descriptor));
524
739
  }
525
740
  /**
526
741
  * Eagerly connect to every configured server and discover tools. Used by
@@ -602,10 +817,11 @@ var McpBundler = class {
602
817
  if (this.disposed) return void 0;
603
818
  const conn = this.connections.get(name);
604
819
  if (!conn) return void 0;
820
+ const checkedTimeout = timeoutMs === void 0 ? void 0 : checkedDuration(timeoutMs, "warmServer timeoutMs", 3e5);
605
821
  const connect = conn.ensureConnected();
606
822
  connect.catch(() => void 0);
607
823
  try {
608
- if (timeoutMs !== void 0 && timeoutMs > 0) await this.raceTimeout(connect, timeoutMs, name);
824
+ if (checkedTimeout !== void 0 && checkedTimeout > 0) await this.raceTimeout(connect, checkedTimeout, name);
609
825
  else await connect;
610
826
  } catch (err) {
611
827
  const status = this.statusOf(conn);
@@ -647,7 +863,7 @@ var McpBundler = class {
647
863
  isError: true,
648
864
  content: [{
649
865
  type: "text",
650
- text: `unknown tool: ${prefixed}`
866
+ text: `unknown MCP tool: ${prefixed.slice(0, 128)}`
651
867
  }]
652
868
  };
653
869
  try {
@@ -657,11 +873,11 @@ var McpBundler = class {
657
873
  tool: route.original,
658
874
  prefixed,
659
875
  kind: "result-error",
660
- message: extractErrorText(result)
876
+ message: redactSensitiveText(extractErrorText(result), route.connection.config)
661
877
  });
662
- return result;
878
+ return redactErrorResult(result, route.connection.config);
663
879
  } catch (err) {
664
- const msg = err instanceof Error ? err.message : String(err);
880
+ const msg = redactSensitiveText(err instanceof Error ? err.message : String(err), route.connection.config);
665
881
  this.logger?.error(`[mcp-bundler] tool call failed for "${prefixed}"`, { err: msg });
666
882
  this.reportToolError({
667
883
  server: route.connection.name,
@@ -684,10 +900,32 @@ var McpBundler = class {
684
900
  * tool name. Returns undefined if the tool is not currently advertised.
685
901
  */
686
902
  routeToolName(prefixed) {
687
- for (const conn of this.connections.values()) for (const tool of conn.snapshotTools()) if (tool.prefixed === prefixed) return {
688
- connection: conn,
689
- original: tool.original
690
- };
903
+ const match = this.resolvedCatalog().find(({ descriptor }) => descriptor.prefixed === prefixed);
904
+ return match ? {
905
+ connection: match.connection,
906
+ original: match.descriptor.original
907
+ } : void 0;
908
+ }
909
+ /**
910
+ * Build one collision-resolved catalog used by BOTH registration and
911
+ * routing. Keeping these views together prevents an advertised `-2` name
912
+ * from becoming uncallable when two child names sanitize to the same key.
913
+ */
914
+ resolvedCatalog() {
915
+ const seen = /* @__PURE__ */ new Set();
916
+ const resolved = [];
917
+ for (const conn of this.connections.values()) for (const tool of conn.snapshotTools()) {
918
+ const finalName = disambiguateAgainst(tool.prefixed, seen);
919
+ seen.add(finalName);
920
+ resolved.push({
921
+ connection: conn,
922
+ descriptor: finalName === tool.prefixed ? tool : {
923
+ ...tool,
924
+ prefixed: finalName
925
+ }
926
+ });
927
+ }
928
+ return resolved;
691
929
  }
692
930
  /**
693
931
  * Tear down all connections and stop background tasks. Idempotent.
@@ -746,6 +984,10 @@ var McpBundler = class {
746
984
  await Promise.allSettled(targets.map((c) => c.close()));
747
985
  }
748
986
  };
987
+ function checkedDuration(value, label, max = 864e5) {
988
+ if (!Number.isSafeInteger(value) || value < 0 || value > max) throw new Error(`McpBundler ${label} must be an integer from 0 to ${max.toString()}`);
989
+ return value;
990
+ }
749
991
  //#endregion
750
992
  //#region src/store.ts
751
993
  const DEFAULT_STORE_PATH = join(join(homedir(), ".alfe", "mcp"), "servers.json");
@@ -753,6 +995,7 @@ const DEFAULT_STORE_PATH = join(join(homedir(), ".alfe", "mcp"), "servers.json")
753
995
  const LOCK_WAIT_MS = 5e3;
754
996
  const LOCK_RETRY_INTERVAL_MS = 25;
755
997
  const LOCK_STALE_MS = 1e4;
998
+ const MAX_STORE_BYTES = 1024 * 1024;
756
999
  /**
757
1000
  * On-disk source of truth for the bundler's configured servers.
758
1001
  *
@@ -771,22 +1014,36 @@ var Store = class {
771
1014
  rewatchTimer;
772
1015
  constructor(opts = {}) {
773
1016
  this.storePath = opts.path ?? DEFAULT_STORE_PATH;
1017
+ if (!isAbsolute(this.storePath)) throw new Error("Store path must be absolute");
774
1018
  this.logger = opts.logger;
775
1019
  }
776
1020
  get path() {
777
1021
  return this.storePath;
778
1022
  }
779
1023
  read() {
780
- if (!existsSync(this.storePath)) return cloneEmpty();
1024
+ let fd = -1;
781
1025
  try {
782
- const raw = readFileSync(this.storePath, "utf8");
1026
+ try {
1027
+ fd = openSync(this.storePath, constants.O_RDONLY | constants.O_NOFOLLOW);
1028
+ } catch (err) {
1029
+ if (err.code === "ENOENT") return cloneEmpty();
1030
+ throw err;
1031
+ }
1032
+ const stat = fstatSync(fd);
1033
+ if (!stat.isFile()) throw new Error("store path is not a regular file");
1034
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error("store file is not owned by the current user");
1035
+ if ((stat.mode & 63) !== 0) fchmodSync(fd, 384);
1036
+ if (stat.size > MAX_STORE_BYTES) throw new Error(`store exceeds the ${MAX_STORE_BYTES.toString()} byte limit`);
1037
+ const raw = readFileSync(fd, "utf8");
783
1038
  return normalize(JSON.parse(raw));
784
1039
  } catch (err) {
785
- this.logger?.warn("[mcp-bundler/store] failed to read store; returning empty", {
1040
+ this.logger?.warn("[mcp-bundler/store] rejected unreadable or invalid store", {
786
1041
  err: errMsg$1(err),
787
1042
  path: this.storePath
788
1043
  });
789
- return cloneEmpty();
1044
+ throw new Error(`Store.read: rejected ${this.storePath}: ${errMsg$1(err)}`, { cause: err });
1045
+ } finally {
1046
+ if (fd >= 0) closeSync(fd);
790
1047
  }
791
1048
  }
792
1049
  /**
@@ -799,8 +1056,8 @@ var Store = class {
799
1056
  * The lock guards the read-then-rename window so two processes
800
1057
  * (e.g. two `alfe mcp add` shells, or the CLI racing the daemon)
801
1058
  * can't drop each other's writes. The lock file is at
802
- * `<storePath>.lock`; stale locks (older than `LOCK_STALE_MS`) are
803
- * stolen so a crashed writer doesn't wedge the store.
1059
+ * `<storePath>.lock`; locks whose recorded writer process is no longer
1060
+ * alive are reclaimed so a crashed writer doesn't wedge the store.
804
1061
  *
805
1062
  * Pure-function shape (instead of a `read()` then `write(next)`
806
1063
  * pair) intentionally — it keeps the read-modify-write contract
@@ -808,18 +1065,27 @@ var Store = class {
808
1065
  * other's partial state.
809
1066
  */
810
1067
  update(fn) {
811
- mkdirSync(dirname(this.storePath), { recursive: true });
1068
+ mkdirSync(dirname(this.storePath), {
1069
+ recursive: true,
1070
+ mode: 448
1071
+ });
812
1072
  const release = this.acquireLock();
813
1073
  try {
814
- const next = fn(this.read());
815
- const tempPath = `${this.storePath}.${String(process.pid)}.${String(Date.now())}.tmp`;
816
- writeFileSync(tempPath, JSON.stringify(next, null, 2), {
817
- encoding: "utf8",
818
- mode: 384
819
- });
1074
+ const next = normalize(fn(this.read()));
1075
+ const payload = JSON.stringify(next, null, 2);
1076
+ if (Buffer.byteLength(payload, "utf8") > MAX_STORE_BYTES) throw new Error(`Store.update: result exceeds the ${MAX_STORE_BYTES.toString()} byte limit`);
1077
+ const tempPath = `${this.storePath}.${String(process.pid)}.${randomUUID()}.tmp`;
1078
+ let tempFd = -1;
820
1079
  try {
1080
+ tempFd = openSync(tempPath, "wx", 384);
1081
+ writeFileSync(tempFd, payload, { encoding: "utf8" });
1082
+ fsyncSync(tempFd);
1083
+ closeSync(tempFd);
1084
+ tempFd = -1;
821
1085
  renameSync(tempPath, this.storePath);
1086
+ this.fsyncParentDirectory();
822
1087
  } catch (err) {
1088
+ if (tempFd >= 0) closeSync(tempFd);
823
1089
  try {
824
1090
  unlinkSync(tempPath);
825
1091
  } catch {}
@@ -832,11 +1098,11 @@ var Store = class {
832
1098
  }
833
1099
  /**
834
1100
  * Acquire an inter-process file lock by atomically creating a
835
- * sentinel via `openSync(lockPath, 'wx')`. Spins with bounded
836
- * backoff up to `LOCK_WAIT_MS`. If the lock file is older than
837
- * `LOCK_STALE_MS` it's assumed orphaned (writer crashed mid-update)
838
- * and stolen the write window is sub-second in practice, so
839
- * holding the lock for >5s means something went wrong.
1101
+ * sentinel via `openSync(lockPath, 'wx')`. Waits with bounded
1102
+ * backoff up to `LOCK_WAIT_MS`. A structured lock is reclaimed only when
1103
+ * its writer process is no longer alive; age alone cannot steal a lock
1104
+ * from a slow but valid writer. Old malformed/legacy locks retain a
1105
+ * conservative age fallback.
840
1106
  *
841
1107
  * Returns the release function. Single-process callers are
842
1108
  * unaffected — re-entering the same process spins briefly while
@@ -846,20 +1112,33 @@ var Store = class {
846
1112
  const lockPath = `${this.storePath}.lock`;
847
1113
  const deadline = Date.now() + LOCK_WAIT_MS;
848
1114
  let fd = -1;
1115
+ let heldDevice = -1;
1116
+ let heldInode = -1;
849
1117
  for (;;) try {
850
1118
  fd = openSync(lockPath, "wx", 384);
1119
+ const token = randomUUID();
1120
+ writeFileSync(fd, JSON.stringify({
1121
+ pid: process.pid,
1122
+ token,
1123
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1124
+ }), "utf8");
1125
+ fsyncSync(fd);
1126
+ const stat = fstatSync(fd);
1127
+ heldDevice = stat.dev;
1128
+ heldInode = stat.ino;
851
1129
  break;
852
1130
  } catch (err) {
853
1131
  if (err.code !== "EEXIST") throw err;
854
- if (this.lockIsStale(lockPath)) {
1132
+ const stale = this.inspectStaleLock(lockPath);
1133
+ if (stale) {
855
1134
  try {
856
- unlinkSync(lockPath);
1135
+ const current = lstatSync(lockPath);
1136
+ if (current.dev === stale.device && current.ino === stale.inode) unlinkSync(lockPath);
857
1137
  } catch {}
858
1138
  continue;
859
1139
  }
860
1140
  if (Date.now() >= deadline) throw new Error(`Store.update: timed out waiting for ${lockPath} (held by another writer or stale lock)`);
861
- const sleepUntil = Date.now() + LOCK_RETRY_INTERVAL_MS;
862
- while (Date.now() < sleepUntil);
1141
+ sleepSync(LOCK_RETRY_INTERVAL_MS);
863
1142
  }
864
1143
  const held = fd;
865
1144
  return () => {
@@ -867,16 +1146,56 @@ var Store = class {
867
1146
  closeSync(held);
868
1147
  } catch {}
869
1148
  try {
870
- unlinkSync(lockPath);
1149
+ const current = lstatSync(lockPath);
1150
+ if (current.dev === heldDevice && current.ino === heldInode) unlinkSync(lockPath);
871
1151
  } catch {}
872
1152
  };
873
1153
  }
874
- lockIsStale(lockPath) {
1154
+ inspectStaleLock(lockPath) {
1155
+ let fd = -1;
1156
+ let modifiedAt;
1157
+ let device;
1158
+ let inode;
875
1159
  try {
876
- const st = statSync(lockPath);
877
- return Date.now() - st.mtimeMs > LOCK_STALE_MS;
1160
+ fd = openSync(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
1161
+ const stat = fstatSync(fd);
1162
+ modifiedAt = stat.mtimeMs;
1163
+ device = stat.dev;
1164
+ inode = stat.ino;
1165
+ if (!stat.isFile() || stat.size > 4096) return Date.now() - stat.mtimeMs > LOCK_STALE_MS ? {
1166
+ device,
1167
+ inode
1168
+ } : void 0;
1169
+ const parsed = JSON.parse(readFileSync(fd, "utf8"));
1170
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return Date.now() - stat.mtimeMs > LOCK_STALE_MS ? {
1171
+ device,
1172
+ inode
1173
+ } : void 0;
1174
+ const pid = parsed.pid;
1175
+ if (!Number.isSafeInteger(pid) || pid <= 0) return Date.now() - stat.mtimeMs > LOCK_STALE_MS ? {
1176
+ device,
1177
+ inode
1178
+ } : void 0;
1179
+ return !processIsAlive(pid) ? {
1180
+ device,
1181
+ inode
1182
+ } : void 0;
878
1183
  } catch {
879
- return false;
1184
+ return modifiedAt !== void 0 && device !== void 0 && inode !== void 0 && Date.now() - modifiedAt > LOCK_STALE_MS ? {
1185
+ device,
1186
+ inode
1187
+ } : void 0;
1188
+ } finally {
1189
+ if (fd >= 0) closeSync(fd);
1190
+ }
1191
+ }
1192
+ fsyncParentDirectory() {
1193
+ let fd = -1;
1194
+ try {
1195
+ fd = openSync(dirname(this.storePath), constants.O_RDONLY);
1196
+ fsyncSync(fd);
1197
+ } catch {} finally {
1198
+ if (fd >= 0) closeSync(fd);
880
1199
  }
881
1200
  }
882
1201
  /**
@@ -900,7 +1219,10 @@ var Store = class {
900
1219
  }
901
1220
  ensureWatcher() {
902
1221
  if (this.watcher) return;
903
- mkdirSync(dirname(this.storePath), { recursive: true });
1222
+ mkdirSync(dirname(this.storePath), {
1223
+ recursive: true,
1224
+ mode: 448
1225
+ });
904
1226
  const dir = dirname(this.storePath);
905
1227
  const basename = this.storePath.slice(dir.length + 1);
906
1228
  let pending;
@@ -949,43 +1271,60 @@ var Store = class {
949
1271
  function defaultStorePath() {
950
1272
  return DEFAULT_STORE_PATH;
951
1273
  }
1274
+ /**
1275
+ * One-way launch-identity fingerprint for cache invalidation across IPC.
1276
+ * Store entries contain credentials in env/headers, so consumers that only
1277
+ * need equality receive this digest rather than the executable configuration.
1278
+ */
1279
+ function serverLaunchFingerprint(entry) {
1280
+ const identity = {
1281
+ config: toServerConfig(entry),
1282
+ version: entry.version ?? null
1283
+ };
1284
+ return `sha256:${createHash("sha256").update(stableJson(identity)).digest("hex")}`;
1285
+ }
952
1286
  /** Pull the runtime config (transport + transport-specific fields) out of a stored entry. */
953
1287
  function toServerConfig(entry) {
954
1288
  if (entry.transport === "stdio") {
955
- const { command, args, env, cwd } = entry;
1289
+ const { command, args, env, cwd, connectionTimeoutMs } = entry;
956
1290
  const cfg = { command };
957
- if (args) cfg.args = args;
958
- if (env) cfg.env = env;
1291
+ if (args) cfg.args = [...args];
1292
+ if (env) cfg.env = { ...env };
959
1293
  if (cwd) cfg.cwd = cwd;
960
- return cfg;
1294
+ if (connectionTimeoutMs !== void 0) cfg.connectionTimeoutMs = connectionTimeoutMs;
1295
+ return validateServerConfig(cfg);
961
1296
  }
962
1297
  const { url, transport, headers, connectionTimeoutMs } = entry;
963
1298
  const cfg = {
964
1299
  url,
965
1300
  transport
966
1301
  };
967
- if (headers) cfg.headers = headers;
1302
+ if (headers) cfg.headers = { ...headers };
968
1303
  if (connectionTimeoutMs !== void 0) cfg.connectionTimeoutMs = connectionTimeoutMs;
969
- return cfg;
1304
+ return validateServerConfig(cfg);
970
1305
  }
971
1306
  /** Build a stored entry from a runtime config + ownership metadata. */
972
1307
  function toStoredEntry(config, meta) {
1308
+ validateOwner(meta.owner);
1309
+ const checked = validateServerConfig(config);
973
1310
  const addedAt = meta.addedAt ?? (/* @__PURE__ */ new Date()).toISOString();
974
- if ("command" in config) return {
1311
+ validateTimestamp(addedAt, "addedAt");
1312
+ if (meta.version !== void 0) validateVersion(meta.version);
1313
+ if ("command" in checked) return {
975
1314
  transport: "stdio",
976
1315
  owner: meta.owner,
977
1316
  addedAt,
978
1317
  ...meta.version !== void 0 ? { version: meta.version } : {},
979
- ...config
1318
+ ...checked
980
1319
  };
981
- const transport = meta.transport ?? config.transport ?? "sse";
1320
+ const transport = meta.transport ?? checked.transport ?? "sse";
982
1321
  if (transport === "stdio") throw new Error("toStoredEntry: transport=stdio specified but config is remote-shaped");
983
1322
  return {
984
- transport,
985
1323
  owner: meta.owner,
986
1324
  addedAt,
987
1325
  ...meta.version !== void 0 ? { version: meta.version } : {},
988
- ...config
1326
+ ...checked,
1327
+ transport
989
1328
  };
990
1329
  }
991
1330
  function cloneEmpty() {
@@ -996,17 +1335,90 @@ function cloneEmpty() {
996
1335
  };
997
1336
  }
998
1337
  function normalize(raw) {
999
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return cloneEmpty();
1338
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("store root must be an object");
1000
1339
  const r = raw;
1340
+ const rawServers = r.servers;
1341
+ if (!rawServers || typeof rawServers !== "object" || Array.isArray(rawServers)) throw new Error("store.servers must be an object");
1342
+ const serverEntries = Object.entries(rawServers);
1343
+ validateServerCount(serverEntries.length);
1344
+ const servers = {};
1345
+ for (const [id, rawEntry] of serverEntries) {
1346
+ validateServerId(id);
1347
+ if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) throw new Error(`store server "${id}" must be an object`);
1348
+ const entry = rawEntry;
1349
+ validateOwner(entry.owner);
1350
+ validateTimestamp(entry.addedAt, `store server "${id}" addedAt`);
1351
+ if (entry.version !== void 0) validateVersion(entry.version);
1352
+ if (![
1353
+ "stdio",
1354
+ "sse",
1355
+ "streamable-http"
1356
+ ].includes(String(entry.transport))) throw new Error(`store server "${id}" has an invalid transport`);
1357
+ servers[id] = toStoredEntry(entry.transport === "stdio" ? validateServerConfig(entry, `store server "${id}"`) : validateServerConfig({
1358
+ ...entry,
1359
+ transport: entry.transport
1360
+ }, `store server "${id}"`), {
1361
+ owner: entry.owner,
1362
+ transport: entry.transport,
1363
+ addedAt: entry.addedAt,
1364
+ version: entry.version
1365
+ });
1366
+ }
1367
+ const rawConfig = r.config ?? {};
1368
+ if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) throw new Error("store.config must be an object");
1369
+ const config = {};
1370
+ const ttl = rawConfig.sessionIdleTtlMs;
1371
+ if (ttl !== void 0) {
1372
+ if (typeof ttl !== "number" || !Number.isSafeInteger(ttl) || ttl < 0 || ttl > 864e5) throw new Error("store.config.sessionIdleTtlMs must be an integer from 0 to 86400000");
1373
+ config.sessionIdleTtlMs = ttl;
1374
+ }
1375
+ const rawOwned = r._ownedOpenclawKeys;
1376
+ if (rawOwned !== void 0 && !Array.isArray(rawOwned)) throw new Error("store._ownedOpenclawKeys must be an array");
1377
+ const owned = [...new Set((rawOwned ?? []).map((value) => validateServerId(value, "store._ownedOpenclawKeys entry")))];
1378
+ validateServerCount(owned.length);
1001
1379
  return {
1002
- servers: r.servers && typeof r.servers === "object" ? r.servers : {},
1003
- config: r.config && typeof r.config === "object" ? r.config : {},
1004
- _ownedOpenclawKeys: Array.isArray(r._ownedOpenclawKeys) ? r._ownedOpenclawKeys.slice() : []
1380
+ servers,
1381
+ config,
1382
+ _ownedOpenclawKeys: owned
1005
1383
  };
1006
1384
  }
1385
+ function validateOwner(owner) {
1386
+ if (owner === "cli" || owner === "manual") return;
1387
+ if (typeof owner === "string" && owner.startsWith("integration:")) {
1388
+ validateServerId(owner.slice(12), "integration owner id");
1389
+ return;
1390
+ }
1391
+ throw new Error("server owner must be cli, manual, or integration:<id>");
1392
+ }
1393
+ function validateTimestamp(value, label) {
1394
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new Error(`${label} must be an ISO timestamp`);
1395
+ }
1396
+ function validateVersion(value) {
1397
+ if (typeof value !== "string" || !/^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$/.test(value)) throw new Error("server version is invalid");
1398
+ }
1399
+ function processIsAlive(pid) {
1400
+ try {
1401
+ process.kill(pid, 0);
1402
+ return true;
1403
+ } catch (err) {
1404
+ return err.code !== "ESRCH";
1405
+ }
1406
+ }
1407
+ const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
1408
+ function sleepSync(ms) {
1409
+ Atomics.wait(sleepBuffer, 0, 0, ms);
1410
+ }
1007
1411
  function errMsg$1(err) {
1008
1412
  return err instanceof Error ? err.message : String(err);
1009
1413
  }
1414
+ function stableJson(value) {
1415
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
1416
+ if (value !== null && typeof value === "object") {
1417
+ const record = value;
1418
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
1419
+ }
1420
+ return value === void 0 ? "null" : JSON.stringify(value);
1421
+ }
1010
1422
  //#endregion
1011
1423
  //#region src/manager.ts
1012
1424
  /**
@@ -1026,11 +1438,13 @@ function errMsg$1(err) {
1026
1438
  */
1027
1439
  var Manager = class {
1028
1440
  store;
1441
+ ownsStore;
1029
1442
  logger;
1030
1443
  bundler;
1031
1444
  changeListeners = /* @__PURE__ */ new Set();
1032
1445
  storeUnsubscribe;
1033
1446
  constructor(opts = {}) {
1447
+ this.ownsStore = opts.store === void 0;
1034
1448
  this.store = opts.store ?? new Store({ logger: opts.logger });
1035
1449
  this.logger = opts.logger;
1036
1450
  }
@@ -1041,14 +1455,14 @@ var Manager = class {
1041
1455
  /**
1042
1456
  * Register or overwrite a server entry. Mutation lands in the store
1043
1457
  * synchronously; if a bundler has been attached via `loadIntoBundler`,
1044
- * it is re-reconciled BEFORE `onChange` listeners fire (errors logged,
1045
- * never thrown the store is the source of truth, the bundler is
1046
- * derived). The ordering is load-bearing: the daemon's `onChange`
1458
+ * it is re-reconciled BEFORE `onChange` listeners fire. A reconcile error is
1459
+ * logged and does not roll back the durable store mutation, but listeners do
1460
+ * not fire against stale live state. The ordering is load-bearing: the daemon's `onChange`
1047
1461
  * handler warms the bundler's current connections, so the just-added
1048
1462
  * server must already have its Connection object or it is never warmed.
1049
1463
  */
1050
1464
  async addServer(config, opts) {
1051
- if (!opts.id) throw new Error("Manager.addServer: id is required");
1465
+ validateServerId(opts.id, "Manager.addServer id");
1052
1466
  const owner = opts.owner ?? "manual";
1053
1467
  this.store.update((cur) => {
1054
1468
  const previousAddedAt = lookupAddedAt(cur.servers, opts.id);
@@ -1066,8 +1480,10 @@ var Manager = class {
1066
1480
  }
1067
1481
  };
1068
1482
  });
1069
- await this.reconcileBundlerLogged();
1070
- this.fireChange();
1483
+ if (opts.strictReconcile) {
1484
+ await this.reconcileBundler();
1485
+ this.fireChange();
1486
+ } else if (await this.reconcileBundlerLogged()) this.fireChange();
1071
1487
  }
1072
1488
  /**
1073
1489
  * Remove a single server entry. No-op if the id isn't in the store.
@@ -1076,16 +1492,24 @@ var Manager = class {
1076
1492
  * accidentally clobbering integration- or cli-owned entries.
1077
1493
  */
1078
1494
  async removeServer(id, opts = {}) {
1079
- const existing = lookupEntry(this.store.read().servers, id);
1080
- if (!existing) return false;
1081
- if (opts.expectedOwner && existing.owner !== opts.expectedOwner) throw new Error(`Manager.removeServer: server "${id}" is owned by "${existing.owner}", not "${opts.expectedOwner}"`);
1082
- this.store.update((cur) => ({
1083
- ...cur,
1084
- servers: Object.fromEntries(Object.entries(cur.servers).filter(([k]) => k !== id))
1085
- }));
1086
- await this.reconcileBundlerLogged();
1087
- this.fireChange();
1088
- return true;
1495
+ validateServerId(id, "Manager.removeServer id");
1496
+ const outcome = { removed: false };
1497
+ this.store.update((cur) => {
1498
+ const existing = lookupEntry(cur.servers, id);
1499
+ if (!existing) return cur;
1500
+ if (opts.expectedOwner && existing.owner !== opts.expectedOwner) throw new Error(`Manager.removeServer: server "${id}" is owned by "${existing.owner}", not "${opts.expectedOwner}"`);
1501
+ outcome.removed = true;
1502
+ return {
1503
+ ...cur,
1504
+ servers: Object.fromEntries(Object.entries(cur.servers).filter(([key]) => key !== id))
1505
+ };
1506
+ });
1507
+ if (!outcome.removed && !opts.strictReconcile) return false;
1508
+ if (opts.strictReconcile) {
1509
+ await this.reconcileBundler();
1510
+ this.fireChange();
1511
+ } else if (await this.reconcileBundlerLogged()) this.fireChange();
1512
+ return outcome.removed;
1089
1513
  }
1090
1514
  /** Drop every entry whose owner matches — used by integration uninstall. */
1091
1515
  async removeServersByOwner(owner) {
@@ -1101,8 +1525,7 @@ var Manager = class {
1101
1525
  };
1102
1526
  });
1103
1527
  if (removed.length > 0) {
1104
- await this.reconcileBundlerLogged();
1105
- this.fireChange();
1528
+ if (await this.reconcileBundlerLogged()) this.fireChange();
1106
1529
  }
1107
1530
  return removed;
1108
1531
  }
@@ -1135,8 +1558,14 @@ var Manager = class {
1135
1558
  */
1136
1559
  async warmServer(id, timeoutMs) {
1137
1560
  if (!this.bundler) return null;
1138
- await this.reconcileBundler();
1139
- return await this.bundler.warmServer(id, timeoutMs) ?? null;
1561
+ try {
1562
+ validateServerId(id, "Manager.warmServer id");
1563
+ await this.reconcileBundler();
1564
+ return await this.bundler.warmServer(id, timeoutMs) ?? null;
1565
+ } catch (err) {
1566
+ this.logger?.warn("[mcp-bundler/manager] warm reconcile failed", { err: errMsg(err) });
1567
+ return null;
1568
+ }
1140
1569
  }
1141
1570
  /**
1142
1571
  * Push the current store contents into a bundler instance (which owns
@@ -1161,31 +1590,33 @@ var Manager = class {
1161
1590
  }
1162
1591
  /**
1163
1592
  * Detach from the bundler and stop watching the store. Safe to call
1164
- * multiple times. Does not dispose the underlying `Store` so the
1165
- * shared instance survives multi-manager environments (rare).
1593
+ * multiple times. An injected Store remains owned by its caller; a Store
1594
+ * constructed by this manager is disposed here.
1166
1595
  */
1167
1596
  async dispose() {
1168
1597
  if (this.storeUnsubscribe) {
1169
1598
  this.storeUnsubscribe();
1170
1599
  this.storeUnsubscribe = void 0;
1171
1600
  }
1172
- this.store.dispose();
1601
+ if (this.ownsStore) this.store.dispose();
1173
1602
  this.changeListeners.clear();
1174
1603
  this.bundler = void 0;
1175
1604
  return Promise.resolve();
1176
1605
  }
1177
1606
  /**
1178
1607
  * Awaited by every mutator so `onChange` listeners observe a bundler
1179
- * that already contains the mutation. Reconcile failures are logged,
1180
- * never thrown a mutation must not fail because the derived bundler
1181
- * hiccuped.
1608
+ * that already contains the mutation. Reconcile failures are logged and
1609
+ * return false so callers preserve the durable mutation without notifying
1610
+ * listeners against stale derived state.
1182
1611
  */
1183
1612
  async reconcileBundlerLogged() {
1184
- if (!this.bundler) return;
1613
+ if (!this.bundler) return true;
1185
1614
  try {
1186
1615
  await this.reconcileBundler();
1616
+ return true;
1187
1617
  } catch (err) {
1188
1618
  this.logger?.warn("[mcp-bundler/manager] bundler reconcile failed", { err: errMsg(err) });
1619
+ return false;
1189
1620
  }
1190
1621
  }
1191
1622
  async reconcileBundler() {
@@ -1233,15 +1664,17 @@ function lookupAddedAt(servers, id) {
1233
1664
  */
1234
1665
  function checkPatternA(tools, options) {
1235
1666
  const selectorNames = typeof options.selector === "string" ? [options.selector] : options.selector;
1667
+ 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");
1236
1668
  const exempt = new Set(options.exempt ?? []);
1237
1669
  const violations = [];
1238
1670
  for (const tool of tools) {
1239
1671
  if (exempt.has(tool.name)) continue;
1240
- const schema = tool.parameters;
1241
- const properties = schema.properties ?? {};
1242
- const required = schema.required ?? [];
1243
- const matched = selectorNames.find((name) => name in properties);
1244
- if (!matched) {
1672
+ const schema = isRecord(tool.parameters) ? tool.parameters : {};
1673
+ const rawProperties = schema.properties;
1674
+ const properties = isRecord(rawProperties) ? rawProperties : {};
1675
+ const required = Array.isArray(schema.required) ? schema.required.filter((value) => typeof value === "string") : [];
1676
+ const present = selectorNames.filter((name) => Object.hasOwn(properties, name));
1677
+ if (present.length === 0) {
1245
1678
  violations.push({
1246
1679
  tool: tool.name,
1247
1680
  reason: "missing-selector-property",
@@ -1249,20 +1682,29 @@ function checkPatternA(tools, options) {
1249
1682
  });
1250
1683
  continue;
1251
1684
  }
1252
- if (!required.includes(matched)) {
1685
+ const requiredSelectors = present.filter((name) => required.includes(name));
1686
+ if (requiredSelectors.length === 0) {
1253
1687
  violations.push({
1254
1688
  tool: tool.name,
1255
1689
  reason: "selector-not-required",
1256
- detail: `selector "${matched}" present in properties but missing from inputSchema.required`
1690
+ detail: `selector [${present.join(", ")}] present in properties but missing from inputSchema.required`
1257
1691
  });
1258
1692
  continue;
1259
1693
  }
1260
- const type = properties[matched].type;
1261
- if (type !== "string") violations.push({
1262
- tool: tool.name,
1263
- reason: "selector-property-not-string",
1264
- detail: `selector "${matched}" must be JSON Schema type=string (found ${JSON.stringify(type)})`
1265
- });
1694
+ if (!requiredSelectors.find((name) => {
1695
+ const property = properties[name];
1696
+ return isRecord(property) && property.type === "string";
1697
+ })) {
1698
+ const foundTypes = requiredSelectors.map((name) => {
1699
+ const property = properties[name];
1700
+ return `${name}=${JSON.stringify(isRecord(property) ? property.type : void 0)}`;
1701
+ });
1702
+ violations.push({
1703
+ tool: tool.name,
1704
+ reason: "selector-property-not-string",
1705
+ detail: `at least one required selector must be JSON Schema type=string (found ${foundTypes.join(", ")})`
1706
+ });
1707
+ }
1266
1708
  }
1267
1709
  return violations;
1268
1710
  }
@@ -1273,7 +1715,7 @@ function checkPatternA(tools, options) {
1273
1715
  function assertPatternA(tools, options) {
1274
1716
  const violations = checkPatternA(tools, options);
1275
1717
  if (violations.length === 0) return;
1276
- const lines = violations.map((v) => ` - [${v.reason}] ${v.tool}: ${v.detail}`);
1718
+ const lines = violations.map((violation) => ` - [${violation.reason}] ${violation.tool}: ${violation.detail}`);
1277
1719
  throw new Error(`Pattern A validation failed for ${String(violations.length)} tool(s):\n${lines.join("\n")}`);
1278
1720
  }
1279
1721
  /**
@@ -1288,7 +1730,10 @@ function fromMcpDescriptor(descriptor) {
1288
1730
  parameters: descriptor.parameters
1289
1731
  };
1290
1732
  }
1733
+ function isRecord(value) {
1734
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1735
+ }
1291
1736
  //#endregion
1292
- export { Connection, Manager, McpBundler, STDIO_ENV_DENYLIST, Store, assertPatternA, buildNamespacedToolName, checkPatternA, defaultConnect, defaultStorePath, disambiguateAgainst, fromMcpDescriptor, sanitizeNameSegment, sanitizeStdioEnv, toServerConfig, toStoredEntry };
1737
+ export { Connection, Manager, McpBundler, STDIO_ENV_DENYLIST, Store, assertPatternA, buildNamespacedToolName, checkPatternA, defaultConnect, defaultStorePath, disambiguateAgainst, fromMcpDescriptor, sanitizeNameSegment, sanitizeStdioEnv, serverLaunchFingerprint, toServerConfig, toStoredEntry };
1293
1738
 
1294
1739
  //# sourceMappingURL=index.js.map