@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.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
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 =
|
|
29
|
+
const reservedForServer = boundedServer.length + 2;
|
|
25
30
|
const toolBudget = Math.max(1, MAX_LEN - reservedForServer);
|
|
26
|
-
return `${
|
|
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
|
|
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
|
-
|
|
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",
|
|
@@ -89,17 +309,21 @@ var Connection = class Connection {
|
|
|
89
309
|
static RECONNECT_BACKOFF_MAX_MS = 3e4;
|
|
90
310
|
onUnexpectedClose;
|
|
91
311
|
onStderrLine;
|
|
312
|
+
connectTimeoutMs;
|
|
313
|
+
/** Whether any connect has ever been attempted — drives the eager retry sweep. */
|
|
314
|
+
connectAttempted = false;
|
|
92
315
|
constructor(params) {
|
|
93
|
-
this.name = params.name;
|
|
94
|
-
this.config = params.config;
|
|
316
|
+
this.name = validateServerId(params.name);
|
|
317
|
+
this.config = validateServerConfig(params.config);
|
|
95
318
|
this.deps = params.deps;
|
|
96
319
|
this.logger = params.logger;
|
|
97
320
|
this.onUnexpectedClose = params.onUnexpectedClose;
|
|
98
321
|
this.onStderrLine = params.onStderrLine;
|
|
322
|
+
this.connectTimeoutMs = params.connectTimeoutMs ?? 0;
|
|
99
323
|
}
|
|
100
324
|
/** Returns the most recent known tool list. May be empty if the server hasn't connected yet. */
|
|
101
325
|
snapshotTools() {
|
|
102
|
-
return this.tools;
|
|
326
|
+
return this.tools.map(cloneToolDescriptor);
|
|
103
327
|
}
|
|
104
328
|
/** Whether an MCP child process / remote connection has been established. */
|
|
105
329
|
isConnected() {
|
|
@@ -117,6 +341,15 @@ var Connection = class Connection {
|
|
|
117
341
|
lastErrorMessage() {
|
|
118
342
|
return this.lastError;
|
|
119
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* Whether a connect was ever attempted (success or failure). A reconciled
|
|
346
|
+
* connection that was never warmed has neither a client nor a `lastError` —
|
|
347
|
+
* this flag lets an eager host's retry sweep find it without also
|
|
348
|
+
* resurrecting cleanly-closed (idle-reaped) connections.
|
|
349
|
+
*/
|
|
350
|
+
hasConnectAttempted() {
|
|
351
|
+
return this.connectAttempted;
|
|
352
|
+
}
|
|
120
353
|
/** Idle timestamp for reaping. */
|
|
121
354
|
idleSinceMs() {
|
|
122
355
|
return Date.now() - this.lastUsedAt;
|
|
@@ -135,46 +368,66 @@ var Connection = class Connection {
|
|
|
135
368
|
return this.connectInFlight;
|
|
136
369
|
}
|
|
137
370
|
async connectAndDiscover() {
|
|
371
|
+
this.connectAttempted = true;
|
|
138
372
|
const safeConfig = "command" in this.config ? {
|
|
139
373
|
...this.config,
|
|
140
374
|
env: sanitizeStdioEnv(this.config.env)
|
|
141
375
|
} : this.config;
|
|
142
376
|
this.logger?.debug(`[mcp-bundler] connecting server "${this.name}"`);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
client = await this.deps.connect(safeConfig, {
|
|
377
|
+
const attempt = (async () => {
|
|
378
|
+
const c = await this.deps.connect(safeConfig, {
|
|
146
379
|
serverName: this.name,
|
|
147
380
|
onStderrLine: this.onStderrLine
|
|
148
381
|
});
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
382
|
+
try {
|
|
383
|
+
return {
|
|
384
|
+
client: c,
|
|
385
|
+
advertised: await c.listTools()
|
|
386
|
+
};
|
|
387
|
+
} catch (err) {
|
|
388
|
+
await c.close().catch(() => void 0);
|
|
389
|
+
throw err;
|
|
390
|
+
}
|
|
391
|
+
})();
|
|
392
|
+
try {
|
|
393
|
+
const { client, advertised } = await this.raceConnectTimeout(attempt);
|
|
394
|
+
this.client = client;
|
|
152
395
|
this.closing = false;
|
|
153
|
-
|
|
154
|
-
this.handleUnexpectedClose(
|
|
396
|
+
client.onClose?.(() => {
|
|
397
|
+
this.handleUnexpectedClose(client);
|
|
155
398
|
});
|
|
156
|
-
this.tools = advertised
|
|
157
|
-
prefixed: buildNamespacedToolName(this.name, t.name),
|
|
158
|
-
server: this.name,
|
|
159
|
-
original: t.name,
|
|
160
|
-
label: (t.description ?? t.name).slice(0, 80),
|
|
161
|
-
description: t.description ?? "",
|
|
162
|
-
parameters: t.inputSchema
|
|
163
|
-
}));
|
|
399
|
+
this.tools = normalizeChildCatalog(this.name, advertised);
|
|
164
400
|
this.lastUsedAt = Date.now();
|
|
165
401
|
this.consecutiveFailures = 0;
|
|
166
402
|
this.lastError = void 0;
|
|
167
403
|
this.reconnectBlockedUntilMs = 0;
|
|
168
404
|
this.logger?.info(`[mcp-bundler] server "${this.name}" connected, ${this.tools.length.toString()} tool(s)`);
|
|
169
405
|
} catch (err) {
|
|
170
|
-
|
|
406
|
+
attempt.then(({ client }) => client.close()).catch(() => void 0);
|
|
171
407
|
this.consecutiveFailures += 1;
|
|
172
|
-
this.lastError = err instanceof Error ? err.message : String(err);
|
|
408
|
+
this.lastError = redactSensitiveText(err instanceof Error ? err.message : String(err), this.config);
|
|
173
409
|
const backoff = Math.min(Connection.RECONNECT_BACKOFF_BASE_MS * 2 ** (this.consecutiveFailures - 1), Connection.RECONNECT_BACKOFF_MAX_MS);
|
|
174
410
|
this.reconnectBlockedUntilMs = Date.now() + backoff;
|
|
175
411
|
throw err;
|
|
176
412
|
}
|
|
177
413
|
}
|
|
414
|
+
/** Race the attempt against `connectTimeoutMs`; 0 disables the bound. */
|
|
415
|
+
async raceConnectTimeout(attempt) {
|
|
416
|
+
const ms = this.connectTimeoutMs;
|
|
417
|
+
if (ms <= 0) return attempt;
|
|
418
|
+
let timer;
|
|
419
|
+
const timeout = new Promise((_, reject) => {
|
|
420
|
+
timer = setTimeout(() => {
|
|
421
|
+
reject(/* @__PURE__ */ new Error(`server "${this.name}" connect timed out after ${ms.toString()}ms — spawned but did not complete the MCP handshake/discovery`));
|
|
422
|
+
}, ms);
|
|
423
|
+
if (typeof timer === "object" && "unref" in timer) timer.unref();
|
|
424
|
+
});
|
|
425
|
+
try {
|
|
426
|
+
return await Promise.race([attempt, timeout]);
|
|
427
|
+
} finally {
|
|
428
|
+
clearTimeout(timer);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
178
431
|
/**
|
|
179
432
|
* Handle an unexpected transport close (crash / network drop). Clears the
|
|
180
433
|
* dead client + tools so the next `ensureConnected` re-spawns. No-op if we
|
|
@@ -185,6 +438,7 @@ var Connection = class Connection {
|
|
|
185
438
|
this.logger?.warn(`[mcp-bundler] server "${this.name}" connection closed unexpectedly; will re-spawn on next use`);
|
|
186
439
|
this.client = void 0;
|
|
187
440
|
this.tools = [];
|
|
441
|
+
handle.close().catch(() => void 0);
|
|
188
442
|
try {
|
|
189
443
|
this.onUnexpectedClose?.();
|
|
190
444
|
} catch {}
|
|
@@ -202,14 +456,10 @@ var Connection = class Connection {
|
|
|
202
456
|
}
|
|
203
457
|
this.refreshInFlight = true;
|
|
204
458
|
try {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
label: (t.description ?? t.name).slice(0, 80),
|
|
210
|
-
description: t.description ?? "",
|
|
211
|
-
parameters: t.inputSchema
|
|
212
|
-
}));
|
|
459
|
+
const handle = this.client;
|
|
460
|
+
const advertised = await handle.listTools();
|
|
461
|
+
if (this.client !== handle) return;
|
|
462
|
+
this.tools = normalizeChildCatalog(this.name, advertised);
|
|
213
463
|
this.logger?.debug(`[mcp-bundler] server "${this.name}" refreshed, ${this.tools.length.toString()} tool(s)`);
|
|
214
464
|
} finally {
|
|
215
465
|
this.refreshInFlight = false;
|
|
@@ -225,7 +475,8 @@ var Connection = class Connection {
|
|
|
225
475
|
await this.ensureConnected();
|
|
226
476
|
if (!this.client) throw new Error(`server "${this.name}" failed to connect`);
|
|
227
477
|
this.lastUsedAt = Date.now();
|
|
228
|
-
|
|
478
|
+
const checkedArgs = validateToolArguments(args);
|
|
479
|
+
return normalizeToolResult(await this.client.callTool(originalName, checkedArgs, signal ? { signal } : void 0));
|
|
229
480
|
}
|
|
230
481
|
/**
|
|
231
482
|
* Close the underlying transport. Idempotent. If a connect is in flight
|
|
@@ -274,16 +525,26 @@ async function defaultConnect(server, ctx) {
|
|
|
274
525
|
});
|
|
275
526
|
if (onStderrLine) {
|
|
276
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
|
+
};
|
|
277
535
|
transport.stderr?.on("data", (chunk) => {
|
|
278
536
|
const parts = (carry + chunk.toString()).split("\n");
|
|
279
537
|
carry = parts.pop() ?? "";
|
|
280
|
-
for (const line of parts)
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
} catch {}
|
|
538
|
+
for (const line of parts) emitLine(line);
|
|
539
|
+
if (carry.length > MAX_STDERR_LINE_LENGTH) {
|
|
540
|
+
emitLine(carry);
|
|
541
|
+
carry = "";
|
|
285
542
|
}
|
|
286
543
|
});
|
|
544
|
+
transport.stderr?.on("end", () => {
|
|
545
|
+
emitLine(carry);
|
|
546
|
+
carry = "";
|
|
547
|
+
});
|
|
287
548
|
}
|
|
288
549
|
await client.connect(transport);
|
|
289
550
|
} else {
|
|
@@ -306,7 +567,7 @@ async function defaultConnect(server, ctx) {
|
|
|
306
567
|
closeHandler?.();
|
|
307
568
|
};
|
|
308
569
|
client.onclose = fireClose;
|
|
309
|
-
client.onerror =
|
|
570
|
+
client.onerror = () => void 0;
|
|
310
571
|
return {
|
|
311
572
|
async listTools() {
|
|
312
573
|
return (await client.listTools()).tools.map((t) => ({
|
|
@@ -335,6 +596,7 @@ async function defaultConnect(server, ctx) {
|
|
|
335
596
|
const DEFAULT_IDLE_TTL_MS = 600 * 1e3;
|
|
336
597
|
const DEFAULT_IDLE_SWEEP_INTERVAL_MS = 60 * 1e3;
|
|
337
598
|
const DEFAULT_RETRY_SWEEP_INTERVAL_MS = 60 * 1e3;
|
|
599
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 120 * 1e3;
|
|
338
600
|
/** First text content of an error result, for host error reporting. */
|
|
339
601
|
function extractErrorText(result) {
|
|
340
602
|
for (const item of result.content) if (item.type === "text" && typeof item.text === "string") return item.text.slice(0, 500);
|
|
@@ -358,6 +620,8 @@ var McpBundler = class {
|
|
|
358
620
|
retrySweepIntervalMs;
|
|
359
621
|
retrySweepTimer;
|
|
360
622
|
retrySweepInFlight = false;
|
|
623
|
+
connectTimeoutMs;
|
|
624
|
+
retryNeverConnected;
|
|
361
625
|
deps;
|
|
362
626
|
onToolError;
|
|
363
627
|
onServerCrash;
|
|
@@ -366,9 +630,11 @@ var McpBundler = class {
|
|
|
366
630
|
reconcileLatch = Promise.resolve();
|
|
367
631
|
constructor(opts = {}, deps) {
|
|
368
632
|
this.logger = opts.logger;
|
|
369
|
-
this.idleTtlMs = opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
|
370
|
-
this.idleSweepIntervalMs = opts.idleSweepIntervalMs ?? DEFAULT_IDLE_SWEEP_INTERVAL_MS;
|
|
371
|
-
this.retrySweepIntervalMs = opts.retrySweepIntervalMs ?? DEFAULT_RETRY_SWEEP_INTERVAL_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);
|
|
637
|
+
this.retryNeverConnected = opts.retryNeverConnected ?? false;
|
|
372
638
|
this.deps = deps ?? { connect: defaultConnect };
|
|
373
639
|
this.onToolError = opts.onToolError;
|
|
374
640
|
this.onServerCrash = opts.onServerCrash;
|
|
@@ -385,11 +651,12 @@ var McpBundler = class {
|
|
|
385
651
|
config,
|
|
386
652
|
deps: this.deps,
|
|
387
653
|
logger: this.logger,
|
|
654
|
+
connectTimeoutMs: config.connectionTimeoutMs ?? this.connectTimeoutMs,
|
|
388
655
|
...crash ? { onUnexpectedClose: () => {
|
|
389
656
|
crash(name);
|
|
390
657
|
} } : {},
|
|
391
658
|
...stderr ? { onStderrLine: (line) => {
|
|
392
|
-
stderr(name, line);
|
|
659
|
+
stderr(name, redactSensitiveText(line, config));
|
|
393
660
|
} } : {}
|
|
394
661
|
});
|
|
395
662
|
}
|
|
@@ -416,7 +683,11 @@ var McpBundler = class {
|
|
|
416
683
|
}
|
|
417
684
|
async doReconcile(desired) {
|
|
418
685
|
if (this.disposed) throw new Error("McpBundler: disposed");
|
|
419
|
-
const
|
|
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));
|
|
420
691
|
const currentNames = new Set(this.connections.keys());
|
|
421
692
|
const added = [];
|
|
422
693
|
const removed = [];
|
|
@@ -428,7 +699,7 @@ var McpBundler = class {
|
|
|
428
699
|
if (conn) await conn.close();
|
|
429
700
|
removed.push(name);
|
|
430
701
|
}
|
|
431
|
-
for (const [name, config] of Object.entries(
|
|
702
|
+
for (const [name, config] of Object.entries(checkedDesired)) {
|
|
432
703
|
const existing = this.connections.get(name);
|
|
433
704
|
if (!existing) {
|
|
434
705
|
this.connections.set(name, this.buildConnection(name, config));
|
|
@@ -464,17 +735,7 @@ var McpBundler = class {
|
|
|
464
735
|
* so cross-server name clashes never produce duplicate registrations.
|
|
465
736
|
*/
|
|
466
737
|
listTools() {
|
|
467
|
-
|
|
468
|
-
const out = [];
|
|
469
|
-
for (const conn of this.connections.values()) for (const tool of conn.snapshotTools()) {
|
|
470
|
-
const finalName = disambiguateAgainst(tool.prefixed, seen);
|
|
471
|
-
seen.add(finalName);
|
|
472
|
-
out.push(finalName === tool.prefixed ? tool : {
|
|
473
|
-
...tool,
|
|
474
|
-
prefixed: finalName
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
return out;
|
|
738
|
+
return this.resolvedCatalog().map(({ descriptor }) => cloneToolDescriptor(descriptor));
|
|
478
739
|
}
|
|
479
740
|
/**
|
|
480
741
|
* Eagerly connect to every configured server and discover tools. Used by
|
|
@@ -503,15 +764,20 @@ var McpBundler = class {
|
|
|
503
764
|
* 500ms → 30s cap). A server that keeps `exit(1)`-ing fast-fails while in
|
|
504
765
|
* backoff, so repeated sweeps are cheap and never become a tight crash-loop
|
|
505
766
|
* — the backoff widens with each failure. Servers that connect on their
|
|
506
|
-
* first warm
|
|
507
|
-
*
|
|
767
|
+
* first warm are left alone, as are cleanly-closed (idle-reaped) ones.
|
|
768
|
+
*
|
|
769
|
+
* Lazily-added servers that were NEVER attempted (no client, no
|
|
770
|
+
* `lastError`) are also left alone by default — but with
|
|
771
|
+
* `retryNeverConnected` (the daemon's eager mode) the sweep picks them up,
|
|
772
|
+
* so a server added after the host's warmup pass self-heals within one
|
|
773
|
+
* sweep instead of stranding its tools until a restart.
|
|
508
774
|
*
|
|
509
775
|
* Returns the statuses of the servers it attempted (empty if none needed a
|
|
510
776
|
* retry). Never throws — per-server failures are reflected in the status.
|
|
511
777
|
*/
|
|
512
778
|
async retryFailed() {
|
|
513
779
|
if (this.disposed) return [];
|
|
514
|
-
const targets = Array.from(this.connections.values()).filter((conn) => !conn.isConnected() && conn.lastErrorMessage() !== void 0);
|
|
780
|
+
const targets = Array.from(this.connections.values()).filter((conn) => !conn.isConnected() && (conn.lastErrorMessage() !== void 0 || this.retryNeverConnected && !conn.hasConnectAttempted()));
|
|
515
781
|
if (targets.length === 0) return [];
|
|
516
782
|
return (await Promise.allSettled(targets.map(async (conn) => {
|
|
517
783
|
try {
|
|
@@ -551,10 +817,11 @@ var McpBundler = class {
|
|
|
551
817
|
if (this.disposed) return void 0;
|
|
552
818
|
const conn = this.connections.get(name);
|
|
553
819
|
if (!conn) return void 0;
|
|
820
|
+
const checkedTimeout = timeoutMs === void 0 ? void 0 : checkedDuration(timeoutMs, "warmServer timeoutMs", 3e5);
|
|
554
821
|
const connect = conn.ensureConnected();
|
|
555
822
|
connect.catch(() => void 0);
|
|
556
823
|
try {
|
|
557
|
-
if (
|
|
824
|
+
if (checkedTimeout !== void 0 && checkedTimeout > 0) await this.raceTimeout(connect, checkedTimeout, name);
|
|
558
825
|
else await connect;
|
|
559
826
|
} catch (err) {
|
|
560
827
|
const status = this.statusOf(conn);
|
|
@@ -596,7 +863,7 @@ var McpBundler = class {
|
|
|
596
863
|
isError: true,
|
|
597
864
|
content: [{
|
|
598
865
|
type: "text",
|
|
599
|
-
text: `unknown tool: ${prefixed}`
|
|
866
|
+
text: `unknown MCP tool: ${prefixed.slice(0, 128)}`
|
|
600
867
|
}]
|
|
601
868
|
};
|
|
602
869
|
try {
|
|
@@ -606,11 +873,11 @@ var McpBundler = class {
|
|
|
606
873
|
tool: route.original,
|
|
607
874
|
prefixed,
|
|
608
875
|
kind: "result-error",
|
|
609
|
-
message: extractErrorText(result)
|
|
876
|
+
message: redactSensitiveText(extractErrorText(result), route.connection.config)
|
|
610
877
|
});
|
|
611
|
-
return result;
|
|
878
|
+
return redactErrorResult(result, route.connection.config);
|
|
612
879
|
} catch (err) {
|
|
613
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
880
|
+
const msg = redactSensitiveText(err instanceof Error ? err.message : String(err), route.connection.config);
|
|
614
881
|
this.logger?.error(`[mcp-bundler] tool call failed for "${prefixed}"`, { err: msg });
|
|
615
882
|
this.reportToolError({
|
|
616
883
|
server: route.connection.name,
|
|
@@ -633,10 +900,32 @@ var McpBundler = class {
|
|
|
633
900
|
* tool name. Returns undefined if the tool is not currently advertised.
|
|
634
901
|
*/
|
|
635
902
|
routeToolName(prefixed) {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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;
|
|
640
929
|
}
|
|
641
930
|
/**
|
|
642
931
|
* Tear down all connections and stop background tasks. Idempotent.
|
|
@@ -695,6 +984,10 @@ var McpBundler = class {
|
|
|
695
984
|
await Promise.allSettled(targets.map((c) => c.close()));
|
|
696
985
|
}
|
|
697
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
|
+
}
|
|
698
991
|
//#endregion
|
|
699
992
|
//#region src/store.ts
|
|
700
993
|
const DEFAULT_STORE_PATH = join(join(homedir(), ".alfe", "mcp"), "servers.json");
|
|
@@ -702,6 +995,7 @@ const DEFAULT_STORE_PATH = join(join(homedir(), ".alfe", "mcp"), "servers.json")
|
|
|
702
995
|
const LOCK_WAIT_MS = 5e3;
|
|
703
996
|
const LOCK_RETRY_INTERVAL_MS = 25;
|
|
704
997
|
const LOCK_STALE_MS = 1e4;
|
|
998
|
+
const MAX_STORE_BYTES = 1024 * 1024;
|
|
705
999
|
/**
|
|
706
1000
|
* On-disk source of truth for the bundler's configured servers.
|
|
707
1001
|
*
|
|
@@ -720,22 +1014,36 @@ var Store = class {
|
|
|
720
1014
|
rewatchTimer;
|
|
721
1015
|
constructor(opts = {}) {
|
|
722
1016
|
this.storePath = opts.path ?? DEFAULT_STORE_PATH;
|
|
1017
|
+
if (!isAbsolute(this.storePath)) throw new Error("Store path must be absolute");
|
|
723
1018
|
this.logger = opts.logger;
|
|
724
1019
|
}
|
|
725
1020
|
get path() {
|
|
726
1021
|
return this.storePath;
|
|
727
1022
|
}
|
|
728
1023
|
read() {
|
|
729
|
-
|
|
1024
|
+
let fd = -1;
|
|
730
1025
|
try {
|
|
731
|
-
|
|
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");
|
|
732
1038
|
return normalize(JSON.parse(raw));
|
|
733
1039
|
} catch (err) {
|
|
734
|
-
this.logger?.warn("[mcp-bundler/store]
|
|
1040
|
+
this.logger?.warn("[mcp-bundler/store] rejected unreadable or invalid store", {
|
|
735
1041
|
err: errMsg$1(err),
|
|
736
1042
|
path: this.storePath
|
|
737
1043
|
});
|
|
738
|
-
|
|
1044
|
+
throw new Error(`Store.read: rejected ${this.storePath}: ${errMsg$1(err)}`, { cause: err });
|
|
1045
|
+
} finally {
|
|
1046
|
+
if (fd >= 0) closeSync(fd);
|
|
739
1047
|
}
|
|
740
1048
|
}
|
|
741
1049
|
/**
|
|
@@ -748,8 +1056,8 @@ var Store = class {
|
|
|
748
1056
|
* The lock guards the read-then-rename window so two processes
|
|
749
1057
|
* (e.g. two `alfe mcp add` shells, or the CLI racing the daemon)
|
|
750
1058
|
* can't drop each other's writes. The lock file is at
|
|
751
|
-
* `<storePath>.lock`;
|
|
752
|
-
*
|
|
1059
|
+
* `<storePath>.lock`; locks whose recorded writer process is no longer
|
|
1060
|
+
* alive are reclaimed so a crashed writer doesn't wedge the store.
|
|
753
1061
|
*
|
|
754
1062
|
* Pure-function shape (instead of a `read()` then `write(next)`
|
|
755
1063
|
* pair) intentionally — it keeps the read-modify-write contract
|
|
@@ -757,18 +1065,27 @@ var Store = class {
|
|
|
757
1065
|
* other's partial state.
|
|
758
1066
|
*/
|
|
759
1067
|
update(fn) {
|
|
760
|
-
mkdirSync(dirname(this.storePath), {
|
|
1068
|
+
mkdirSync(dirname(this.storePath), {
|
|
1069
|
+
recursive: true,
|
|
1070
|
+
mode: 448
|
|
1071
|
+
});
|
|
761
1072
|
const release = this.acquireLock();
|
|
762
1073
|
try {
|
|
763
|
-
const next = fn(this.read());
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
});
|
|
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;
|
|
769
1079
|
try {
|
|
1080
|
+
tempFd = openSync(tempPath, "wx", 384);
|
|
1081
|
+
writeFileSync(tempFd, payload, { encoding: "utf8" });
|
|
1082
|
+
fsyncSync(tempFd);
|
|
1083
|
+
closeSync(tempFd);
|
|
1084
|
+
tempFd = -1;
|
|
770
1085
|
renameSync(tempPath, this.storePath);
|
|
1086
|
+
this.fsyncParentDirectory();
|
|
771
1087
|
} catch (err) {
|
|
1088
|
+
if (tempFd >= 0) closeSync(tempFd);
|
|
772
1089
|
try {
|
|
773
1090
|
unlinkSync(tempPath);
|
|
774
1091
|
} catch {}
|
|
@@ -781,11 +1098,11 @@ var Store = class {
|
|
|
781
1098
|
}
|
|
782
1099
|
/**
|
|
783
1100
|
* Acquire an inter-process file lock by atomically creating a
|
|
784
|
-
* sentinel via `openSync(lockPath, 'wx')`.
|
|
785
|
-
* backoff up to `LOCK_WAIT_MS`.
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
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.
|
|
789
1106
|
*
|
|
790
1107
|
* Returns the release function. Single-process callers are
|
|
791
1108
|
* unaffected — re-entering the same process spins briefly while
|
|
@@ -795,20 +1112,33 @@ var Store = class {
|
|
|
795
1112
|
const lockPath = `${this.storePath}.lock`;
|
|
796
1113
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
797
1114
|
let fd = -1;
|
|
1115
|
+
let heldDevice = -1;
|
|
1116
|
+
let heldInode = -1;
|
|
798
1117
|
for (;;) try {
|
|
799
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;
|
|
800
1129
|
break;
|
|
801
1130
|
} catch (err) {
|
|
802
1131
|
if (err.code !== "EEXIST") throw err;
|
|
803
|
-
|
|
1132
|
+
const stale = this.inspectStaleLock(lockPath);
|
|
1133
|
+
if (stale) {
|
|
804
1134
|
try {
|
|
805
|
-
|
|
1135
|
+
const current = lstatSync(lockPath);
|
|
1136
|
+
if (current.dev === stale.device && current.ino === stale.inode) unlinkSync(lockPath);
|
|
806
1137
|
} catch {}
|
|
807
1138
|
continue;
|
|
808
1139
|
}
|
|
809
1140
|
if (Date.now() >= deadline) throw new Error(`Store.update: timed out waiting for ${lockPath} (held by another writer or stale lock)`);
|
|
810
|
-
|
|
811
|
-
while (Date.now() < sleepUntil);
|
|
1141
|
+
sleepSync(LOCK_RETRY_INTERVAL_MS);
|
|
812
1142
|
}
|
|
813
1143
|
const held = fd;
|
|
814
1144
|
return () => {
|
|
@@ -816,16 +1146,56 @@ var Store = class {
|
|
|
816
1146
|
closeSync(held);
|
|
817
1147
|
} catch {}
|
|
818
1148
|
try {
|
|
819
|
-
|
|
1149
|
+
const current = lstatSync(lockPath);
|
|
1150
|
+
if (current.dev === heldDevice && current.ino === heldInode) unlinkSync(lockPath);
|
|
820
1151
|
} catch {}
|
|
821
1152
|
};
|
|
822
1153
|
}
|
|
823
|
-
|
|
1154
|
+
inspectStaleLock(lockPath) {
|
|
1155
|
+
let fd = -1;
|
|
1156
|
+
let modifiedAt;
|
|
1157
|
+
let device;
|
|
1158
|
+
let inode;
|
|
824
1159
|
try {
|
|
825
|
-
|
|
826
|
-
|
|
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;
|
|
827
1183
|
} catch {
|
|
828
|
-
return
|
|
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);
|
|
829
1199
|
}
|
|
830
1200
|
}
|
|
831
1201
|
/**
|
|
@@ -849,7 +1219,10 @@ var Store = class {
|
|
|
849
1219
|
}
|
|
850
1220
|
ensureWatcher() {
|
|
851
1221
|
if (this.watcher) return;
|
|
852
|
-
mkdirSync(dirname(this.storePath), {
|
|
1222
|
+
mkdirSync(dirname(this.storePath), {
|
|
1223
|
+
recursive: true,
|
|
1224
|
+
mode: 448
|
|
1225
|
+
});
|
|
853
1226
|
const dir = dirname(this.storePath);
|
|
854
1227
|
const basename = this.storePath.slice(dir.length + 1);
|
|
855
1228
|
let pending;
|
|
@@ -898,43 +1271,60 @@ var Store = class {
|
|
|
898
1271
|
function defaultStorePath() {
|
|
899
1272
|
return DEFAULT_STORE_PATH;
|
|
900
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
|
+
}
|
|
901
1286
|
/** Pull the runtime config (transport + transport-specific fields) out of a stored entry. */
|
|
902
1287
|
function toServerConfig(entry) {
|
|
903
1288
|
if (entry.transport === "stdio") {
|
|
904
|
-
const { command, args, env, cwd } = entry;
|
|
1289
|
+
const { command, args, env, cwd, connectionTimeoutMs } = entry;
|
|
905
1290
|
const cfg = { command };
|
|
906
|
-
if (args) cfg.args = args;
|
|
907
|
-
if (env) cfg.env = env;
|
|
1291
|
+
if (args) cfg.args = [...args];
|
|
1292
|
+
if (env) cfg.env = { ...env };
|
|
908
1293
|
if (cwd) cfg.cwd = cwd;
|
|
909
|
-
|
|
1294
|
+
if (connectionTimeoutMs !== void 0) cfg.connectionTimeoutMs = connectionTimeoutMs;
|
|
1295
|
+
return validateServerConfig(cfg);
|
|
910
1296
|
}
|
|
911
1297
|
const { url, transport, headers, connectionTimeoutMs } = entry;
|
|
912
1298
|
const cfg = {
|
|
913
1299
|
url,
|
|
914
1300
|
transport
|
|
915
1301
|
};
|
|
916
|
-
if (headers) cfg.headers = headers;
|
|
1302
|
+
if (headers) cfg.headers = { ...headers };
|
|
917
1303
|
if (connectionTimeoutMs !== void 0) cfg.connectionTimeoutMs = connectionTimeoutMs;
|
|
918
|
-
return cfg;
|
|
1304
|
+
return validateServerConfig(cfg);
|
|
919
1305
|
}
|
|
920
1306
|
/** Build a stored entry from a runtime config + ownership metadata. */
|
|
921
1307
|
function toStoredEntry(config, meta) {
|
|
1308
|
+
validateOwner(meta.owner);
|
|
1309
|
+
const checked = validateServerConfig(config);
|
|
922
1310
|
const addedAt = meta.addedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
923
|
-
|
|
1311
|
+
validateTimestamp(addedAt, "addedAt");
|
|
1312
|
+
if (meta.version !== void 0) validateVersion(meta.version);
|
|
1313
|
+
if ("command" in checked) return {
|
|
924
1314
|
transport: "stdio",
|
|
925
1315
|
owner: meta.owner,
|
|
926
1316
|
addedAt,
|
|
927
1317
|
...meta.version !== void 0 ? { version: meta.version } : {},
|
|
928
|
-
...
|
|
1318
|
+
...checked
|
|
929
1319
|
};
|
|
930
|
-
const transport = meta.transport ??
|
|
1320
|
+
const transport = meta.transport ?? checked.transport ?? "sse";
|
|
931
1321
|
if (transport === "stdio") throw new Error("toStoredEntry: transport=stdio specified but config is remote-shaped");
|
|
932
1322
|
return {
|
|
933
|
-
transport,
|
|
934
1323
|
owner: meta.owner,
|
|
935
1324
|
addedAt,
|
|
936
1325
|
...meta.version !== void 0 ? { version: meta.version } : {},
|
|
937
|
-
...
|
|
1326
|
+
...checked,
|
|
1327
|
+
transport
|
|
938
1328
|
};
|
|
939
1329
|
}
|
|
940
1330
|
function cloneEmpty() {
|
|
@@ -945,17 +1335,90 @@ function cloneEmpty() {
|
|
|
945
1335
|
};
|
|
946
1336
|
}
|
|
947
1337
|
function normalize(raw) {
|
|
948
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
1338
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("store root must be an object");
|
|
949
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);
|
|
950
1379
|
return {
|
|
951
|
-
servers
|
|
952
|
-
config
|
|
953
|
-
_ownedOpenclawKeys:
|
|
1380
|
+
servers,
|
|
1381
|
+
config,
|
|
1382
|
+
_ownedOpenclawKeys: owned
|
|
954
1383
|
};
|
|
955
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
|
+
}
|
|
956
1411
|
function errMsg$1(err) {
|
|
957
1412
|
return err instanceof Error ? err.message : String(err);
|
|
958
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
|
+
}
|
|
959
1422
|
//#endregion
|
|
960
1423
|
//#region src/manager.ts
|
|
961
1424
|
/**
|
|
@@ -975,11 +1438,13 @@ function errMsg$1(err) {
|
|
|
975
1438
|
*/
|
|
976
1439
|
var Manager = class {
|
|
977
1440
|
store;
|
|
1441
|
+
ownsStore;
|
|
978
1442
|
logger;
|
|
979
1443
|
bundler;
|
|
980
1444
|
changeListeners = /* @__PURE__ */ new Set();
|
|
981
1445
|
storeUnsubscribe;
|
|
982
1446
|
constructor(opts = {}) {
|
|
1447
|
+
this.ownsStore = opts.store === void 0;
|
|
983
1448
|
this.store = opts.store ?? new Store({ logger: opts.logger });
|
|
984
1449
|
this.logger = opts.logger;
|
|
985
1450
|
}
|
|
@@ -990,11 +1455,14 @@ var Manager = class {
|
|
|
990
1455
|
/**
|
|
991
1456
|
* Register or overwrite a server entry. Mutation lands in the store
|
|
992
1457
|
* synchronously; if a bundler has been attached via `loadIntoBundler`,
|
|
993
|
-
* it
|
|
994
|
-
*
|
|
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`
|
|
1461
|
+
* handler warms the bundler's current connections, so the just-added
|
|
1462
|
+
* server must already have its Connection object or it is never warmed.
|
|
995
1463
|
*/
|
|
996
1464
|
async addServer(config, opts) {
|
|
997
|
-
|
|
1465
|
+
validateServerId(opts.id, "Manager.addServer id");
|
|
998
1466
|
const owner = opts.owner ?? "manual";
|
|
999
1467
|
this.store.update((cur) => {
|
|
1000
1468
|
const previousAddedAt = lookupAddedAt(cur.servers, opts.id);
|
|
@@ -1012,9 +1480,7 @@ var Manager = class {
|
|
|
1012
1480
|
}
|
|
1013
1481
|
};
|
|
1014
1482
|
});
|
|
1015
|
-
this.
|
|
1016
|
-
this.fireChange();
|
|
1017
|
-
return Promise.resolve();
|
|
1483
|
+
if (await this.reconcileBundlerLogged()) this.fireChange();
|
|
1018
1484
|
}
|
|
1019
1485
|
/**
|
|
1020
1486
|
* Remove a single server entry. No-op if the id isn't in the store.
|
|
@@ -1022,17 +1488,22 @@ var Manager = class {
|
|
|
1022
1488
|
* when supplied — the CLI uses this to guard `alfe mcp remove` from
|
|
1023
1489
|
* accidentally clobbering integration- or cli-owned entries.
|
|
1024
1490
|
*/
|
|
1025
|
-
removeServer(id, opts = {}) {
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1491
|
+
async removeServer(id, opts = {}) {
|
|
1492
|
+
validateServerId(id, "Manager.removeServer id");
|
|
1493
|
+
const outcome = { removed: false };
|
|
1494
|
+
this.store.update((cur) => {
|
|
1495
|
+
const existing = lookupEntry(cur.servers, id);
|
|
1496
|
+
if (!existing) return cur;
|
|
1497
|
+
if (opts.expectedOwner && existing.owner !== opts.expectedOwner) throw new Error(`Manager.removeServer: server "${id}" is owned by "${existing.owner}", not "${opts.expectedOwner}"`);
|
|
1498
|
+
outcome.removed = true;
|
|
1499
|
+
return {
|
|
1500
|
+
...cur,
|
|
1501
|
+
servers: Object.fromEntries(Object.entries(cur.servers).filter(([key]) => key !== id))
|
|
1502
|
+
};
|
|
1503
|
+
});
|
|
1504
|
+
if (!outcome.removed) return false;
|
|
1505
|
+
if (await this.reconcileBundlerLogged()) this.fireChange();
|
|
1506
|
+
return true;
|
|
1036
1507
|
}
|
|
1037
1508
|
/** Drop every entry whose owner matches — used by integration uninstall. */
|
|
1038
1509
|
async removeServersByOwner(owner) {
|
|
@@ -1048,10 +1519,9 @@ var Manager = class {
|
|
|
1048
1519
|
};
|
|
1049
1520
|
});
|
|
1050
1521
|
if (removed.length > 0) {
|
|
1051
|
-
this.
|
|
1052
|
-
this.fireChange();
|
|
1522
|
+
if (await this.reconcileBundlerLogged()) this.fireChange();
|
|
1053
1523
|
}
|
|
1054
|
-
return
|
|
1524
|
+
return removed;
|
|
1055
1525
|
}
|
|
1056
1526
|
/** Read-only snapshot for `alfe mcp list` and similar UIs. */
|
|
1057
1527
|
listServers() {
|
|
@@ -1082,8 +1552,14 @@ var Manager = class {
|
|
|
1082
1552
|
*/
|
|
1083
1553
|
async warmServer(id, timeoutMs) {
|
|
1084
1554
|
if (!this.bundler) return null;
|
|
1085
|
-
|
|
1086
|
-
|
|
1555
|
+
try {
|
|
1556
|
+
validateServerId(id, "Manager.warmServer id");
|
|
1557
|
+
await this.reconcileBundler();
|
|
1558
|
+
return await this.bundler.warmServer(id, timeoutMs) ?? null;
|
|
1559
|
+
} catch (err) {
|
|
1560
|
+
this.logger?.warn("[mcp-bundler/manager] warm reconcile failed", { err: errMsg(err) });
|
|
1561
|
+
return null;
|
|
1562
|
+
}
|
|
1087
1563
|
}
|
|
1088
1564
|
/**
|
|
1089
1565
|
* Push the current store contents into a bundler instance (which owns
|
|
@@ -1108,24 +1584,34 @@ var Manager = class {
|
|
|
1108
1584
|
}
|
|
1109
1585
|
/**
|
|
1110
1586
|
* Detach from the bundler and stop watching the store. Safe to call
|
|
1111
|
-
* multiple times.
|
|
1112
|
-
*
|
|
1587
|
+
* multiple times. An injected Store remains owned by its caller; a Store
|
|
1588
|
+
* constructed by this manager is disposed here.
|
|
1113
1589
|
*/
|
|
1114
1590
|
async dispose() {
|
|
1115
1591
|
if (this.storeUnsubscribe) {
|
|
1116
1592
|
this.storeUnsubscribe();
|
|
1117
1593
|
this.storeUnsubscribe = void 0;
|
|
1118
1594
|
}
|
|
1119
|
-
this.store.dispose();
|
|
1595
|
+
if (this.ownsStore) this.store.dispose();
|
|
1120
1596
|
this.changeListeners.clear();
|
|
1121
1597
|
this.bundler = void 0;
|
|
1122
1598
|
return Promise.resolve();
|
|
1123
1599
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1600
|
+
/**
|
|
1601
|
+
* Awaited by every mutator so `onChange` listeners observe a bundler
|
|
1602
|
+
* that already contains the mutation. Reconcile failures are logged and
|
|
1603
|
+
* return false so callers preserve the durable mutation without notifying
|
|
1604
|
+
* listeners against stale derived state.
|
|
1605
|
+
*/
|
|
1606
|
+
async reconcileBundlerLogged() {
|
|
1607
|
+
if (!this.bundler) return true;
|
|
1608
|
+
try {
|
|
1609
|
+
await this.reconcileBundler();
|
|
1610
|
+
return true;
|
|
1611
|
+
} catch (err) {
|
|
1127
1612
|
this.logger?.warn("[mcp-bundler/manager] bundler reconcile failed", { err: errMsg(err) });
|
|
1128
|
-
|
|
1613
|
+
return false;
|
|
1614
|
+
}
|
|
1129
1615
|
}
|
|
1130
1616
|
async reconcileBundler() {
|
|
1131
1617
|
if (!this.bundler) return;
|
|
@@ -1172,15 +1658,17 @@ function lookupAddedAt(servers, id) {
|
|
|
1172
1658
|
*/
|
|
1173
1659
|
function checkPatternA(tools, options) {
|
|
1174
1660
|
const selectorNames = typeof options.selector === "string" ? [options.selector] : options.selector;
|
|
1661
|
+
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");
|
|
1175
1662
|
const exempt = new Set(options.exempt ?? []);
|
|
1176
1663
|
const violations = [];
|
|
1177
1664
|
for (const tool of tools) {
|
|
1178
1665
|
if (exempt.has(tool.name)) continue;
|
|
1179
|
-
const schema = tool.parameters;
|
|
1180
|
-
const
|
|
1181
|
-
const
|
|
1182
|
-
const
|
|
1183
|
-
|
|
1666
|
+
const schema = isRecord(tool.parameters) ? tool.parameters : {};
|
|
1667
|
+
const rawProperties = schema.properties;
|
|
1668
|
+
const properties = isRecord(rawProperties) ? rawProperties : {};
|
|
1669
|
+
const required = Array.isArray(schema.required) ? schema.required.filter((value) => typeof value === "string") : [];
|
|
1670
|
+
const present = selectorNames.filter((name) => Object.hasOwn(properties, name));
|
|
1671
|
+
if (present.length === 0) {
|
|
1184
1672
|
violations.push({
|
|
1185
1673
|
tool: tool.name,
|
|
1186
1674
|
reason: "missing-selector-property",
|
|
@@ -1188,20 +1676,29 @@ function checkPatternA(tools, options) {
|
|
|
1188
1676
|
});
|
|
1189
1677
|
continue;
|
|
1190
1678
|
}
|
|
1191
|
-
|
|
1679
|
+
const requiredSelectors = present.filter((name) => required.includes(name));
|
|
1680
|
+
if (requiredSelectors.length === 0) {
|
|
1192
1681
|
violations.push({
|
|
1193
1682
|
tool: tool.name,
|
|
1194
1683
|
reason: "selector-not-required",
|
|
1195
|
-
detail: `selector
|
|
1684
|
+
detail: `selector [${present.join(", ")}] present in properties but missing from inputSchema.required`
|
|
1196
1685
|
});
|
|
1197
1686
|
continue;
|
|
1198
1687
|
}
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1688
|
+
if (!requiredSelectors.find((name) => {
|
|
1689
|
+
const property = properties[name];
|
|
1690
|
+
return isRecord(property) && property.type === "string";
|
|
1691
|
+
})) {
|
|
1692
|
+
const foundTypes = requiredSelectors.map((name) => {
|
|
1693
|
+
const property = properties[name];
|
|
1694
|
+
return `${name}=${JSON.stringify(isRecord(property) ? property.type : void 0)}`;
|
|
1695
|
+
});
|
|
1696
|
+
violations.push({
|
|
1697
|
+
tool: tool.name,
|
|
1698
|
+
reason: "selector-property-not-string",
|
|
1699
|
+
detail: `at least one required selector must be JSON Schema type=string (found ${foundTypes.join(", ")})`
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1205
1702
|
}
|
|
1206
1703
|
return violations;
|
|
1207
1704
|
}
|
|
@@ -1212,7 +1709,7 @@ function checkPatternA(tools, options) {
|
|
|
1212
1709
|
function assertPatternA(tools, options) {
|
|
1213
1710
|
const violations = checkPatternA(tools, options);
|
|
1214
1711
|
if (violations.length === 0) return;
|
|
1215
|
-
const lines = violations.map((
|
|
1712
|
+
const lines = violations.map((violation) => ` - [${violation.reason}] ${violation.tool}: ${violation.detail}`);
|
|
1216
1713
|
throw new Error(`Pattern A validation failed for ${String(violations.length)} tool(s):\n${lines.join("\n")}`);
|
|
1217
1714
|
}
|
|
1218
1715
|
/**
|
|
@@ -1227,7 +1724,10 @@ function fromMcpDescriptor(descriptor) {
|
|
|
1227
1724
|
parameters: descriptor.parameters
|
|
1228
1725
|
};
|
|
1229
1726
|
}
|
|
1727
|
+
function isRecord(value) {
|
|
1728
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1729
|
+
}
|
|
1230
1730
|
//#endregion
|
|
1231
|
-
export { Connection, Manager, McpBundler, STDIO_ENV_DENYLIST, Store, assertPatternA, buildNamespacedToolName, checkPatternA, defaultConnect, defaultStorePath, disambiguateAgainst, fromMcpDescriptor, sanitizeNameSegment, sanitizeStdioEnv, toServerConfig, toStoredEntry };
|
|
1731
|
+
export { Connection, Manager, McpBundler, STDIO_ENV_DENYLIST, Store, assertPatternA, buildNamespacedToolName, checkPatternA, defaultConnect, defaultStorePath, disambiguateAgainst, fromMcpDescriptor, sanitizeNameSegment, sanitizeStdioEnv, serverLaunchFingerprint, toServerConfig, toStoredEntry };
|
|
1232
1732
|
|
|
1233
1733
|
//# sourceMappingURL=index.js.map
|