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