agent.libx.js 0.93.17 → 0.93.18

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.
@@ -55,18 +55,47 @@ interface MountedMcpLike {
55
55
  callTool(name: string, args: unknown): Promise<unknown>;
56
56
  };
57
57
  }
58
+ /** A flattened catalog entry's origin: which server owns it + the un-prefixed raw tool name. */
59
+ interface McpRoute {
60
+ server: string;
61
+ rawName: string;
62
+ }
63
+ /** Flatten servers' specs into one `mcp__<server>__<tool>` namespace + a display→origin map.
64
+ * Sanitizing/truncating to provider name rules can MERGE distinct names (`a/b` & `a:b` → `a_b`),
65
+ * so dedupe with a numeric suffix — a silent overwrite would orphan a tool. */
66
+ declare function buildMcpCatalog(servers: {
67
+ name: string;
68
+ specs: McpToolSpec[];
69
+ }[]): {
70
+ specs: McpToolSpec[];
71
+ routes: Map<string, McpRoute>;
72
+ };
73
+ /** Resolve a routed MCP call to its result. Throw to surface an error to the model. */
74
+ type McpRouteResolver = (server: string, rawName: string, args: any) => Promise<unknown>;
58
75
  /**
59
76
  * Ergonomic deferred-mount over already-mounted MCP servers: flattens their specs into one
60
- * `mcp__<server>__<tool>` namespace (sanitized to provider name rules) and wires a single
61
- * `ToolSearch`/`McpCall` pair that routes each call to the owning server's RAW `callTool`
62
- * (so result normalization happens exactly once — double-normalizing corrupts image blocks).
63
- * Display names are `mcp__<server>__` prefixed, then deduped (sanitization can merge distinct
64
- * names) so every tool stays uniquely addressable and routed to its own client.
77
+ * `mcp__<server>__<tool>` namespace and wires a single `ToolSearch`/`McpCall` pair that routes
78
+ * each call to the owning server's RAW `callTool` (so result normalization happens exactly once —
79
+ * double-normalizing corrupts image blocks).
65
80
  */
66
81
  declare function makeMcpToolSearchFromMounted(mounted: MountedMcpLike[], options?: McpToolSearchOptions): {
67
82
  tools: AgentTool[];
68
83
  serverNames: string[];
69
84
  toolCount: number;
70
85
  };
86
+ /**
87
+ * Lazy variant: same `ToolSearch`/`McpCall` surface, but the server isn't required to be connected.
88
+ * Each `McpCall` resolves the owning server from the catalog and `resolve(server, rawName, args)`
89
+ * connects-on-demand — so a turn that calls no MCP tool opens ZERO connections. Edge-safe: the
90
+ * caller (`mountMcpCatalog` in `mcp.client.ts`) supplies the node-side connect/pool logic.
91
+ */
92
+ declare function makeLazyMcpToolSearch(servers: {
93
+ name: string;
94
+ specs: McpToolSpec[];
95
+ }[], resolve: McpRouteResolver, options?: McpToolSearchOptions): {
96
+ tools: AgentTool[];
97
+ serverNames: string[];
98
+ toolCount: number;
99
+ };
71
100
 
72
- export { type McpCall as M, type McpImage as a, type McpToolResult as b, type McpToolSearchOptions as c, type McpToolSpec as d, type MountedMcpLike as e, makeMcpToolSearchFromMounted as f, mcpToolToAgentTool as g, mcpToolsToAgentTools as h, makeMcpToolSearch as m };
101
+ export { type McpCall as M, type McpImage as a, type McpRoute as b, type McpRouteResolver as c, type McpToolResult as d, type McpToolSearchOptions as e, type McpToolSpec as f, type MountedMcpLike as g, buildMcpCatalog as h, makeMcpToolSearch as i, makeMcpToolSearchFromMounted as j, mcpToolToAgentTool as k, mcpToolsToAgentTools as l, makeLazyMcpToolSearch as m };
@@ -1,4 +1,4 @@
1
- import { d as McpToolSpec, c as McpToolSearchOptions } from './mcp-DGWuuWJm.js';
1
+ import { e as McpToolSearchOptions, f as McpToolSpec } from './mcp-C5GuDinb.js';
2
2
  import { A as AgentTool } from './tools-GPWp7oXq.js';
