@alfe.ai/mcp-bundler 0.4.0 → 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 +583 -143
- package/dist/index.d.cts +34 -18
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +34 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +584 -145
- 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,7 @@ var Manager = class {
|
|
|
1067
1481
|
}
|
|
1068
1482
|
};
|
|
1069
1483
|
});
|
|
1070
|
-
await this.reconcileBundlerLogged();
|
|
1071
|
-
this.fireChange();
|
|
1484
|
+
if (await this.reconcileBundlerLogged()) this.fireChange();
|
|
1072
1485
|
}
|
|
1073
1486
|
/**
|
|
1074
1487
|
* Remove a single server entry. No-op if the id isn't in the store.
|
|
@@ -1077,15 +1490,20 @@ var Manager = class {
|
|
|
1077
1490
|
* accidentally clobbering integration- or cli-owned entries.
|
|
1078
1491
|
*/
|
|
1079
1492
|
async removeServer(id, opts = {}) {
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1493
|
+
validateServerId(id, "Manager.removeServer id");
|
|
1494
|
+
const outcome = { removed: false };
|
|
1495
|
+
this.store.update((cur) => {
|
|
1496
|
+
const existing = lookupEntry(cur.servers, id);
|
|
1497
|
+
if (!existing) return cur;
|
|
1498
|
+
if (opts.expectedOwner && existing.owner !== opts.expectedOwner) throw new Error(`Manager.removeServer: server "${id}" is owned by "${existing.owner}", not "${opts.expectedOwner}"`);
|
|
1499
|
+
outcome.removed = true;
|
|
1500
|
+
return {
|
|
1501
|
+
...cur,
|
|
1502
|
+
servers: Object.fromEntries(Object.entries(cur.servers).filter(([key]) => key !== id))
|
|
1503
|
+
};
|
|
1504
|
+
});
|
|
1505
|
+
if (!outcome.removed) return false;
|
|
1506
|
+
if (await this.reconcileBundlerLogged()) this.fireChange();
|
|
1089
1507
|
return true;
|
|
1090
1508
|
}
|
|
1091
1509
|
/** Drop every entry whose owner matches — used by integration uninstall. */
|
|
@@ -1102,8 +1520,7 @@ var Manager = class {
|
|
|
1102
1520
|
};
|
|
1103
1521
|
});
|
|
1104
1522
|
if (removed.length > 0) {
|
|
1105
|
-
await this.reconcileBundlerLogged();
|
|
1106
|
-
this.fireChange();
|
|
1523
|
+
if (await this.reconcileBundlerLogged()) this.fireChange();
|
|
1107
1524
|
}
|
|
1108
1525
|
return removed;
|
|
1109
1526
|
}
|
|
@@ -1136,8 +1553,14 @@ var Manager = class {
|
|
|
1136
1553
|
*/
|
|
1137
1554
|
async warmServer(id, timeoutMs) {
|
|
1138
1555
|
if (!this.bundler) return null;
|
|
1139
|
-
|
|
1140
|
-
|
|
1556
|
+
try {
|
|
1557
|
+
validateServerId(id, "Manager.warmServer id");
|
|
1558
|
+
await this.reconcileBundler();
|
|
1559
|
+
return await this.bundler.warmServer(id, timeoutMs) ?? null;
|
|
1560
|
+
} catch (err) {
|
|
1561
|
+
this.logger?.warn("[mcp-bundler/manager] warm reconcile failed", { err: errMsg(err) });
|
|
1562
|
+
return null;
|
|
1563
|
+
}
|
|
1141
1564
|
}
|
|
1142
1565
|
/**
|
|
1143
1566
|
* Push the current store contents into a bundler instance (which owns
|
|
@@ -1162,31 +1585,33 @@ var Manager = class {
|
|
|
1162
1585
|
}
|
|
1163
1586
|
/**
|
|
1164
1587
|
* Detach from the bundler and stop watching the store. Safe to call
|
|
1165
|
-
* multiple times.
|
|
1166
|
-
*
|
|
1588
|
+
* multiple times. An injected Store remains owned by its caller; a Store
|
|
1589
|
+
* constructed by this manager is disposed here.
|
|
1167
1590
|
*/
|
|
1168
1591
|
async dispose() {
|
|
1169
1592
|
if (this.storeUnsubscribe) {
|
|
1170
1593
|
this.storeUnsubscribe();
|
|
1171
1594
|
this.storeUnsubscribe = void 0;
|
|
1172
1595
|
}
|
|
1173
|
-
this.store.dispose();
|
|
1596
|
+
if (this.ownsStore) this.store.dispose();
|
|
1174
1597
|
this.changeListeners.clear();
|
|
1175
1598
|
this.bundler = void 0;
|
|
1176
1599
|
return Promise.resolve();
|
|
1177
1600
|
}
|
|
1178
1601
|
/**
|
|
1179
1602
|
* Awaited by every mutator so `onChange` listeners observe a bundler
|
|
1180
|
-
* that already contains the mutation. Reconcile failures are logged
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1603
|
+
* that already contains the mutation. Reconcile failures are logged and
|
|
1604
|
+
* return false so callers preserve the durable mutation without notifying
|
|
1605
|
+
* listeners against stale derived state.
|
|
1183
1606
|
*/
|
|
1184
1607
|
async reconcileBundlerLogged() {
|
|
1185
|
-
if (!this.bundler) return;
|
|
1608
|
+
if (!this.bundler) return true;
|
|
1186
1609
|
try {
|
|
1187
1610
|
await this.reconcileBundler();
|
|
1611
|
+
return true;
|
|
1188
1612
|
} catch (err) {
|
|
1189
1613
|
this.logger?.warn("[mcp-bundler/manager] bundler reconcile failed", { err: errMsg(err) });
|
|
1614
|
+
return false;
|
|
1190
1615
|
}
|
|
1191
1616
|
}
|
|
1192
1617
|
async reconcileBundler() {
|
|
@@ -1234,15 +1659,17 @@ function lookupAddedAt(servers, id) {
|
|
|
1234
1659
|
*/
|
|
1235
1660
|
function checkPatternA(tools, options) {
|
|
1236
1661
|
const selectorNames = typeof options.selector === "string" ? [options.selector] : options.selector;
|
|
1662
|
+
if (selectorNames.length === 0 || selectorNames.some((name) => typeof name !== "string" || name.length === 0)) throw new Error("Pattern A selector must contain at least one non-empty property name");
|
|
1237
1663
|
const exempt = new Set(options.exempt ?? []);
|
|
1238
1664
|
const violations = [];
|
|
1239
1665
|
for (const tool of tools) {
|
|
1240
1666
|
if (exempt.has(tool.name)) continue;
|
|
1241
|
-
const schema = tool.parameters;
|
|
1242
|
-
const
|
|
1243
|
-
const
|
|
1244
|
-
const
|
|
1245
|
-
|
|
1667
|
+
const schema = isRecord(tool.parameters) ? tool.parameters : {};
|
|
1668
|
+
const rawProperties = schema.properties;
|
|
1669
|
+
const properties = isRecord(rawProperties) ? rawProperties : {};
|
|
1670
|
+
const required = Array.isArray(schema.required) ? schema.required.filter((value) => typeof value === "string") : [];
|
|
1671
|
+
const present = selectorNames.filter((name) => Object.hasOwn(properties, name));
|
|
1672
|
+
if (present.length === 0) {
|
|
1246
1673
|
violations.push({
|
|
1247
1674
|
tool: tool.name,
|
|
1248
1675
|
reason: "missing-selector-property",
|
|
@@ -1250,20 +1677,29 @@ function checkPatternA(tools, options) {
|
|
|
1250
1677
|
});
|
|
1251
1678
|
continue;
|
|
1252
1679
|
}
|
|
1253
|
-
|
|
1680
|
+
const requiredSelectors = present.filter((name) => required.includes(name));
|
|
1681
|
+
if (requiredSelectors.length === 0) {
|
|
1254
1682
|
violations.push({
|
|
1255
1683
|
tool: tool.name,
|
|
1256
1684
|
reason: "selector-not-required",
|
|
1257
|
-
detail: `selector
|
|
1685
|
+
detail: `selector [${present.join(", ")}] present in properties but missing from inputSchema.required`
|
|
1258
1686
|
});
|
|
1259
1687
|
continue;
|
|
1260
1688
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1689
|
+
if (!requiredSelectors.find((name) => {
|
|
1690
|
+
const property = properties[name];
|
|
1691
|
+
return isRecord(property) && property.type === "string";
|
|
1692
|
+
})) {
|
|
1693
|
+
const foundTypes = requiredSelectors.map((name) => {
|
|
1694
|
+
const property = properties[name];
|
|
1695
|
+
return `${name}=${JSON.stringify(isRecord(property) ? property.type : void 0)}`;
|
|
1696
|
+
});
|
|
1697
|
+
violations.push({
|
|
1698
|
+
tool: tool.name,
|
|
1699
|
+
reason: "selector-property-not-string",
|
|
1700
|
+
detail: `at least one required selector must be JSON Schema type=string (found ${foundTypes.join(", ")})`
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1267
1703
|
}
|
|
1268
1704
|
return violations;
|
|
1269
1705
|
}
|
|
@@ -1274,7 +1710,7 @@ function checkPatternA(tools, options) {
|
|
|
1274
1710
|
function assertPatternA(tools, options) {
|
|
1275
1711
|
const violations = checkPatternA(tools, options);
|
|
1276
1712
|
if (violations.length === 0) return;
|
|
1277
|
-
const lines = violations.map((
|
|
1713
|
+
const lines = violations.map((violation) => ` - [${violation.reason}] ${violation.tool}: ${violation.detail}`);
|
|
1278
1714
|
throw new Error(`Pattern A validation failed for ${String(violations.length)} tool(s):\n${lines.join("\n")}`);
|
|
1279
1715
|
}
|
|
1280
1716
|
/**
|
|
@@ -1289,6 +1725,9 @@ function fromMcpDescriptor(descriptor) {
|
|
|
1289
1725
|
parameters: descriptor.parameters
|
|
1290
1726
|
};
|
|
1291
1727
|
}
|
|
1728
|
+
function isRecord(value) {
|
|
1729
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1730
|
+
}
|
|
1292
1731
|
//#endregion
|
|
1293
1732
|
exports.Connection = Connection;
|
|
1294
1733
|
exports.Manager = Manager;
|
|
@@ -1304,5 +1743,6 @@ exports.disambiguateAgainst = disambiguateAgainst;
|
|
|
1304
1743
|
exports.fromMcpDescriptor = fromMcpDescriptor;
|
|
1305
1744
|
exports.sanitizeNameSegment = sanitizeNameSegment;
|
|
1306
1745
|
exports.sanitizeStdioEnv = sanitizeStdioEnv;
|
|
1746
|
+
exports.serverLaunchFingerprint = serverLaunchFingerprint;
|
|
1307
1747
|
exports.toServerConfig = toServerConfig;
|
|
1308
1748
|
exports.toStoredEntry = toStoredEntry;
|