@alfe.ai/mcp-bundler 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/index.cjs +590 -144
- package/dist/index.d.cts +37 -18
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +37 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +591 -146
- 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",
|
|
@@ -94,8 +314,8 @@ var Connection = class Connection {
|
|
|
94
314
|
/** Whether any connect has ever been attempted — drives the eager retry sweep. */
|
|
95
315
|
connectAttempted = false;
|
|
96
316
|
constructor(params) {
|
|
97
|
-
this.name = params.name;
|
|
98
|
-
this.config = params.config;
|
|
317
|
+
this.name = validateServerId(params.name);
|
|
318
|
+
this.config = validateServerConfig(params.config);
|
|
99
319
|
this.deps = params.deps;
|
|
100
320
|
this.logger = params.logger;
|
|
101
321
|
this.onUnexpectedClose = params.onUnexpectedClose;
|
|
@@ -104,7 +324,7 @@ var Connection = class Connection {
|
|
|
104
324
|
}
|
|
105
325
|
/** Returns the most recent known tool list. May be empty if the server hasn't connected yet. */
|
|
106
326
|
snapshotTools() {
|
|
107
|
-
return this.tools;
|
|
327
|
+
return this.tools.map(cloneToolDescriptor);
|
|
108
328
|
}
|
|
109
329
|
/** Whether an MCP child process / remote connection has been established. */
|
|
110
330
|
isConnected() {
|
|
@@ -177,14 +397,7 @@ var Connection = class Connection {
|
|
|
177
397
|
client.onClose?.(() => {
|
|
178
398
|
this.handleUnexpectedClose(client);
|
|
179
399
|
});
|
|
180
|
-
this.tools = advertised
|
|
181
|
-
prefixed: buildNamespacedToolName(this.name, t.name),
|
|
182
|
-
server: this.name,
|
|
183
|
-
original: t.name,
|
|
184
|
-
label: (t.description ?? t.name).slice(0, 80),
|
|
185
|
-
description: t.description ?? "",
|
|
186
|
-
parameters: t.inputSchema
|
|
187
|
-
}));
|
|
400
|
+
this.tools = normalizeChildCatalog(this.name, advertised);
|
|
188
401
|
this.lastUsedAt = Date.now();
|
|
189
402
|
this.consecutiveFailures = 0;
|
|
190
403
|
this.lastError = void 0;
|
|
@@ -193,7 +406,7 @@ var Connection = class Connection {
|
|
|
193
406
|
} catch (err) {
|
|
194
407
|
attempt.then(({ client }) => client.close()).catch(() => void 0);
|
|
195
408
|
this.consecutiveFailures += 1;
|
|
196
|
-
this.lastError = err instanceof Error ? err.message : String(err);
|
|
409
|
+
this.lastError = redactSensitiveText(err instanceof Error ? err.message : String(err), this.config);
|
|
197
410
|
const backoff = Math.min(Connection.RECONNECT_BACKOFF_BASE_MS * 2 ** (this.consecutiveFailures - 1), Connection.RECONNECT_BACKOFF_MAX_MS);
|
|
198
411
|
this.reconnectBlockedUntilMs = Date.now() + backoff;
|
|
199
412
|
throw err;
|
|
@@ -226,6 +439,7 @@ var Connection = class Connection {
|
|
|
226
439
|
this.logger?.warn(`[mcp-bundler] server "${this.name}" connection closed unexpectedly; will re-spawn on next use`);
|
|
227
440
|
this.client = void 0;
|
|
228
441
|
this.tools = [];
|
|
442
|
+
handle.close().catch(() => void 0);
|
|
229
443
|
try {
|
|
230
444
|
this.onUnexpectedClose?.();
|
|
231
445
|
} catch {}
|
|
@@ -243,14 +457,10 @@ var Connection = class Connection {
|
|
|
243
457
|
}
|
|
244
458
|
this.refreshInFlight = true;
|
|
245
459
|
try {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
label: (t.description ?? t.name).slice(0, 80),
|
|
251
|
-
description: t.description ?? "",
|
|
252
|
-
parameters: t.inputSchema
|
|
253
|
-
}));
|
|
460
|
+
const handle = this.client;
|
|
461
|
+
const advertised = await handle.listTools();
|
|
462
|
+
if (this.client !== handle) return;
|
|
463
|
+
this.tools = normalizeChildCatalog(this.name, advertised);
|
|
254
464
|
this.logger?.debug(`[mcp-bundler] server "${this.name}" refreshed, ${this.tools.length.toString()} tool(s)`);
|
|
255
465
|
} finally {
|
|
256
466
|
this.refreshInFlight = false;
|
|
@@ -266,7 +476,8 @@ var Connection = class Connection {
|
|
|
266
476
|
await this.ensureConnected();
|
|
267
477
|
if (!this.client) throw new Error(`server "${this.name}" failed to connect`);
|
|
268
478
|
this.lastUsedAt = Date.now();
|
|
269
|
-
|
|
479
|
+
const checkedArgs = validateToolArguments(args);
|
|
480
|
+
return normalizeToolResult(await this.client.callTool(originalName, checkedArgs, signal ? { signal } : void 0));
|
|
270
481
|
}
|
|
271
482
|
/**
|
|
272
483
|
* Close the underlying transport. Idempotent. If a connect is in flight
|
|
@@ -315,16 +526,26 @@ async function defaultConnect(server, ctx) {
|
|
|
315
526
|
});
|
|
316
527
|
if (onStderrLine) {
|
|
317
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
|
+
};
|
|
318
536
|
transport.stderr?.on("data", (chunk) => {
|
|
319
537
|
const parts = (carry + chunk.toString()).split("\n");
|
|
320
538
|
carry = parts.pop() ?? "";
|
|
321
|
-
for (const line of parts)
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
} catch {}
|
|
539
|
+
for (const line of parts) emitLine(line);
|
|
540
|
+
if (carry.length > MAX_STDERR_LINE_LENGTH) {
|
|
541
|
+
emitLine(carry);
|
|
542
|
+
carry = "";
|
|
326
543
|
}
|
|
327
544
|
});
|
|
545
|
+
transport.stderr?.on("end", () => {
|
|
546
|
+
emitLine(carry);
|
|
547
|
+
carry = "";
|
|
548
|
+
});
|
|
328
549
|
}
|
|
329
550
|
await client.connect(transport);
|
|
330
551
|
} else {
|
|
@@ -347,7 +568,7 @@ async function defaultConnect(server, ctx) {
|
|
|
347
568
|
closeHandler?.();
|
|
348
569
|
};
|
|
349
570
|
client.onclose = fireClose;
|
|
350
|
-
client.onerror =
|
|
571
|
+
client.onerror = () => void 0;
|
|
351
572
|
return {
|
|
352
573
|
async listTools() {
|
|
353
574
|
return (await client.listTools()).tools.map((t) => ({
|
|
@@ -410,10 +631,10 @@ var McpBundler = class {
|
|
|
410
631
|
reconcileLatch = Promise.resolve();
|
|
411
632
|
constructor(opts = {}, deps) {
|
|
412
633
|
this.logger = opts.logger;
|
|
413
|
-
this.idleTtlMs = opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
|
414
|
-
this.idleSweepIntervalMs = opts.idleSweepIntervalMs ?? DEFAULT_IDLE_SWEEP_INTERVAL_MS;
|
|
415
|
-
this.retrySweepIntervalMs = opts.retrySweepIntervalMs ?? DEFAULT_RETRY_SWEEP_INTERVAL_MS;
|
|
416
|
-
this.connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_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);
|
|
417
638
|
this.retryNeverConnected = opts.retryNeverConnected ?? false;
|
|
418
639
|
this.deps = deps ?? { connect: defaultConnect };
|
|
419
640
|
this.onToolError = opts.onToolError;
|
|
@@ -436,7 +657,7 @@ var McpBundler = class {
|
|
|
436
657
|
crash(name);
|
|
437
658
|
} } : {},
|
|
438
659
|
...stderr ? { onStderrLine: (line) => {
|
|
439
|
-
stderr(name, line);
|
|
660
|
+
stderr(name, redactSensitiveText(line, config));
|
|
440
661
|
} } : {}
|
|
441
662
|
});
|
|
442
663
|
}
|
|
@@ -463,7 +684,11 @@ var McpBundler = class {
|
|
|
463
684
|
}
|
|
464
685
|
async doReconcile(desired) {
|
|
465
686
|
if (this.disposed) throw new Error("McpBundler: disposed");
|
|
466
|
-
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));
|
|
467
692
|
const currentNames = new Set(this.connections.keys());
|
|
468
693
|
const added = [];
|
|
469
694
|
const removed = [];
|
|
@@ -475,7 +700,7 @@ var McpBundler = class {
|
|
|
475
700
|
if (conn) await conn.close();
|
|
476
701
|
removed.push(name);
|
|
477
702
|
}
|
|
478
|
-
for (const [name, config] of Object.entries(
|
|
703
|
+
for (const [name, config] of Object.entries(checkedDesired)) {
|
|
479
704
|
const existing = this.connections.get(name);
|
|
480
705
|
if (!existing) {
|
|
481
706
|
this.connections.set(name, this.buildConnection(name, config));
|
|
@@ -511,17 +736,7 @@ var McpBundler = class {
|
|
|
511
736
|
* so cross-server name clashes never produce duplicate registrations.
|
|
512
737
|
*/
|
|
513
738
|
listTools() {
|
|
514
|
-
|
|
515
|
-
const out = [];
|
|
516
|
-
for (const conn of this.connections.values()) for (const tool of conn.snapshotTools()) {
|
|
517
|
-
const finalName = disambiguateAgainst(tool.prefixed, seen);
|
|
518
|
-
seen.add(finalName);
|
|
519
|
-
out.push(finalName === tool.prefixed ? tool : {
|
|
520
|
-
...tool,
|
|
521
|
-
prefixed: finalName
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
return out;
|
|
739
|
+
return this.resolvedCatalog().map(({ descriptor }) => cloneToolDescriptor(descriptor));
|
|
525
740
|
}
|
|
526
741
|
/**
|
|
527
742
|
* Eagerly connect to every configured server and discover tools. Used by
|
|
@@ -603,10 +818,11 @@ var McpBundler = class {
|
|
|
603
818
|
if (this.disposed) return void 0;
|
|
604
819
|
const conn = this.connections.get(name);
|
|
605
820
|
if (!conn) return void 0;
|
|
821
|
+
const checkedTimeout = timeoutMs === void 0 ? void 0 : checkedDuration(timeoutMs, "warmServer timeoutMs", 3e5);
|
|
606
822
|
const connect = conn.ensureConnected();
|
|
607
823
|
connect.catch(() => void 0);
|
|
608
824
|
try {
|
|
609
|
-
if (
|
|
825
|
+
if (checkedTimeout !== void 0 && checkedTimeout > 0) await this.raceTimeout(connect, checkedTimeout, name);
|
|
610
826
|
else await connect;
|
|
611
827
|
} catch (err) {
|
|
612
828
|
const status = this.statusOf(conn);
|
|
@@ -648,7 +864,7 @@ var McpBundler = class {
|
|
|
648
864
|
isError: true,
|
|
649
865
|
content: [{
|
|
650
866
|
type: "text",
|
|
651
|
-
text: `unknown tool: ${prefixed}`
|
|
867
|
+
text: `unknown MCP tool: ${prefixed.slice(0, 128)}`
|
|
652
868
|
}]
|
|
653
869
|
};
|
|
654
870
|
try {
|
|
@@ -658,11 +874,11 @@ var McpBundler = class {
|
|
|
658
874
|
tool: route.original,
|
|
659
875
|
prefixed,
|
|
660
876
|
kind: "result-error",
|
|
661
|
-
message: extractErrorText(result)
|
|
877
|
+
message: redactSensitiveText(extractErrorText(result), route.connection.config)
|
|
662
878
|
});
|
|
663
|
-
return result;
|
|
879
|
+
return redactErrorResult(result, route.connection.config);
|
|
664
880
|
} catch (err) {
|
|
665
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
881
|
+
const msg = redactSensitiveText(err instanceof Error ? err.message : String(err), route.connection.config);
|
|
666
882
|
this.logger?.error(`[mcp-bundler] tool call failed for "${prefixed}"`, { err: msg });
|
|
667
883
|
this.reportToolError({
|
|
668
884
|
server: route.connection.name,
|
|
@@ -685,10 +901,32 @@ var McpBundler = class {
|
|
|
685
901
|
* tool name. Returns undefined if the tool is not currently advertised.
|
|
686
902
|
*/
|
|
687
903
|
routeToolName(prefixed) {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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;
|
|
692
930
|
}
|
|
693
931
|
/**
|
|
694
932
|
* Tear down all connections and stop background tasks. Idempotent.
|
|
@@ -747,6 +985,10 @@ var McpBundler = class {
|
|
|
747
985
|
await Promise.allSettled(targets.map((c) => c.close()));
|
|
748
986
|
}
|
|
749
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
|
+
}
|
|
750
992
|
//#endregion
|
|
751
993
|
//#region src/store.ts
|
|
752
994
|
const DEFAULT_STORE_PATH = (0, node_path.join)((0, node_path.join)((0, node_os.homedir)(), ".alfe", "mcp"), "servers.json");
|
|
@@ -754,6 +996,7 @@ const DEFAULT_STORE_PATH = (0, node_path.join)((0, node_path.join)((0, node_os.h
|
|
|
754
996
|
const LOCK_WAIT_MS = 5e3;
|
|
755
997
|
const LOCK_RETRY_INTERVAL_MS = 25;
|
|
756
998
|
const LOCK_STALE_MS = 1e4;
|
|
999
|
+
const MAX_STORE_BYTES = 1024 * 1024;
|
|
757
1000
|
/**
|
|
758
1001
|
* On-disk source of truth for the bundler's configured servers.
|
|
759
1002
|
*
|
|
@@ -772,22 +1015,36 @@ var Store = class {
|
|
|
772
1015
|
rewatchTimer;
|
|
773
1016
|
constructor(opts = {}) {
|
|
774
1017
|
this.storePath = opts.path ?? DEFAULT_STORE_PATH;
|
|
1018
|
+
if (!(0, node_path.isAbsolute)(this.storePath)) throw new Error("Store path must be absolute");
|
|
775
1019
|
this.logger = opts.logger;
|
|
776
1020
|
}
|
|
777
1021
|
get path() {
|
|
778
1022
|
return this.storePath;
|
|
779
1023
|
}
|
|
780
1024
|
read() {
|
|
781
|
-
|
|
1025
|
+
let fd = -1;
|
|
782
1026
|
try {
|
|
783
|
-
|
|
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");
|
|
784
1039
|
return normalize(JSON.parse(raw));
|
|
785
1040
|
} catch (err) {
|
|
786
|
-
this.logger?.warn("[mcp-bundler/store]
|
|
1041
|
+
this.logger?.warn("[mcp-bundler/store] rejected unreadable or invalid store", {
|
|
787
1042
|
err: errMsg$1(err),
|
|
788
1043
|
path: this.storePath
|
|
789
1044
|
});
|
|
790
|
-
|
|
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);
|
|
791
1048
|
}
|
|
792
1049
|
}
|
|
793
1050
|
/**
|
|
@@ -800,8 +1057,8 @@ var Store = class {
|
|
|
800
1057
|
* The lock guards the read-then-rename window so two processes
|
|
801
1058
|
* (e.g. two `alfe mcp add` shells, or the CLI racing the daemon)
|
|
802
1059
|
* can't drop each other's writes. The lock file is at
|
|
803
|
-
* `<storePath>.lock`;
|
|
804
|
-
*
|
|
1060
|
+
* `<storePath>.lock`; locks whose recorded writer process is no longer
|
|
1061
|
+
* alive are reclaimed so a crashed writer doesn't wedge the store.
|
|
805
1062
|
*
|
|
806
1063
|
* Pure-function shape (instead of a `read()` then `write(next)`
|
|
807
1064
|
* pair) intentionally — it keeps the read-modify-write contract
|
|
@@ -809,18 +1066,27 @@ var Store = class {
|
|
|
809
1066
|
* other's partial state.
|
|
810
1067
|
*/
|
|
811
1068
|
update(fn) {
|
|
812
|
-
(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
|
+
});
|
|
813
1073
|
const release = this.acquireLock();
|
|
814
1074
|
try {
|
|
815
|
-
const next = fn(this.read());
|
|
816
|
-
const
|
|
817
|
-
(
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
});
|
|
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;
|
|
821
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;
|
|
822
1086
|
(0, node_fs.renameSync)(tempPath, this.storePath);
|
|
1087
|
+
this.fsyncParentDirectory();
|
|
823
1088
|
} catch (err) {
|
|
1089
|
+
if (tempFd >= 0) (0, node_fs.closeSync)(tempFd);
|
|
824
1090
|
try {
|
|
825
1091
|
(0, node_fs.unlinkSync)(tempPath);
|
|
826
1092
|
} catch {}
|
|
@@ -833,11 +1099,11 @@ var Store = class {
|
|
|
833
1099
|
}
|
|
834
1100
|
/**
|
|
835
1101
|
* Acquire an inter-process file lock by atomically creating a
|
|
836
|
-
* sentinel via `openSync(lockPath, 'wx')`.
|
|
837
|
-
* backoff up to `LOCK_WAIT_MS`.
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
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.
|
|
841
1107
|
*
|
|
842
1108
|
* Returns the release function. Single-process callers are
|
|
843
1109
|
* unaffected — re-entering the same process spins briefly while
|
|
@@ -847,20 +1113,33 @@ var Store = class {
|
|
|
847
1113
|
const lockPath = `${this.storePath}.lock`;
|
|
848
1114
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
849
1115
|
let fd = -1;
|
|
1116
|
+
let heldDevice = -1;
|
|
1117
|
+
let heldInode = -1;
|
|
850
1118
|
for (;;) try {
|
|
851
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;
|
|
852
1130
|
break;
|
|
853
1131
|
} catch (err) {
|
|
854
1132
|
if (err.code !== "EEXIST") throw err;
|
|
855
|
-
|
|
1133
|
+
const stale = this.inspectStaleLock(lockPath);
|
|
1134
|
+
if (stale) {
|
|
856
1135
|
try {
|
|
857
|
-
(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);
|
|
858
1138
|
} catch {}
|
|
859
1139
|
continue;
|
|
860
1140
|
}
|
|
861
1141
|
if (Date.now() >= deadline) throw new Error(`Store.update: timed out waiting for ${lockPath} (held by another writer or stale lock)`);
|
|
862
|
-
|
|
863
|
-
while (Date.now() < sleepUntil);
|
|
1142
|
+
sleepSync(LOCK_RETRY_INTERVAL_MS);
|
|
864
1143
|
}
|
|
865
1144
|
const held = fd;
|
|
866
1145
|
return () => {
|
|
@@ -868,16 +1147,56 @@ var Store = class {
|
|
|
868
1147
|
(0, node_fs.closeSync)(held);
|
|
869
1148
|
} catch {}
|
|
870
1149
|
try {
|
|
871
|
-
(0, node_fs.
|
|
1150
|
+
const current = (0, node_fs.lstatSync)(lockPath);
|
|
1151
|
+
if (current.dev === heldDevice && current.ino === heldInode) (0, node_fs.unlinkSync)(lockPath);
|
|
872
1152
|
} catch {}
|
|
873
1153
|
};
|
|
874
1154
|
}
|
|
875
|
-
|
|
1155
|
+
inspectStaleLock(lockPath) {
|
|
1156
|
+
let fd = -1;
|
|
1157
|
+
let modifiedAt;
|
|
1158
|
+
let device;
|
|
1159
|
+
let inode;
|
|
876
1160
|
try {
|
|
877
|
-
|
|
878
|
-
|
|
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;
|
|
879
1184
|
} catch {
|
|
880
|
-
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);
|
|
881
1200
|
}
|
|
882
1201
|
}
|
|
883
1202
|
/**
|
|
@@ -901,7 +1220,10 @@ var Store = class {
|
|
|
901
1220
|
}
|
|
902
1221
|
ensureWatcher() {
|
|
903
1222
|
if (this.watcher) return;
|
|
904
|
-
(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
|
+
});
|
|
905
1227
|
const dir = (0, node_path.dirname)(this.storePath);
|
|
906
1228
|
const basename = this.storePath.slice(dir.length + 1);
|
|
907
1229
|
let pending;
|
|
@@ -950,43 +1272,60 @@ var Store = class {
|
|
|
950
1272
|
function defaultStorePath() {
|
|
951
1273
|
return DEFAULT_STORE_PATH;
|
|
952
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
|
+
}
|
|
953
1287
|
/** Pull the runtime config (transport + transport-specific fields) out of a stored entry. */
|
|
954
1288
|
function toServerConfig(entry) {
|
|
955
1289
|
if (entry.transport === "stdio") {
|
|
956
|
-
const { command, args, env, cwd } = entry;
|
|
1290
|
+
const { command, args, env, cwd, connectionTimeoutMs } = entry;
|
|
957
1291
|
const cfg = { command };
|
|
958
|
-
if (args) cfg.args = args;
|
|
959
|
-
if (env) cfg.env = env;
|
|
1292
|
+
if (args) cfg.args = [...args];
|
|
1293
|
+
if (env) cfg.env = { ...env };
|
|
960
1294
|
if (cwd) cfg.cwd = cwd;
|
|
961
|
-
|
|
1295
|
+
if (connectionTimeoutMs !== void 0) cfg.connectionTimeoutMs = connectionTimeoutMs;
|
|
1296
|
+
return validateServerConfig(cfg);
|
|
962
1297
|
}
|
|
963
1298
|
const { url, transport, headers, connectionTimeoutMs } = entry;
|
|
964
1299
|
const cfg = {
|
|
965
1300
|
url,
|
|
966
1301
|
transport
|
|
967
1302
|
};
|
|
968
|
-
if (headers) cfg.headers = headers;
|
|
1303
|
+
if (headers) cfg.headers = { ...headers };
|
|
969
1304
|
if (connectionTimeoutMs !== void 0) cfg.connectionTimeoutMs = connectionTimeoutMs;
|
|
970
|
-
return cfg;
|
|
1305
|
+
return validateServerConfig(cfg);
|
|
971
1306
|
}
|
|
972
1307
|
/** Build a stored entry from a runtime config + ownership metadata. */
|
|
973
1308
|
function toStoredEntry(config, meta) {
|
|
1309
|
+
validateOwner(meta.owner);
|
|
1310
|
+
const checked = validateServerConfig(config);
|
|
974
1311
|
const addedAt = meta.addedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
975
|
-
|
|
1312
|
+
validateTimestamp(addedAt, "addedAt");
|
|
1313
|
+
if (meta.version !== void 0) validateVersion(meta.version);
|
|
1314
|
+
if ("command" in checked) return {
|
|
976
1315
|
transport: "stdio",
|
|
977
1316
|
owner: meta.owner,
|
|
978
1317
|
addedAt,
|
|
979
1318
|
...meta.version !== void 0 ? { version: meta.version } : {},
|
|
980
|
-
...
|
|
1319
|
+
...checked
|
|
981
1320
|
};
|
|
982
|
-
const transport = meta.transport ??
|
|
1321
|
+
const transport = meta.transport ?? checked.transport ?? "sse";
|
|
983
1322
|
if (transport === "stdio") throw new Error("toStoredEntry: transport=stdio specified but config is remote-shaped");
|
|
984
1323
|
return {
|
|
985
|
-
transport,
|
|
986
1324
|
owner: meta.owner,
|
|
987
1325
|
addedAt,
|
|
988
1326
|
...meta.version !== void 0 ? { version: meta.version } : {},
|
|
989
|
-
...
|
|
1327
|
+
...checked,
|
|
1328
|
+
transport
|
|
990
1329
|
};
|
|
991
1330
|
}
|
|
992
1331
|
function cloneEmpty() {
|
|
@@ -997,17 +1336,90 @@ function cloneEmpty() {
|
|
|
997
1336
|
};
|
|
998
1337
|
}
|
|
999
1338
|
function normalize(raw) {
|
|
1000
|
-
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");
|
|
1001
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);
|
|
1002
1380
|
return {
|
|
1003
|
-
servers
|
|
1004
|
-
config
|
|
1005
|
-
_ownedOpenclawKeys:
|
|
1381
|
+
servers,
|
|
1382
|
+
config,
|
|
1383
|
+
_ownedOpenclawKeys: owned
|
|
1006
1384
|
};
|
|
1007
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
|
+
}
|
|
1008
1412
|
function errMsg$1(err) {
|
|
1009
1413
|
return err instanceof Error ? err.message : String(err);
|
|
1010
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
|
+
}
|
|
1011
1423
|
//#endregion
|
|
1012
1424
|
//#region src/manager.ts
|
|
1013
1425
|
/**
|
|
@@ -1027,11 +1439,13 @@ function errMsg$1(err) {
|
|
|
1027
1439
|
*/
|
|
1028
1440
|
var Manager = class {
|
|
1029
1441
|
store;
|
|
1442
|
+
ownsStore;
|
|
1030
1443
|
logger;
|
|
1031
1444
|
bundler;
|
|
1032
1445
|
changeListeners = /* @__PURE__ */ new Set();
|
|
1033
1446
|
storeUnsubscribe;
|
|
1034
1447
|
constructor(opts = {}) {
|
|
1448
|
+
this.ownsStore = opts.store === void 0;
|
|
1035
1449
|
this.store = opts.store ?? new Store({ logger: opts.logger });
|
|
1036
1450
|
this.logger = opts.logger;
|
|
1037
1451
|
}
|
|
@@ -1042,14 +1456,14 @@ var Manager = class {
|
|
|
1042
1456
|
/**
|
|
1043
1457
|
* Register or overwrite a server entry. Mutation lands in the store
|
|
1044
1458
|
* synchronously; if a bundler has been attached via `loadIntoBundler`,
|
|
1045
|
-
* it is re-reconciled BEFORE `onChange` listeners fire
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
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`
|
|
1048
1462
|
* handler warms the bundler's current connections, so the just-added
|
|
1049
1463
|
* server must already have its Connection object or it is never warmed.
|
|
1050
1464
|
*/
|
|
1051
1465
|
async addServer(config, opts) {
|
|
1052
|
-
|
|
1466
|
+
validateServerId(opts.id, "Manager.addServer id");
|
|
1053
1467
|
const owner = opts.owner ?? "manual";
|
|
1054
1468
|
this.store.update((cur) => {
|
|
1055
1469
|
const previousAddedAt = lookupAddedAt(cur.servers, opts.id);
|
|
@@ -1067,8 +1481,10 @@ var Manager = class {
|
|
|
1067
1481
|
}
|
|
1068
1482
|
};
|
|
1069
1483
|
});
|
|
1070
|
-
|
|
1071
|
-
|
|
1484
|
+
if (opts.strictReconcile) {
|
|
1485
|
+
await this.reconcileBundler();
|
|
1486
|
+
this.fireChange();
|
|
1487
|
+
} else if (await this.reconcileBundlerLogged()) this.fireChange();
|
|
1072
1488
|
}
|
|
1073
1489
|
/**
|
|
1074
1490
|
* Remove a single server entry. No-op if the id isn't in the store.
|
|
@@ -1077,16 +1493,24 @@ var Manager = class {
|
|
|
1077
1493
|
* accidentally clobbering integration- or cli-owned entries.
|
|
1078
1494
|
*/
|
|
1079
1495
|
async removeServer(id, opts = {}) {
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1496
|
+
validateServerId(id, "Manager.removeServer id");
|
|
1497
|
+
const outcome = { removed: false };
|
|
1498
|
+
this.store.update((cur) => {
|
|
1499
|
+
const existing = lookupEntry(cur.servers, id);
|
|
1500
|
+
if (!existing) return cur;
|
|
1501
|
+
if (opts.expectedOwner && existing.owner !== opts.expectedOwner) throw new Error(`Manager.removeServer: server "${id}" is owned by "${existing.owner}", not "${opts.expectedOwner}"`);
|
|
1502
|
+
outcome.removed = true;
|
|
1503
|
+
return {
|
|
1504
|
+
...cur,
|
|
1505
|
+
servers: Object.fromEntries(Object.entries(cur.servers).filter(([key]) => key !== id))
|
|
1506
|
+
};
|
|
1507
|
+
});
|
|
1508
|
+
if (!outcome.removed && !opts.strictReconcile) return false;
|
|
1509
|
+
if (opts.strictReconcile) {
|
|
1510
|
+
await this.reconcileBundler();
|
|
1511
|
+
this.fireChange();
|
|
1512
|
+
} else if (await this.reconcileBundlerLogged()) this.fireChange();
|
|
1513
|
+
return outcome.removed;
|
|
1090
1514
|
}
|
|
1091
1515
|
/** Drop every entry whose owner matches — used by integration uninstall. */
|
|
1092
1516
|
async removeServersByOwner(owner) {
|
|
@@ -1102,8 +1526,7 @@ var Manager = class {
|
|
|
1102
1526
|
};
|
|
1103
1527
|
});
|
|
1104
1528
|
if (removed.length > 0) {
|
|
1105
|
-
await this.reconcileBundlerLogged();
|
|
1106
|
-
this.fireChange();
|
|
1529
|
+
if (await this.reconcileBundlerLogged()) this.fireChange();
|
|
1107
1530
|
}
|
|
1108
1531
|
return removed;
|
|
1109
1532
|
}
|
|
@@ -1136,8 +1559,14 @@ var Manager = class {
|
|
|
1136
1559
|
*/
|
|
1137
1560
|
async warmServer(id, timeoutMs) {
|
|
1138
1561
|
if (!this.bundler) return null;
|
|
1139
|
-
|
|
1140
|
-
|
|
1562
|
+
try {
|
|
1563
|
+
validateServerId(id, "Manager.warmServer id");
|
|
1564
|
+
await this.reconcileBundler();
|
|
1565
|
+
return await this.bundler.warmServer(id, timeoutMs) ?? null;
|
|
1566
|
+
} catch (err) {
|
|
1567
|
+
this.logger?.warn("[mcp-bundler/manager] warm reconcile failed", { err: errMsg(err) });
|
|
1568
|
+
return null;
|
|
1569
|
+
}
|
|
1141
1570
|
}
|
|
1142
1571
|
/**
|
|
1143
1572
|
* Push the current store contents into a bundler instance (which owns
|
|
@@ -1162,31 +1591,33 @@ var Manager = class {
|
|
|
1162
1591
|
}
|
|
1163
1592
|
/**
|
|
1164
1593
|
* Detach from the bundler and stop watching the store. Safe to call
|
|
1165
|
-
* multiple times.
|
|
1166
|
-
*
|
|
1594
|
+
* multiple times. An injected Store remains owned by its caller; a Store
|
|
1595
|
+
* constructed by this manager is disposed here.
|
|
1167
1596
|
*/
|
|
1168
1597
|
async dispose() {
|
|
1169
1598
|
if (this.storeUnsubscribe) {
|
|
1170
1599
|
this.storeUnsubscribe();
|
|
1171
1600
|
this.storeUnsubscribe = void 0;
|
|
1172
1601
|
}
|
|
1173
|
-
this.store.dispose();
|
|
1602
|
+
if (this.ownsStore) this.store.dispose();
|
|
1174
1603
|
this.changeListeners.clear();
|
|
1175
1604
|
this.bundler = void 0;
|
|
1176
1605
|
return Promise.resolve();
|
|
1177
1606
|
}
|
|
1178
1607
|
/**
|
|
1179
1608
|
* Awaited by every mutator so `onChange` listeners observe a bundler
|
|
1180
|
-
* that already contains the mutation. Reconcile failures are logged
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1609
|
+
* that already contains the mutation. Reconcile failures are logged and
|
|
1610
|
+
* return false so callers preserve the durable mutation without notifying
|
|
1611
|
+
* listeners against stale derived state.
|
|
1183
1612
|
*/
|
|
1184
1613
|
async reconcileBundlerLogged() {
|
|
1185
|
-
if (!this.bundler) return;
|
|
1614
|
+
if (!this.bundler) return true;
|
|
1186
1615
|
try {
|
|
1187
1616
|
await this.reconcileBundler();
|
|
1617
|
+
return true;
|
|
1188
1618
|
} catch (err) {
|
|
1189
1619
|
this.logger?.warn("[mcp-bundler/manager] bundler reconcile failed", { err: errMsg(err) });
|
|
1620
|
+
return false;
|
|
1190
1621
|
}
|
|
1191
1622
|
}
|
|
1192
1623
|
async reconcileBundler() {
|
|
@@ -1234,15 +1665,17 @@ function lookupAddedAt(servers, id) {
|
|
|
1234
1665
|
*/
|
|
1235
1666
|
function checkPatternA(tools, options) {
|
|
1236
1667
|
const selectorNames = typeof options.selector === "string" ? [options.selector] : options.selector;
|
|
1668
|
+
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");
|
|
1237
1669
|
const exempt = new Set(options.exempt ?? []);
|
|
1238
1670
|
const violations = [];
|
|
1239
1671
|
for (const tool of tools) {
|
|
1240
1672
|
if (exempt.has(tool.name)) continue;
|
|
1241
|
-
const schema = tool.parameters;
|
|
1242
|
-
const
|
|
1243
|
-
const
|
|
1244
|
-
const
|
|
1245
|
-
|
|
1673
|
+
const schema = isRecord(tool.parameters) ? tool.parameters : {};
|
|
1674
|
+
const rawProperties = schema.properties;
|
|
1675
|
+
const properties = isRecord(rawProperties) ? rawProperties : {};
|
|
1676
|
+
const required = Array.isArray(schema.required) ? schema.required.filter((value) => typeof value === "string") : [];
|
|
1677
|
+
const present = selectorNames.filter((name) => Object.hasOwn(properties, name));
|
|
1678
|
+
if (present.length === 0) {
|
|
1246
1679
|
violations.push({
|
|
1247
1680
|
tool: tool.name,
|
|
1248
1681
|
reason: "missing-selector-property",
|
|
@@ -1250,20 +1683,29 @@ function checkPatternA(tools, options) {
|
|
|
1250
1683
|
});
|
|
1251
1684
|
continue;
|
|
1252
1685
|
}
|
|
1253
|
-
|
|
1686
|
+
const requiredSelectors = present.filter((name) => required.includes(name));
|
|
1687
|
+
if (requiredSelectors.length === 0) {
|
|
1254
1688
|
violations.push({
|
|
1255
1689
|
tool: tool.name,
|
|
1256
1690
|
reason: "selector-not-required",
|
|
1257
|
-
detail: `selector
|
|
1691
|
+
detail: `selector [${present.join(", ")}] present in properties but missing from inputSchema.required`
|
|
1258
1692
|
});
|
|
1259
1693
|
continue;
|
|
1260
1694
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1695
|
+
if (!requiredSelectors.find((name) => {
|
|
1696
|
+
const property = properties[name];
|
|
1697
|
+
return isRecord(property) && property.type === "string";
|
|
1698
|
+
})) {
|
|
1699
|
+
const foundTypes = requiredSelectors.map((name) => {
|
|
1700
|
+
const property = properties[name];
|
|
1701
|
+
return `${name}=${JSON.stringify(isRecord(property) ? property.type : void 0)}`;
|
|
1702
|
+
});
|
|
1703
|
+
violations.push({
|
|
1704
|
+
tool: tool.name,
|
|
1705
|
+
reason: "selector-property-not-string",
|
|
1706
|
+
detail: `at least one required selector must be JSON Schema type=string (found ${foundTypes.join(", ")})`
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1267
1709
|
}
|
|
1268
1710
|
return violations;
|
|
1269
1711
|
}
|
|
@@ -1274,7 +1716,7 @@ function checkPatternA(tools, options) {
|
|
|
1274
1716
|
function assertPatternA(tools, options) {
|
|
1275
1717
|
const violations = checkPatternA(tools, options);
|
|
1276
1718
|
if (violations.length === 0) return;
|
|
1277
|
-
const lines = violations.map((
|
|
1719
|
+
const lines = violations.map((violation) => ` - [${violation.reason}] ${violation.tool}: ${violation.detail}`);
|
|
1278
1720
|
throw new Error(`Pattern A validation failed for ${String(violations.length)} tool(s):\n${lines.join("\n")}`);
|
|
1279
1721
|
}
|
|
1280
1722
|
/**
|
|
@@ -1289,6 +1731,9 @@ function fromMcpDescriptor(descriptor) {
|
|
|
1289
1731
|
parameters: descriptor.parameters
|
|
1290
1732
|
};
|
|
1291
1733
|
}
|
|
1734
|
+
function isRecord(value) {
|
|
1735
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1736
|
+
}
|
|
1292
1737
|
//#endregion
|
|
1293
1738
|
exports.Connection = Connection;
|
|
1294
1739
|
exports.Manager = Manager;
|
|
@@ -1304,5 +1749,6 @@ exports.disambiguateAgainst = disambiguateAgainst;
|
|
|
1304
1749
|
exports.fromMcpDescriptor = fromMcpDescriptor;
|
|
1305
1750
|
exports.sanitizeNameSegment = sanitizeNameSegment;
|
|
1306
1751
|
exports.sanitizeStdioEnv = sanitizeStdioEnv;
|
|
1752
|
+
exports.serverLaunchFingerprint = serverLaunchFingerprint;
|
|
1307
1753
|
exports.toServerConfig = toServerConfig;
|
|
1308
1754
|
exports.toStoredEntry = toStoredEntry;
|