@alfe.ai/mcp-bundler 0.0.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/dist/index.cjs ADDED
@@ -0,0 +1,460 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/tool-naming.ts
3
+ /**
4
+ * Tool name sanitization and collision handling.
5
+ *
6
+ * OpenClaw constraint: tool names must match `[A-Za-z0-9_-]` and be ≤64 chars.
7
+ * Pattern mirrored from `openclaw/src/agents/pi-bundle-mcp-names.ts`.
8
+ *
9
+ * Strategy: prefix every tool with its server name (`{server}__{tool}`),
10
+ * sanitize disallowed chars to `_`, truncate, then suffix-disambiguate
11
+ * (`-2`, `-3`, ...) on collision.
12
+ */
13
+ const DISALLOWED = /[^A-Za-z0-9_-]/g;
14
+ const MAX_LEN = 64;
15
+ const SEPARATOR = "__";
16
+ function sanitizeNameSegment(value) {
17
+ return value.replace(DISALLOWED, "_");
18
+ }
19
+ function buildNamespacedToolName(server, tool) {
20
+ const base = `${sanitizeNameSegment(server)}${SEPARATOR}${sanitizeNameSegment(tool)}`;
21
+ if (base.length <= MAX_LEN) return base;
22
+ const reservedForServer = sanitizeNameSegment(server).length + 2;
23
+ const toolBudget = Math.max(1, MAX_LEN - reservedForServer);
24
+ return `${sanitizeNameSegment(server)}${SEPARATOR}${sanitizeNameSegment(tool).slice(0, toolBudget)}`;
25
+ }
26
+ /**
27
+ * Disambiguate a candidate name against an existing set by appending `-2`, `-3`, etc.
28
+ * Mutates nothing; returns the chosen name. Caller is responsible for inserting it
29
+ * into the set.
30
+ */
31
+ function disambiguateAgainst(candidate, taken) {
32
+ if (!taken.has(candidate)) return candidate;
33
+ for (let i = 2; i < 1e3; i += 1) {
34
+ const suffix = `-${i.toString()}`;
35
+ const room = MAX_LEN - suffix.length;
36
+ const next = `${candidate.length > room ? candidate.slice(0, room) : candidate}${suffix}`;
37
+ if (!taken.has(next)) return next;
38
+ }
39
+ return `${candidate.slice(0, MAX_LEN - 6)}-x${(taken.size % 1e3).toString().padStart(3, "0")}`;
40
+ }
41
+ //#endregion
42
+ //#region src/connection.ts
43
+ /** Env keys OpenClaw rejects from stdio MCP env blocks. Filter them out before spawning. */
44
+ const STDIO_ENV_DENYLIST = new Set([
45
+ "NODE_OPTIONS",
46
+ "PYTHONSTARTUP",
47
+ "PYTHONPATH",
48
+ "PERL5OPT",
49
+ "RUBYOPT",
50
+ "SHELLOPTS",
51
+ "PS4"
52
+ ]);
53
+ function sanitizeStdioEnv(env) {
54
+ if (!env) return {};
55
+ const safe = {};
56
+ for (const [k, v] of Object.entries(env)) {
57
+ if (STDIO_ENV_DENYLIST.has(k)) continue;
58
+ safe[k] = v;
59
+ }
60
+ return safe;
61
+ }
62
+ /**
63
+ * One Connection per MCP server. Owns lifecycle (lazy-spawn, close, refresh-lock).
64
+ * Refresh-lock pattern adapted from AIWerk `index.ts:219-250` — prevents
65
+ * reconnect + `notifications/tools/list_changed` race.
66
+ */
67
+ var Connection = class {
68
+ name;
69
+ config;
70
+ deps;
71
+ logger;
72
+ client;
73
+ tools = [];
74
+ connectInFlight;
75
+ refreshInFlight = false;
76
+ refreshQueued = false;
77
+ lastUsedAt = Date.now();
78
+ constructor(params) {
79
+ this.name = params.name;
80
+ this.config = params.config;
81
+ this.deps = params.deps;
82
+ this.logger = params.logger;
83
+ }
84
+ /** Returns the most recent known tool list. May be empty if the server hasn't connected yet. */
85
+ snapshotTools() {
86
+ return this.tools;
87
+ }
88
+ /** Whether an MCP child process / remote connection has been established. */
89
+ isConnected() {
90
+ return this.client !== void 0;
91
+ }
92
+ /** Idle timestamp for reaping. */
93
+ idleSinceMs() {
94
+ return Date.now() - this.lastUsedAt;
95
+ }
96
+ /**
97
+ * Lazy connect + tool discovery. Safe to call concurrently; in-flight
98
+ * connects coalesce.
99
+ */
100
+ async ensureConnected() {
101
+ if (this.client) return;
102
+ if (this.connectInFlight) return this.connectInFlight;
103
+ this.connectInFlight = this.connectAndDiscover().finally(() => {
104
+ this.connectInFlight = void 0;
105
+ });
106
+ return this.connectInFlight;
107
+ }
108
+ async connectAndDiscover() {
109
+ const safeConfig = "command" in this.config ? {
110
+ ...this.config,
111
+ env: sanitizeStdioEnv(this.config.env)
112
+ } : this.config;
113
+ this.logger?.debug(`[mcp-bundler] connecting server "${this.name}"`);
114
+ const client = await this.deps.connect(safeConfig);
115
+ try {
116
+ const advertised = await client.listTools();
117
+ this.client = client;
118
+ this.tools = advertised.map((t) => ({
119
+ prefixed: buildNamespacedToolName(this.name, t.name),
120
+ server: this.name,
121
+ original: t.name,
122
+ label: (t.description ?? t.name).slice(0, 80),
123
+ description: t.description ?? "",
124
+ parameters: t.inputSchema
125
+ }));
126
+ this.lastUsedAt = Date.now();
127
+ this.logger?.info(`[mcp-bundler] server "${this.name}" connected, ${this.tools.length.toString()} tool(s)`);
128
+ } catch (err) {
129
+ await client.close().catch(() => void 0);
130
+ throw err;
131
+ }
132
+ }
133
+ /**
134
+ * Re-discover tools. Used on reconnect or `tools/list_changed` notification.
135
+ * Refresh-lock collapses concurrent refreshes; if one is in flight, the next
136
+ * is queued (max 1 queued, since N>1 queued provides no extra freshness).
137
+ */
138
+ async refresh() {
139
+ if (!this.client) return this.ensureConnected();
140
+ if (this.refreshInFlight) {
141
+ this.refreshQueued = true;
142
+ return;
143
+ }
144
+ this.refreshInFlight = true;
145
+ try {
146
+ this.tools = (await this.client.listTools()).map((t) => ({
147
+ prefixed: buildNamespacedToolName(this.name, t.name),
148
+ server: this.name,
149
+ original: t.name,
150
+ label: (t.description ?? t.name).slice(0, 80),
151
+ description: t.description ?? "",
152
+ parameters: t.inputSchema
153
+ }));
154
+ this.logger?.debug(`[mcp-bundler] server "${this.name}" refreshed, ${this.tools.length.toString()} tool(s)`);
155
+ } finally {
156
+ this.refreshInFlight = false;
157
+ if (this.refreshQueued) {
158
+ this.refreshQueued = false;
159
+ this.refresh().catch((err) => {
160
+ this.logger?.warn(`[mcp-bundler] queued refresh for "${this.name}" failed`, { err: err instanceof Error ? err.message : String(err) });
161
+ });
162
+ }
163
+ }
164
+ }
165
+ async callTool(originalName, args, signal) {
166
+ await this.ensureConnected();
167
+ if (!this.client) throw new Error(`server "${this.name}" failed to connect`);
168
+ this.lastUsedAt = Date.now();
169
+ return this.client.callTool(originalName, args, signal ? { signal } : void 0);
170
+ }
171
+ /**
172
+ * Close the underlying transport. Idempotent. If a connect is in flight
173
+ * (warmup racing with reconcile-removal), wait for it to settle and then
174
+ * close the client it produced — otherwise the child process is orphaned.
175
+ */
176
+ async close() {
177
+ if (this.connectInFlight) await this.connectInFlight.catch(() => void 0);
178
+ const c = this.client;
179
+ this.client = void 0;
180
+ this.tools = [];
181
+ if (c) await c.close().catch((err) => {
182
+ this.logger?.warn(`[mcp-bundler] close error for "${this.name}"`, { err: err instanceof Error ? err.message : String(err) });
183
+ });
184
+ }
185
+ /**
186
+ * Stable hash of the config for diff detection in `reconcile`.
187
+ * Two configs with the same hash are equivalent (no restart needed).
188
+ */
189
+ configFingerprint() {
190
+ return JSON.stringify(this.config);
191
+ }
192
+ };
193
+ /**
194
+ * Build the production `connect` factory using the official MCP SDK.
195
+ * Kept in a separate function so tests can substitute a mock without
196
+ * pulling the SDK into the test bundle.
197
+ */
198
+ async function defaultConnect(server) {
199
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
200
+ const client = new Client({
201
+ name: "alfe-mcp-bundler",
202
+ version: "0.0.0"
203
+ }, {});
204
+ if ("command" in server) {
205
+ const stdio = server;
206
+ const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
207
+ const transport = new StdioClientTransport({
208
+ command: stdio.command,
209
+ args: stdio.args ?? [],
210
+ env: { ...sanitizeStdioEnv(stdio.env) },
211
+ cwd: stdio.cwd
212
+ });
213
+ await client.connect(transport);
214
+ } else {
215
+ const remote = server;
216
+ if (remote.transport === "streamable-http") {
217
+ const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
218
+ const transport = new StreamableHTTPClientTransport(new URL(remote.url), { requestInit: { headers: remote.headers ?? {} } });
219
+ await client.connect(transport);
220
+ } else {
221
+ const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
222
+ const transport = new SSEClientTransport(new URL(remote.url), { requestInit: { headers: remote.headers ?? {} } });
223
+ await client.connect(transport);
224
+ }
225
+ }
226
+ return {
227
+ async listTools() {
228
+ return (await client.listTools()).tools.map((t) => ({
229
+ name: t.name,
230
+ description: t.description,
231
+ inputSchema: t.inputSchema
232
+ }));
233
+ },
234
+ async callTool(name, args, opts) {
235
+ return await client.callTool({
236
+ name,
237
+ arguments: args
238
+ }, void 0, opts);
239
+ },
240
+ async close() {
241
+ await client.close();
242
+ }
243
+ };
244
+ }
245
+ //#endregion
246
+ //#region src/bundler.ts
247
+ const DEFAULT_IDLE_TTL_MS = 600 * 1e3;
248
+ const DEFAULT_IDLE_SWEEP_INTERVAL_MS = 60 * 1e3;
249
+ /**
250
+ * Provider-agnostic MCP server bundler. Holds N MCP server connections,
251
+ * exposes a unified namespaced tool catalog, and routes calls to the right
252
+ * server.
253
+ *
254
+ * Designed to be embedded in any host (OpenClaw plugin, AI proxy, Lambda).
255
+ * Public surface is intentionally synchronous where the host needs sync
256
+ * (snapshot, listTools), async only where I/O is unavoidable.
257
+ */
258
+ var McpBundler = class {
259
+ logger;
260
+ connections = /* @__PURE__ */ new Map();
261
+ idleTtlMs;
262
+ idleSweepIntervalMs;
263
+ idleSweepTimer;
264
+ deps;
265
+ disposed = false;
266
+ reconcileLatch = Promise.resolve();
267
+ constructor(opts = {}, deps) {
268
+ this.logger = opts.logger;
269
+ this.idleTtlMs = opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
270
+ this.idleSweepIntervalMs = opts.idleSweepIntervalMs ?? DEFAULT_IDLE_SWEEP_INTERVAL_MS;
271
+ this.deps = deps ?? { connect: defaultConnect };
272
+ if (this.idleTtlMs > 0) this.startIdleSweep();
273
+ }
274
+ /**
275
+ * Diff `desired` against current connections, spawn newcomers, dispose
276
+ * removals, hot-restart on config change. Pull-based — call whenever the
277
+ * host's config snapshot may have changed. Cheap if no diff.
278
+ *
279
+ * Lazy: newly-added servers are NOT eagerly connected; they connect on the
280
+ * first `callTool()` (or first `listTools()` after `forceDiscover()`).
281
+ * This avoids paying spawn cost for servers the agent never uses.
282
+ */
283
+ async reconcile(desired) {
284
+ if (this.disposed) throw new Error("McpBundler: disposed");
285
+ const slot = this.reconcileLatch.then(async () => this.doReconcile(desired));
286
+ this.reconcileLatch = slot.catch(() => void 0);
287
+ return slot;
288
+ }
289
+ async doReconcile(desired) {
290
+ if (this.disposed) throw new Error("McpBundler: disposed");
291
+ const desiredNames = new Set(Object.keys(desired));
292
+ const currentNames = new Set(this.connections.keys());
293
+ const added = [];
294
+ const removed = [];
295
+ const changed = [];
296
+ const unchanged = [];
297
+ for (const name of currentNames) if (!desiredNames.has(name)) {
298
+ const conn = this.connections.get(name);
299
+ this.connections.delete(name);
300
+ if (conn) await conn.close();
301
+ removed.push(name);
302
+ }
303
+ for (const [name, config] of Object.entries(desired)) {
304
+ const existing = this.connections.get(name);
305
+ if (!existing) {
306
+ this.connections.set(name, new Connection({
307
+ name,
308
+ config,
309
+ deps: this.deps,
310
+ logger: this.logger
311
+ }));
312
+ added.push(name);
313
+ continue;
314
+ }
315
+ const nextFingerprint = JSON.stringify(config);
316
+ if (existing.configFingerprint() !== nextFingerprint) {
317
+ await existing.close();
318
+ this.connections.set(name, new Connection({
319
+ name,
320
+ config,
321
+ deps: this.deps,
322
+ logger: this.logger
323
+ }));
324
+ changed.push(name);
325
+ } else unchanged.push(name);
326
+ }
327
+ if (added.length || removed.length || changed.length) this.logger?.info("[mcp-bundler] reconciled", {
328
+ added: added.length,
329
+ removed: removed.length,
330
+ changed: changed.length,
331
+ unchanged: unchanged.length
332
+ });
333
+ return {
334
+ added,
335
+ removed,
336
+ changed,
337
+ unchanged
338
+ };
339
+ }
340
+ /**
341
+ * Synchronous snapshot of all currently-known tools across connected servers.
342
+ * Servers that have not connected yet contribute nothing. Intended for use
343
+ * inside OpenClaw's plugin tool factory which must be sync.
344
+ *
345
+ * Tool names are namespaced and disambiguated (suffix `-2`, `-3` on collision)
346
+ * so cross-server name clashes never produce duplicate registrations.
347
+ */
348
+ listTools() {
349
+ const seen = /* @__PURE__ */ new Set();
350
+ const out = [];
351
+ for (const conn of this.connections.values()) for (const tool of conn.snapshotTools()) {
352
+ const finalName = disambiguateAgainst(tool.prefixed, seen);
353
+ seen.add(finalName);
354
+ out.push(finalName === tool.prefixed ? tool : {
355
+ ...tool,
356
+ prefixed: finalName
357
+ });
358
+ }
359
+ return out;
360
+ }
361
+ /**
362
+ * Eagerly connect to every configured server and discover tools. Used by
363
+ * hosts that want a hot list rather than the lazy default. Errors are
364
+ * swallowed per-server (logged), so one bad server doesn't fail the batch.
365
+ */
366
+ async warmup() {
367
+ if (this.disposed) return;
368
+ await Promise.allSettled(Array.from(this.connections.values()).map(async (conn) => {
369
+ try {
370
+ await conn.ensureConnected();
371
+ } catch (err) {
372
+ this.logger?.warn(`[mcp-bundler] warmup failed for "${conn.name}"`, { err: err instanceof Error ? err.message : String(err) });
373
+ }
374
+ }));
375
+ }
376
+ /**
377
+ * Invoke a tool by its namespaced name. Routes to the originating server.
378
+ * Errors are returned as `{ isError: true, content: [...] }` so a failing
379
+ * tool doesn't crash the host.
380
+ */
381
+ async callTool(prefixed, args, signal) {
382
+ if (this.disposed) return {
383
+ isError: true,
384
+ content: [{
385
+ type: "text",
386
+ text: "mcp-bundler disposed"
387
+ }]
388
+ };
389
+ const route = this.routeToolName(prefixed);
390
+ if (!route) return {
391
+ isError: true,
392
+ content: [{
393
+ type: "text",
394
+ text: `unknown tool: ${prefixed}`
395
+ }]
396
+ };
397
+ try {
398
+ return await route.connection.callTool(route.original, args, signal);
399
+ } catch (err) {
400
+ const msg = err instanceof Error ? err.message : String(err);
401
+ this.logger?.error(`[mcp-bundler] tool call failed for "${prefixed}"`, { err: msg });
402
+ return {
403
+ isError: true,
404
+ content: [{
405
+ type: "text",
406
+ text: `tool ${prefixed} failed: ${msg}`
407
+ }]
408
+ };
409
+ }
410
+ }
411
+ /**
412
+ * Resolve a namespaced tool name back to its server connection and original
413
+ * tool name. Returns undefined if the tool is not currently advertised.
414
+ */
415
+ routeToolName(prefixed) {
416
+ for (const conn of this.connections.values()) for (const tool of conn.snapshotTools()) if (tool.prefixed === prefixed) return {
417
+ connection: conn,
418
+ original: tool.original
419
+ };
420
+ }
421
+ /**
422
+ * Tear down all connections and stop background tasks. Idempotent.
423
+ * Call from `registerRuntimeLifecycle({ cleanup })` in the host plugin.
424
+ */
425
+ async dispose() {
426
+ if (this.disposed) return;
427
+ this.disposed = true;
428
+ if (this.idleSweepTimer) {
429
+ clearInterval(this.idleSweepTimer);
430
+ this.idleSweepTimer = void 0;
431
+ }
432
+ await Promise.allSettled(Array.from(this.connections.values()).map((c) => c.close()));
433
+ this.connections.clear();
434
+ }
435
+ startIdleSweep() {
436
+ this.idleSweepTimer = setInterval(() => {
437
+ this.sweepIdle().catch((err) => {
438
+ this.logger?.warn("[mcp-bundler] idle sweep error", { err: err instanceof Error ? err.message : String(err) });
439
+ });
440
+ }, this.idleSweepIntervalMs);
441
+ if (typeof this.idleSweepTimer === "object" && "unref" in this.idleSweepTimer) this.idleSweepTimer.unref();
442
+ }
443
+ async sweepIdle() {
444
+ if (this.idleTtlMs <= 0) return;
445
+ const targets = [];
446
+ for (const conn of this.connections.values()) if (conn.isConnected() && conn.idleSinceMs() > this.idleTtlMs) targets.push(conn);
447
+ if (targets.length === 0) return;
448
+ this.logger?.debug(`[mcp-bundler] reaping ${targets.length.toString()} idle server(s)`);
449
+ await Promise.allSettled(targets.map((c) => c.close()));
450
+ }
451
+ };
452
+ //#endregion
453
+ exports.Connection = Connection;
454
+ exports.McpBundler = McpBundler;
455
+ exports.STDIO_ENV_DENYLIST = STDIO_ENV_DENYLIST;
456
+ exports.buildNamespacedToolName = buildNamespacedToolName;
457
+ exports.defaultConnect = defaultConnect;
458
+ exports.disambiguateAgainst = disambiguateAgainst;
459
+ exports.sanitizeNameSegment = sanitizeNameSegment;
460
+ exports.sanitizeStdioEnv = sanitizeStdioEnv;
@@ -0,0 +1,241 @@
1
+ //#region src/types.d.ts
2
+ /**
3
+ * Shape of a single MCP server entry — matches OpenClaw `mcp.servers.{name}`.
4
+ * Either a stdio child process spec or a remote URL spec.
5
+ */
6
+ type McpServerConfig = StdioServerConfig | RemoteServerConfig;
7
+ interface StdioServerConfig {
8
+ command: string;
9
+ args?: string[];
10
+ env?: Record<string, string>;
11
+ cwd?: string;
12
+ }
13
+ interface RemoteServerConfig {
14
+ url: string;
15
+ transport?: 'sse' | 'streamable-http';
16
+ headers?: Record<string, string>;
17
+ connectionTimeoutMs?: number;
18
+ }
19
+ type McpTransportKind = 'stdio' | 'sse' | 'streamable-http';
20
+ /**
21
+ * One tool surfaced from one MCP server, after namespacing.
22
+ */
23
+ interface McpToolDescriptor {
24
+ /** Namespaced name as the LLM sees it: `{serverName}__{originalName}`, sanitized + max 64 chars. */
25
+ prefixed: string;
26
+ /** Server name (key in `mcp.servers.*`). */
27
+ server: string;
28
+ /** Original tool name as advertised by the MCP server. */
29
+ original: string;
30
+ /** Human label for the tool (truncated description, suitable for UI). */
31
+ label: string;
32
+ /** Full description from the MCP server. */
33
+ description: string;
34
+ /** JSON Schema (object) for tool parameters. */
35
+ parameters: Record<string, unknown>;
36
+ }
37
+ interface ReconcileDiff {
38
+ added: string[];
39
+ removed: string[];
40
+ changed: string[];
41
+ unchanged: string[];
42
+ }
43
+ interface Logger {
44
+ debug: (msg: string, meta?: Record<string, unknown>) => void;
45
+ info: (msg: string, meta?: Record<string, unknown>) => void;
46
+ warn: (msg: string, meta?: Record<string, unknown>) => void;
47
+ error: (msg: string, meta?: Record<string, unknown>) => void;
48
+ }
49
+ interface McpToolCallResult {
50
+ content: Record<string, unknown>[];
51
+ isError?: boolean;
52
+ }
53
+ interface BundlerOptions {
54
+ logger?: Logger;
55
+ /** Idle TTL for spawned children (ms). 0 disables. Default: 600_000 (10 min). */
56
+ idleTtlMs?: number;
57
+ /** Sweep interval for idle reaping (ms). Default: 60_000 (1 min). */
58
+ idleSweepIntervalMs?: number;
59
+ }
60
+ //# sourceMappingURL=types.d.ts.map
61
+ //#endregion
62
+ //#region src/connection.d.ts
63
+ /** Env keys OpenClaw rejects from stdio MCP env blocks. Filter them out before spawning. */
64
+ declare const STDIO_ENV_DENYLIST: Set<string>;
65
+ declare function sanitizeStdioEnv(env: Record<string, string> | undefined): Record<string, string>;
66
+ interface ConnectionDeps {
67
+ /**
68
+ * Factory for an MCP Client connected to the given config. Injected so tests
69
+ * can mock without spawning real processes. In production this wraps
70
+ * `@modelcontextprotocol/sdk/client`.
71
+ */
72
+ connect: (server: McpServerConfig) => Promise<McpClientHandle>;
73
+ }
74
+ /**
75
+ * Minimal interface our connection layer needs from an MCP client. Mirrors
76
+ * the @modelcontextprotocol/sdk Client surface but kept narrow so we can
77
+ * mock cleanly in tests.
78
+ */
79
+ interface McpClientHandle {
80
+ listTools(): Promise<{
81
+ name: string;
82
+ description?: string;
83
+ inputSchema: Record<string, unknown>;
84
+ }[]>;
85
+ callTool(name: string, args: unknown, opts?: {
86
+ signal?: AbortSignal;
87
+ }): Promise<McpToolCallResult>;
88
+ close(): Promise<void>;
89
+ }
90
+ /**
91
+ * One Connection per MCP server. Owns lifecycle (lazy-spawn, close, refresh-lock).
92
+ * Refresh-lock pattern adapted from AIWerk `index.ts:219-250` — prevents
93
+ * reconnect + `notifications/tools/list_changed` race.
94
+ */
95
+ declare class Connection {
96
+ readonly name: string;
97
+ readonly config: McpServerConfig;
98
+ private readonly deps;
99
+ private readonly logger;
100
+ private client;
101
+ private tools;
102
+ private connectInFlight;
103
+ private refreshInFlight;
104
+ private refreshQueued;
105
+ private lastUsedAt;
106
+ constructor(params: {
107
+ name: string;
108
+ config: McpServerConfig;
109
+ deps: ConnectionDeps;
110
+ logger?: Logger;
111
+ });
112
+ /** Returns the most recent known tool list. May be empty if the server hasn't connected yet. */
113
+ snapshotTools(): McpToolDescriptor[];
114
+ /** Whether an MCP child process / remote connection has been established. */
115
+ isConnected(): boolean;
116
+ /** Idle timestamp for reaping. */
117
+ idleSinceMs(): number;
118
+ /**
119
+ * Lazy connect + tool discovery. Safe to call concurrently; in-flight
120
+ * connects coalesce.
121
+ */
122
+ ensureConnected(): Promise<void>;
123
+ private connectAndDiscover;
124
+ /**
125
+ * Re-discover tools. Used on reconnect or `tools/list_changed` notification.
126
+ * Refresh-lock collapses concurrent refreshes; if one is in flight, the next
127
+ * is queued (max 1 queued, since N>1 queued provides no extra freshness).
128
+ */
129
+ refresh(): Promise<void>;
130
+ callTool(originalName: string, args: unknown, signal?: AbortSignal): Promise<McpToolCallResult>;
131
+ /**
132
+ * Close the underlying transport. Idempotent. If a connect is in flight
133
+ * (warmup racing with reconcile-removal), wait for it to settle and then
134
+ * close the client it produced — otherwise the child process is orphaned.
135
+ */
136
+ close(): Promise<void>;
137
+ /**
138
+ * Stable hash of the config for diff detection in `reconcile`.
139
+ * Two configs with the same hash are equivalent (no restart needed).
140
+ */
141
+ configFingerprint(): string;
142
+ }
143
+ /**
144
+ * Build the production `connect` factory using the official MCP SDK.
145
+ * Kept in a separate function so tests can substitute a mock without
146
+ * pulling the SDK into the test bundle.
147
+ */
148
+ declare function defaultConnect(server: McpServerConfig): Promise<McpClientHandle>;
149
+ //# sourceMappingURL=connection.d.ts.map
150
+ //#endregion
151
+ //#region src/bundler.d.ts
152
+ /**
153
+ * Provider-agnostic MCP server bundler. Holds N MCP server connections,
154
+ * exposes a unified namespaced tool catalog, and routes calls to the right
155
+ * server.
156
+ *
157
+ * Designed to be embedded in any host (OpenClaw plugin, AI proxy, Lambda).
158
+ * Public surface is intentionally synchronous where the host needs sync
159
+ * (snapshot, listTools), async only where I/O is unavoidable.
160
+ */
161
+ declare class McpBundler {
162
+ private readonly logger;
163
+ private readonly connections;
164
+ private readonly idleTtlMs;
165
+ private readonly idleSweepIntervalMs;
166
+ private idleSweepTimer;
167
+ private readonly deps;
168
+ private disposed;
169
+ private reconcileLatch;
170
+ constructor(opts?: BundlerOptions, deps?: ConnectionDeps);
171
+ /**
172
+ * Diff `desired` against current connections, spawn newcomers, dispose
173
+ * removals, hot-restart on config change. Pull-based — call whenever the
174
+ * host's config snapshot may have changed. Cheap if no diff.
175
+ *
176
+ * Lazy: newly-added servers are NOT eagerly connected; they connect on the
177
+ * first `callTool()` (or first `listTools()` after `forceDiscover()`).
178
+ * This avoids paying spawn cost for servers the agent never uses.
179
+ */
180
+ reconcile(desired: Record<string, McpServerConfig>): Promise<ReconcileDiff>;
181
+ private doReconcile;
182
+ /**
183
+ * Synchronous snapshot of all currently-known tools across connected servers.
184
+ * Servers that have not connected yet contribute nothing. Intended for use
185
+ * inside OpenClaw's plugin tool factory which must be sync.
186
+ *
187
+ * Tool names are namespaced and disambiguated (suffix `-2`, `-3` on collision)
188
+ * so cross-server name clashes never produce duplicate registrations.
189
+ */
190
+ listTools(): McpToolDescriptor[];
191
+ /**
192
+ * Eagerly connect to every configured server and discover tools. Used by
193
+ * hosts that want a hot list rather than the lazy default. Errors are
194
+ * swallowed per-server (logged), so one bad server doesn't fail the batch.
195
+ */
196
+ warmup(): Promise<void>;
197
+ /**
198
+ * Invoke a tool by its namespaced name. Routes to the originating server.
199
+ * Errors are returned as `{ isError: true, content: [...] }` so a failing
200
+ * tool doesn't crash the host.
201
+ */
202
+ callTool(prefixed: string, args: unknown, signal?: AbortSignal): Promise<McpToolCallResult>;
203
+ /**
204
+ * Resolve a namespaced tool name back to its server connection and original
205
+ * tool name. Returns undefined if the tool is not currently advertised.
206
+ */
207
+ private routeToolName;
208
+ /**
209
+ * Tear down all connections and stop background tasks. Idempotent.
210
+ * Call from `registerRuntimeLifecycle({ cleanup })` in the host plugin.
211
+ */
212
+ dispose(): Promise<void>;
213
+ private startIdleSweep;
214
+ private sweepIdle;
215
+ }
216
+ //# sourceMappingURL=bundler.d.ts.map
217
+ //#endregion
218
+ //#region src/tool-naming.d.ts
219
+ /**
220
+ * Tool name sanitization and collision handling.
221
+ *
222
+ * OpenClaw constraint: tool names must match `[A-Za-z0-9_-]` and be ≤64 chars.
223
+ * Pattern mirrored from `openclaw/src/agents/pi-bundle-mcp-names.ts`.
224
+ *
225
+ * Strategy: prefix every tool with its server name (`{server}__{tool}`),
226
+ * sanitize disallowed chars to `_`, truncate, then suffix-disambiguate
227
+ * (`-2`, `-3`, ...) on collision.
228
+ */
229
+ declare function sanitizeNameSegment(value: string): string;
230
+ declare function buildNamespacedToolName(server: string, tool: string): string;
231
+ /**
232
+ * Disambiguate a candidate name against an existing set by appending `-2`, `-3`, etc.
233
+ * Mutates nothing; returns the chosen name. Caller is responsible for inserting it
234
+ * into the set.
235
+ */
236
+ declare function disambiguateAgainst(candidate: string, taken: ReadonlySet<string>): string;
237
+ //# sourceMappingURL=tool-naming.d.ts.map
238
+
239
+ //#endregion
240
+ export { type BundlerOptions, Connection, type ConnectionDeps, type Logger, McpBundler, type McpClientHandle, type McpServerConfig, type McpToolCallResult, type McpToolDescriptor, type McpTransportKind, type ReconcileDiff, type RemoteServerConfig, STDIO_ENV_DENYLIST, type StdioServerConfig, buildNamespacedToolName, defaultConnect, disambiguateAgainst, sanitizeNameSegment, sanitizeStdioEnv };
241
+ //# sourceMappingURL=index.d.cts.map