@alfe.ai/mcp-bundler 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -89,6 +89,9 @@ var Connection = class Connection {
89
89
  static RECONNECT_BACKOFF_MAX_MS = 3e4;
90
90
  onUnexpectedClose;
91
91
  onStderrLine;
92
+ connectTimeoutMs;
93
+ /** Whether any connect has ever been attempted — drives the eager retry sweep. */
94
+ connectAttempted = false;
92
95
  constructor(params) {
93
96
  this.name = params.name;
94
97
  this.config = params.config;
@@ -96,6 +99,7 @@ var Connection = class Connection {
96
99
  this.logger = params.logger;
97
100
  this.onUnexpectedClose = params.onUnexpectedClose;
98
101
  this.onStderrLine = params.onStderrLine;
102
+ this.connectTimeoutMs = params.connectTimeoutMs ?? 0;
99
103
  }
100
104
  /** Returns the most recent known tool list. May be empty if the server hasn't connected yet. */
101
105
  snapshotTools() {
@@ -117,6 +121,15 @@ var Connection = class Connection {
117
121
  lastErrorMessage() {
118
122
  return this.lastError;
119
123
  }
124
+ /**
125
+ * Whether a connect was ever attempted (success or failure). A reconciled
126
+ * connection that was never warmed has neither a client nor a `lastError` —
127
+ * this flag lets an eager host's retry sweep find it without also
128
+ * resurrecting cleanly-closed (idle-reaped) connections.
129
+ */
130
+ hasConnectAttempted() {
131
+ return this.connectAttempted;
132
+ }
120
133
  /** Idle timestamp for reaping. */
121
134
  idleSinceMs() {
122
135
  return Date.now() - this.lastUsedAt;
@@ -135,23 +148,33 @@ var Connection = class Connection {
135
148
  return this.connectInFlight;
136
149
  }
137
150
  async connectAndDiscover() {
151
+ this.connectAttempted = true;
138
152
  const safeConfig = "command" in this.config ? {
139
153
  ...this.config,
140
154
  env: sanitizeStdioEnv(this.config.env)
141
155
  } : this.config;
142
156
  this.logger?.debug(`[mcp-bundler] connecting server "${this.name}"`);
143
- let client;
144
- try {
145
- client = await this.deps.connect(safeConfig, {
157
+ const attempt = (async () => {
158
+ const c = await this.deps.connect(safeConfig, {
146
159
  serverName: this.name,
147
160
  onStderrLine: this.onStderrLine
148
161
  });
149
- const advertised = await client.listTools();
150
- const connected = client;
151
- this.client = connected;
162
+ try {
163
+ return {
164
+ client: c,
165
+ advertised: await c.listTools()
166
+ };
167
+ } catch (err) {
168
+ await c.close().catch(() => void 0);
169
+ throw err;
170
+ }
171
+ })();
172
+ try {
173
+ const { client, advertised } = await this.raceConnectTimeout(attempt);
174
+ this.client = client;
152
175
  this.closing = false;
153
- connected.onClose?.(() => {
154
- this.handleUnexpectedClose(connected);
176
+ client.onClose?.(() => {
177
+ this.handleUnexpectedClose(client);
155
178
  });
156
179
  this.tools = advertised.map((t) => ({
157
180
  prefixed: buildNamespacedToolName(this.name, t.name),
@@ -167,7 +190,7 @@ var Connection = class Connection {
167
190
  this.reconnectBlockedUntilMs = 0;
168
191
  this.logger?.info(`[mcp-bundler] server "${this.name}" connected, ${this.tools.length.toString()} tool(s)`);
169
192
  } catch (err) {
170
- if (client) await client.close().catch(() => void 0);
193
+ attempt.then(({ client }) => client.close()).catch(() => void 0);
171
194
  this.consecutiveFailures += 1;
172
195
  this.lastError = err instanceof Error ? err.message : String(err);
173
196
  const backoff = Math.min(Connection.RECONNECT_BACKOFF_BASE_MS * 2 ** (this.consecutiveFailures - 1), Connection.RECONNECT_BACKOFF_MAX_MS);
@@ -175,6 +198,23 @@ var Connection = class Connection {
175
198
  throw err;
176
199
  }
177
200
  }
201
+ /** Race the attempt against `connectTimeoutMs`; 0 disables the bound. */
202
+ async raceConnectTimeout(attempt) {
203
+ const ms = this.connectTimeoutMs;
204
+ if (ms <= 0) return attempt;
205
+ let timer;
206
+ const timeout = new Promise((_, reject) => {
207
+ timer = setTimeout(() => {
208
+ reject(/* @__PURE__ */ new Error(`server "${this.name}" connect timed out after ${ms.toString()}ms — spawned but did not complete the MCP handshake/discovery`));
209
+ }, ms);
210
+ if (typeof timer === "object" && "unref" in timer) timer.unref();
211
+ });
212
+ try {
213
+ return await Promise.race([attempt, timeout]);
214
+ } finally {
215
+ clearTimeout(timer);
216
+ }
217
+ }
178
218
  /**
179
219
  * Handle an unexpected transport close (crash / network drop). Clears the
180
220
  * dead client + tools so the next `ensureConnected` re-spawns. No-op if we
@@ -335,6 +375,7 @@ async function defaultConnect(server, ctx) {
335
375
  const DEFAULT_IDLE_TTL_MS = 600 * 1e3;
336
376
  const DEFAULT_IDLE_SWEEP_INTERVAL_MS = 60 * 1e3;
337
377
  const DEFAULT_RETRY_SWEEP_INTERVAL_MS = 60 * 1e3;
378
+ const DEFAULT_CONNECT_TIMEOUT_MS = 120 * 1e3;
338
379
  /** First text content of an error result, for host error reporting. */
339
380
  function extractErrorText(result) {
340
381
  for (const item of result.content) if (item.type === "text" && typeof item.text === "string") return item.text.slice(0, 500);
@@ -358,6 +399,8 @@ var McpBundler = class {
358
399
  retrySweepIntervalMs;
359
400
  retrySweepTimer;
360
401
  retrySweepInFlight = false;
402
+ connectTimeoutMs;
403
+ retryNeverConnected;
361
404
  deps;
362
405
  onToolError;
363
406
  onServerCrash;
@@ -369,6 +412,8 @@ var McpBundler = class {
369
412
  this.idleTtlMs = opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
370
413
  this.idleSweepIntervalMs = opts.idleSweepIntervalMs ?? DEFAULT_IDLE_SWEEP_INTERVAL_MS;
371
414
  this.retrySweepIntervalMs = opts.retrySweepIntervalMs ?? DEFAULT_RETRY_SWEEP_INTERVAL_MS;
415
+ this.connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
416
+ this.retryNeverConnected = opts.retryNeverConnected ?? false;
372
417
  this.deps = deps ?? { connect: defaultConnect };
373
418
  this.onToolError = opts.onToolError;
374
419
  this.onServerCrash = opts.onServerCrash;
@@ -385,6 +430,7 @@ var McpBundler = class {
385
430
  config,
386
431
  deps: this.deps,
387
432
  logger: this.logger,
433
+ connectTimeoutMs: config.connectionTimeoutMs ?? this.connectTimeoutMs,
388
434
  ...crash ? { onUnexpectedClose: () => {
389
435
  crash(name);
390
436
  } } : {},
@@ -503,15 +549,20 @@ var McpBundler = class {
503
549
  * 500ms → 30s cap). A server that keeps `exit(1)`-ing fast-fails while in
504
550
  * backoff, so repeated sweeps are cheap and never become a tight crash-loop
505
551
  * — the backoff widens with each failure. Servers that connect on their
506
- * first warm, and lazily-added servers that never warmed (no `lastError`),
507
- * are left alone.
552
+ * first warm are left alone, as are cleanly-closed (idle-reaped) ones.
553
+ *
554
+ * Lazily-added servers that were NEVER attempted (no client, no
555
+ * `lastError`) are also left alone by default — but with
556
+ * `retryNeverConnected` (the daemon's eager mode) the sweep picks them up,
557
+ * so a server added after the host's warmup pass self-heals within one
558
+ * sweep instead of stranding its tools until a restart.
508
559
  *
509
560
  * Returns the statuses of the servers it attempted (empty if none needed a
510
561
  * retry). Never throws — per-server failures are reflected in the status.
511
562
  */
512
563
  async retryFailed() {
513
564
  if (this.disposed) return [];
514
- const targets = Array.from(this.connections.values()).filter((conn) => !conn.isConnected() && conn.lastErrorMessage() !== void 0);
565
+ const targets = Array.from(this.connections.values()).filter((conn) => !conn.isConnected() && (conn.lastErrorMessage() !== void 0 || this.retryNeverConnected && !conn.hasConnectAttempted()));
515
566
  if (targets.length === 0) return [];
516
567
  return (await Promise.allSettled(targets.map(async (conn) => {
517
568
  try {
@@ -990,8 +1041,11 @@ var Manager = class {
990
1041
  /**
991
1042
  * Register or overwrite a server entry. Mutation lands in the store
992
1043
  * synchronously; if a bundler has been attached via `loadIntoBundler`,
993
- * it gets re-reconciled in the background (errors logged, never
994
- * thrown — the store is the source of truth, the bundler is derived).
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`
1047
+ * handler warms the bundler's current connections, so the just-added
1048
+ * server must already have its Connection object or it is never warmed.
995
1049
  */
996
1050
  async addServer(config, opts) {
997
1051
  if (!opts.id) throw new Error("Manager.addServer: id is required");
@@ -1012,9 +1066,8 @@ var Manager = class {
1012
1066
  }
1013
1067
  };
1014
1068
  });
1015
- this.scheduleBundlerReconcile();
1069
+ await this.reconcileBundlerLogged();
1016
1070
  this.fireChange();
1017
- return Promise.resolve();
1018
1071
  }
1019
1072
  /**
1020
1073
  * Remove a single server entry. No-op if the id isn't in the store.
@@ -1022,17 +1075,17 @@ var Manager = class {
1022
1075
  * when supplied — the CLI uses this to guard `alfe mcp remove` from
1023
1076
  * accidentally clobbering integration- or cli-owned entries.
1024
1077
  */
1025
- removeServer(id, opts = {}) {
1078
+ async removeServer(id, opts = {}) {
1026
1079
  const existing = lookupEntry(this.store.read().servers, id);
1027
- if (!existing) return Promise.resolve(false);
1028
- if (opts.expectedOwner && existing.owner !== opts.expectedOwner) return Promise.reject(/* @__PURE__ */ new Error(`Manager.removeServer: server "${id}" is owned by "${existing.owner}", not "${opts.expectedOwner}"`));
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}"`);
1029
1082
  this.store.update((cur) => ({
1030
1083
  ...cur,
1031
1084
  servers: Object.fromEntries(Object.entries(cur.servers).filter(([k]) => k !== id))
1032
1085
  }));
1033
- this.scheduleBundlerReconcile();
1086
+ await this.reconcileBundlerLogged();
1034
1087
  this.fireChange();
1035
- return Promise.resolve(true);
1088
+ return true;
1036
1089
  }
1037
1090
  /** Drop every entry whose owner matches — used by integration uninstall. */
1038
1091
  async removeServersByOwner(owner) {
@@ -1048,10 +1101,10 @@ var Manager = class {
1048
1101
  };
1049
1102
  });
1050
1103
  if (removed.length > 0) {
1051
- this.scheduleBundlerReconcile();
1104
+ await this.reconcileBundlerLogged();
1052
1105
  this.fireChange();
1053
1106
  }
1054
- return Promise.resolve(removed);
1107
+ return removed;
1055
1108
  }
1056
1109
  /** Read-only snapshot for `alfe mcp list` and similar UIs. */
1057
1110
  listServers() {
@@ -1121,11 +1174,19 @@ var Manager = class {
1121
1174
  this.bundler = void 0;
1122
1175
  return Promise.resolve();
1123
1176
  }
1124
- scheduleBundlerReconcile() {
1177
+ /**
1178
+ * 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.
1182
+ */
1183
+ async reconcileBundlerLogged() {
1125
1184
  if (!this.bundler) return;
1126
- this.reconcileBundler().catch((err) => {
1185
+ try {
1186
+ await this.reconcileBundler();
1187
+ } catch (err) {
1127
1188
  this.logger?.warn("[mcp-bundler/manager] bundler reconcile failed", { err: errMsg(err) });
1128
- });
1189
+ }
1129
1190
  }
1130
1191
  async reconcileBundler() {
1131
1192
  if (!this.bundler) return;