3
3
  import '@livx.cc/wcli/core';
4
4
 
@@ -116,21 +116,84 @@ interface MountedMcp {
116
116
  /** Connect one server, discover its tools, and adapt them to prefixed AgentTools (`mcp__<name>__`). */
117
117
  declare function mountMcpServer(name: string, cfg: McpServerConfig): Promise<MountedMcp>;
118
118
  /**
119
- * Mount every configured server. A server that fails (bad command, no handshake, timeout) is
120
- * logged and skipped one broken server can never block the agent from starting.
119
+ * Mount every configured server in PARALLEL (one slow/dead server no longer serializes the rest);
120
+ * each may carry a `mountTimeoutMs` deadline. A server that fails is logged and skipped.
121
121
  */
122
- declare function mountMcpServers(servers?: Record<string, McpServerConfig>): Promise<MountedMcp[]>;
122
+ declare function mountMcpServers(servers?: Record<string, McpServerConfig>, opts?: {
123
+ mountTimeoutMs?: number;
124
+ }): Promise<MountedMcp[]>;
123
125
  /**
124
126
  * One-shot deferred MCP mount: connect every entry (failures dropped, never block startup) and
125
127
  * fold the survivors into a single `ToolSearch`/`McpCall` pair via `makeMcpToolSearchFromMounted`.
126
128
  * The node-only counterpart to that edge-safe helper — returns `mounted` too so callers can close
127
- * the clients on shutdown.
129
+ * the clients on shutdown. Eager: it connects all servers up front. For lazy connect + a cached
130
+ * catalog (zero connections on a turn that uses no MCP tool), use `mountMcpCatalog`.
128
131
  */
129
- declare function mountMcpDeferred(servers?: Record<string, McpServerConfig>, options?: McpToolSearchOptions): Promise<{
132
+ declare function mountMcpDeferred(servers?: Record<string, McpServerConfig>, options?: McpToolSearchOptions & {
133
+ mountTimeoutMs?: number;
134
+ }): Promise<{
130
135
  tools: AgentTool[];
131
136
  serverNames: string[];
132
137
  toolCount: number;
133
138
  mounted: MountedMcp[];
134
139
  }>;
140
+ /** A persistent tool catalog: lets `mountMcpCatalog` build `ToolSearch` WITHOUT connecting when a
141
+ * fresh entry exists. The lib defines the interface; the consumer supplies the store (RAM/disk).
142
+ * Key = a hash of the server's resolved config (see `mcpConfigKey`). TTL/versioning is the store's. */
143
+ interface McpCatalogStore {
144
+ get(key: string): McpToolSpec[] | null;
145
+ set(key: string, specs: McpToolSpec[]): void;
146
+ }
147
+ /** Stable cache key for a server config — command/args/cwd + env NAMES (not secret values), or
148
+ * url + header names. Changing any of these invalidates the cached catalog; rotating a secret does not. */
149
+ declare function mcpConfigKey(cfg: McpServerConfig): string;
150
+ /** Default in-memory catalog: process-lifetime, hash-keyed, TTL-expiring (so a server that gains
151
+ * tools is picked up after the TTL without a restart). Shared module singleton → cross-turn reuse. */
152
+ declare class MemMcpCatalog implements McpCatalogStore {
153
+ private ttlMs;
154
+ private m;
155
+ constructor(ttlMs?: number);
156
+ get(key: string): McpToolSpec[] | null;
157
+ set(key: string, specs: McpToolSpec[]): void;
158
+ }
159
+ /** Opt-in warm pool: keeps stdio MCP clients connected across turns, reaping each after `ttlMs`
160
+ * idle. Most stdio MCPs are per-turn by design, so this is off by default; HTTP servers are
161
+ * stateless and never pooled. */
162
+ declare class McpPool {
163
+ private ttlMs;
164
+ private warm;
165
+ constructor(ttlMs?: number);
166
+ get(key: string): McpClient | null;
167
+ put(key: string, client: McpClient): void;
168
+ private arm;
169
+ private evict;
170
+ closeAll(): Promise<void>;
171
+ }
172
+ interface McpCatalogOptions extends McpToolSearchOptions {
173
+ /** Where to read/write discovered specs. Default: a shared process-lifetime `MemMcpCatalog`. */
174
+ catalog?: McpCatalogStore;
175
+ /** Per-server deadline for the connect+list on a cache MISS. A hung server is skipped, not blocking. */
176
+ mountTimeoutMs?: number;
177
+ /** Opt-in: keep stdio clients warm across turns (see `McpPool`). HTTP servers are never pooled. */
178
+ keepWarm?: boolean;
179
+ /** Warm-client pool. Default: a shared process-lifetime `McpPool` (only used when `keepWarm`). */
180
+ pool?: McpPool;
181
+ }
182
+ /**
183
+ * Lazy + cached MCP mount. Builds the `ToolSearch`/`McpCall` pair from the CACHED catalog when one
184
+ * exists — connecting NOTHING. On a cache miss it connects once (parallel, deadline-bounded), lists,
185
+ * caches, then disconnects (or keeps warm if `keepWarm`). A server is connected only when one of its
186
+ * tools is actually invoked via `McpCall` (memoized per turn; reused from the warm pool if enabled).
187
+ *
188
+ * Per-turn cost: a turn using NO MCP tool → 0 connections; a turn using one → exactly one. Latency
189
+ * scales with tools-used, not servers-configured. Returns `connect`/`close` for explicit control.
190
+ */
191
+ declare function mountMcpCatalog(servers?: Record<string, McpServerConfig>, opts?: McpCatalogOptions): Promise<{
192
+ tools: AgentTool[];
193
+ serverNames: string[];
194
+ toolCount: number;
195
+ connect(name: string): Promise<McpClient>;
196
+ close(): Promise<void>;
197
+ }>;
135
198
 
136
- export { type HttpServerSpec, HttpTransport, McpAuthError, McpClient, type McpServerConfig, type McpTransport, type MountedMcp, type StdioServerSpec, StdioTransport, mountMcpDeferred, mountMcpServer, mountMcpServers };
199
+ export { type HttpServerSpec, HttpTransport, McpAuthError, type McpCatalogOptions, type McpCatalogStore, McpClient, McpPool, type McpServerConfig, type McpTransport, MemMcpCatalog, type MountedMcp, type StdioServerSpec, StdioTransport, mcpConfigKey, mountMcpCatalog, mountMcpDeferred, mountMcpServer, mountMcpServers };
@@ -1,5 +1,6 @@
1
1
  // src/mcp.client.ts
2
2
  import { spawn } from "child_process";
3
+ import { createHash } from "crypto";
3
4
 
4
5
  // src/logging.ts
5
6
  import { log } from "libx.js/src/modules/log";
@@ -147,24 +148,36 @@ function makeMcpToolSearch(specs, callTool, options = {}) {
147
148
  };
148
149
  return [searchTool, callMcpTool];
149
150
  }
150
- function makeMcpToolSearchFromMounted(mounted, options) {
151
+ function buildMcpCatalog(servers) {
151
152
  const specs = [];
152
- const callMap = /* @__PURE__ */ new Map();
153
- for (const m of mounted) {
153
+ const routes = /* @__PURE__ */ new Map();
154
+ for (const m of servers) {
154
155
  for (const s of m.specs) {
155
156
  const base = `mcp__${m.name}__${s.name}`.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 128);
156
157
  let display = base;
157
- for (let i = 2; callMap.has(display); i++) display = `${base.slice(0, 128 - String(i).length - 1)}_${i}`;
158
+ for (let i = 2; routes.has(display); i++) display = `${base.slice(0, 128 - String(i).length - 1)}_${i}`;
158
159
  specs.push({ name: display, description: s.description, inputSchema: s.inputSchema });
159
- callMap.set(display, (_n, args) => m.client.callTool(s.name, args));
160
+ routes.set(display, { server: m.name, rawName: s.name });
160
161
  }
161
162
  }
163
+ return { specs, routes };
164
+ }
165
+ function searchOverCatalog(servers, specs, routes, resolve, options) {
162
166
  const tools = specs.length ? makeMcpToolSearch(specs, (name, args) => {
163
- const call = callMap.get(name);
164
- if (!call) throw new Error(`unknown MCP tool '${name}' \u2014 use ToolSearch to find valid names`);
165
- return call(name, args ?? {});
167
+ const r = routes.get(name);
168
+ if (!r) throw new Error(`unknown MCP tool '${name}' \u2014 use ToolSearch to find valid names`);
169
+ return resolve(r.server, r.rawName, args ?? {});
166
170
  }, options) : [];
167
- return { tools, serverNames: mounted.map((m) => m.name), toolCount: specs.length };
171
+ return { tools, serverNames: servers, toolCount: specs.length };
172
+ }
173
+ function makeMcpToolSearchFromMounted(mounted, options) {
174
+ const { specs, routes } = buildMcpCatalog(mounted);
175
+ const byName = new Map(mounted.map((m) => [m.name, m]));
176
+ return searchOverCatalog(mounted.map((m) => m.name), specs, routes, (server, rawName, args) => byName.get(server).client.callTool(rawName, args), options);
177
+ }
178
+ function makeLazyMcpToolSearch(servers, resolve, options) {
179
+ const { specs, routes } = buildMcpCatalog(servers);
180
+ return searchOverCatalog(servers.map((s) => s.name), specs, routes, resolve, options);
168
181
  }
169
182
 
170
183
  // src/mcp.client.ts
@@ -360,42 +373,210 @@ var McpClient = class {
360
373
  await this.transport.close();
361
374
  }
362
375
  };
363
- async function mountMcpServer(name, cfg) {
364
- const transport = cfg.url ? new HttpTransport({ url: cfg.url, headers: cfg.headers, bearerToken: cfg.bearerToken, timeoutMs: cfg.timeoutMs }) : new StdioTransport({ command: cfg.command, args: cfg.args, env: cfg.env, cwd: cfg.cwd, timeoutMs: cfg.timeoutMs });
365
- const client = new McpClient(transport);
366
- const init = await client.connect();
367
- const specs = await client.listTools();
368
- const tools = mcpToolsToAgentTools(specs, (tool, a) => client.callTool(tool, a), `mcp__${name}__`);
369
- return { name, client, tools, specs, serverInfo: init?.serverInfo };
376
+ function buildTransport(cfg) {
377
+ return cfg.url ? new HttpTransport({ url: cfg.url, headers: cfg.headers, bearerToken: cfg.bearerToken, timeoutMs: cfg.timeoutMs }) : new StdioTransport({ command: cfg.command, args: cfg.args, env: cfg.env, cwd: cfg.cwd, timeoutMs: cfg.timeoutMs });
370
378
  }
371
- async function mountMcpServers(servers = {}) {
372
- const out = [];
373
- for (const [name, cfg] of Object.entries(servers)) {
374
- if (!cfg || cfg.disabled) continue;
379
+ function withTimeout(p, ms, label) {
380
+ if (!ms || ms <= 0) return p;
381
+ return new Promise((resolve, reject) => {
382
+ const timer = setTimeout(() => reject(new Error(`MCP "${label}" mount exceeded ${ms}ms`)), ms);
383
+ timer.unref?.();
384
+ p.then((v) => {
385
+ clearTimeout(timer);
386
+ resolve(v);
387
+ }, (e) => {
388
+ clearTimeout(timer);
389
+ reject(e);
390
+ });
391
+ });
392
+ }
393
+ async function mountWithDeadline(name, cfg, mountTimeoutMs) {
394
+ const client = new McpClient(buildTransport(cfg));
395
+ try {
396
+ return await withTimeout((async () => {
397
+ const init = await client.connect();
398
+ const specs = await client.listTools();
399
+ const tools = mcpToolsToAgentTools(specs, (tool, a) => client.callTool(tool, a), `mcp__${name}__`);
400
+ return { name, client, tools, specs, serverInfo: init?.serverInfo };
401
+ })(), mountTimeoutMs, name);
402
+ } catch (e) {
403
+ await client.close().catch((err) => log2.debug(`close after failed mount of "${name}": ${err}`));
404
+ throw e;
405
+ }
406
+ }
407
+ function mountMcpServer(name, cfg) {
408
+ return mountWithDeadline(name, cfg);
409
+ }
410
+ function validEntries(servers) {
411
+ return Object.entries(servers).filter(([name, cfg]) => {
412
+ if (!cfg || cfg.disabled) return false;
375
413
  if (!cfg.command && !cfg.url) {
376
414
  log2.warn(`MCP server "${name}" needs a command (stdio) or url (http) \u2014 skipping`);
377
- continue;
415
+ return false;
378
416
  }
379
- try {
380
- const m = await mountMcpServer(name, cfg);
381
- out.push(m);
382
- log2.info(`MCP "${name}" mounted \u2014 ${m.tools.length} tool(s)${m.serverInfo?.name ? ` from ${m.serverInfo.name}` : ""}`);
383
- } catch (e) {
384
- if (e instanceof McpAuthError) log2.warn(`MCP "${name}" needs-auth: HTTP ${e.status} \u2014 set bearerToken or headers in its config; skipping`);
385
- else log2.error(`MCP server "${name}" failed to mount: ${e?.message ?? e}`);
386
- }
387
- }
417
+ return true;
418
+ });
419
+ }
420
+ function logMountFailure(name, e) {
421
+ if (e instanceof McpAuthError) log2.warn(`MCP "${name}" needs-auth: HTTP ${e.status} \u2014 set bearerToken or headers in its config; skipping`);
422
+ else log2.error(`MCP server "${name}" failed to mount: ${e?.message ?? e}`);
423
+ }
424
+ async function mountMcpServers(servers = {}, opts = {}) {
425
+ const entries = validEntries(servers);
426
+ const settled = await Promise.allSettled(entries.map(([name, cfg]) => mountWithDeadline(name, cfg, opts.mountTimeoutMs)));
427
+ const out = [];
428
+ settled.forEach((r, i) => {
429
+ const name = entries[i][0];
430
+ if (r.status === "fulfilled") {
431
+ out.push(r.value);
432
+ log2.info(`MCP "${name}" mounted \u2014 ${r.value.tools.length} tool(s)${r.value.serverInfo?.name ? ` from ${r.value.serverInfo.name}` : ""}`);
433
+ } else logMountFailure(name, r.reason);
434
+ });
388
435
  return out;
389
436
  }
390
437
  async function mountMcpDeferred(servers = {}, options) {
391
- const mounted = await mountMcpServers(servers);
438
+ const mounted = await mountMcpServers(servers, { mountTimeoutMs: options?.mountTimeoutMs });
392
439
  return { ...makeMcpToolSearchFromMounted(mounted, options), mounted };
393
440
  }
441
+ function mcpConfigKey(cfg) {
442
+ const parts = cfg.url ? ["http", cfg.url, ...Object.keys(cfg.headers ?? {}).sort()] : ["stdio", cfg.command ?? "", ...cfg.args ?? [], cfg.cwd ?? "", ...Object.keys(cfg.env ?? {}).sort()];
443
+ return createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, 16);
444
+ }
445
+ var MemMcpCatalog = class {
446
+ constructor(ttlMs = 5 * 6e4) {
447
+ this.ttlMs = ttlMs;
448
+ }
449
+ ttlMs;
450
+ m = /* @__PURE__ */ new Map();
451
+ get(key) {
452
+ const e = this.m.get(key);
453
+ if (!e) return null;
454
+ if (Date.now() > e.exp) {
455
+ this.m.delete(key);
456
+ return null;
457
+ }
458
+ return e.specs;
459
+ }
460
+ set(key, specs) {
461
+ this.m.set(key, { specs, exp: Date.now() + this.ttlMs });
462
+ }
463
+ };
464
+ var McpPool = class {
465
+ constructor(ttlMs = 5 * 6e4) {
466
+ this.ttlMs = ttlMs;
467
+ }
468
+ ttlMs;
469
+ warm = /* @__PURE__ */ new Map();
470
+ get(key) {
471
+ const e = this.warm.get(key);
472
+ if (!e) return null;
473
+ this.arm(key, e);
474
+ return e.client;
475
+ }
476
+ put(key, client) {
477
+ const prev = this.warm.get(key);
478
+ if (prev) {
479
+ clearTimeout(prev.timer);
480
+ if (prev.client !== client) void prev.client.close().catch((err) => log2.debug(`warm-pool replace close failed: ${err}`));
481
+ }
482
+ const e = { client, timer: void 0 };
483
+ this.warm.set(key, e);
484
+ this.arm(key, e);
485
+ }
486
+ arm(key, e) {
487
+ clearTimeout(e.timer);
488
+ e.timer = setTimeout(() => {
489
+ void this.evict(key);
490
+ }, this.ttlMs);
491
+ e.timer.unref?.();
492
+ }
493
+ async evict(key) {
494
+ const e = this.warm.get(key);
495
+ if (!e) return;
496
+ this.warm.delete(key);
497
+ await e.client.close().catch((err) => log2.debug(`warm-pool evict close failed: ${err}`));
498
+ }
499
+ async closeAll() {
500
+ for (const e of this.warm.values()) {
501
+ clearTimeout(e.timer);
502
+ await e.client.close().catch(() => {
503
+ });
504
+ }
505
+ this.warm.clear();
506
+ }
507
+ };
508
+ var defaultCatalog = new MemMcpCatalog();
509
+ var defaultPool = new McpPool();
510
+ async function mountMcpCatalog(servers = {}, opts = {}) {
511
+ const catalog = opts.catalog ?? defaultCatalog;
512
+ const pool = opts.pool ?? defaultPool;
513
+ const discovered = (await Promise.all(validEntries(servers).map(async ([name, cfg]) => {
514
+ const key = mcpConfigKey(cfg);
515
+ const cached = catalog.get(key);
516
+ if (cached) return { name, cfg, key, specs: cached };
517
+ const client = new McpClient(buildTransport(cfg));
518
+ try {
519
+ const specs = await withTimeout((async () => {
520
+ await client.connect();
521
+ return client.listTools();
522
+ })(), opts.mountTimeoutMs, name);
523
+ catalog.set(key, specs);
524
+ if (opts.keepWarm && !cfg.url) pool.put(key, client);
525
+ else await client.close().catch(() => {
526
+ });
527
+ return { name, cfg, key, specs };
528
+ } catch (e) {
529
+ await client.close().catch(() => {
530
+ });
531
+ logMountFailure(name, e);
532
+ return null;
533
+ }
534
+ }))).filter(Boolean);
535
+ const byName = new Map(discovered.map((d) => [d.name, d]));
536
+ const inflight = /* @__PURE__ */ new Map();
537
+ const live = [];
538
+ const connect = (name) => {
539
+ let p = inflight.get(name);
540
+ if (p) return p;
541
+ p = (async () => {
542
+ const d = byName.get(name);
543
+ if (!d) throw new Error(`MCP server '${name}' is not in the catalog`);
544
+ const warmable = opts.keepWarm && !d.cfg.url;
545
+ if (warmable) {
546
+ const w = pool.get(d.key);
547
+ if (w) return w;
548
+ }
549
+ const client = new McpClient(buildTransport(d.cfg));
550
+ await client.connect();
551
+ if (warmable) pool.put(d.key, client);
552
+ else live.push(client);
553
+ return client;
554
+ })();
555
+ inflight.set(name, p);
556
+ return p;
557
+ };
558
+ const search = makeLazyMcpToolSearch(
559
+ discovered.map((d) => ({ name: d.name, specs: d.specs })),
560
+ async (server, rawName, args) => (await connect(server)).callTool(rawName, args),
561
+ opts
562
+ );
563
+ const close = async () => {
564
+ for (const c of live) await c.close().catch(() => {
565
+ });
566
+ live.length = 0;
567
+ inflight.clear();
568
+ };
569
+ return { ...search, connect, close };
570
+ }
394
571
  export {
395
572
  HttpTransport,
396
573
  McpAuthError,
397
574
  McpClient,
575
+ McpPool,
576
+ MemMcpCatalog,
398
577
  StdioTransport,
578
+ mcpConfigKey,
579
+ mountMcpCatalog,
399
580
  mountMcpDeferred,
400
581
  mountMcpServer,
401
582
  mountMcpServers