@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.cjs +87 -26
- package/dist/index.d.cts +55 -5
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +55 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +87 -26
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
this.handleUnexpectedClose(
|
|
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
|
-
|
|
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
|
|
507
|
-
*
|
|
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
|
|
994
|
-
* thrown — the store is the source of truth, the bundler is
|
|
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.
|
|
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
|
|
1028
|
-
if (opts.expectedOwner && existing.owner !== 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.
|
|
1086
|
+
await this.reconcileBundlerLogged();
|
|
1034
1087
|
this.fireChange();
|
|
1035
|
-
return
|
|
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.
|
|
1104
|
+
await this.reconcileBundlerLogged();
|
|
1052
1105
|
this.fireChange();
|
|
1053
1106
|
}
|
|
1054
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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;
|