dynmcp 0.3.1 → 0.4.0

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 CHANGED
@@ -29,7 +29,7 @@ var import_commander = require("commander");
29
29
  // package.json
30
30
  var package_default = {
31
31
  name: "dynmcp",
32
- version: "0.3.1",
32
+ version: "0.4.0",
33
33
  description: "Dynamic MCP context management tool for AI MCP-enabled agents and clients.",
34
34
  author: "Brandon Burrus <brandon@burrus.io>",
35
35
  license: "MIT",
@@ -133,8 +133,12 @@ var mcpName = import_zod.z.string().regex(MCP_NAME_PATTERN);
133
133
  var envModeSchema = import_zod.z.enum(["enable", "dotenv", "process", "disable"]).describe(
134
134
  'Controls environment variable interpolation in config values. "enable" (default) merges .env and process.env (.env wins). "dotenv" loads .env only. "process" uses process.env only. "disable" turns interpolation off.'
135
135
  );
136
+ var description = import_zod.z.string().min(1, { message: "description must be a non-empty string" }).refine((value) => value.trim().length > 0, {
137
+ message: "description must not be whitespace-only"
138
+ }).optional();
136
139
  var stdioTransport = import_zod.z.object({
137
140
  transport: import_zod.z.literal("stdio"),
141
+ description,
138
142
  command: import_zod.z.string(),
139
143
  args: import_zod.z.array(import_zod.z.string()).optional(),
140
144
  env: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional()
@@ -144,11 +148,13 @@ var httpUrl = import_zod.z.string().url().refine((u) => u.startsWith("http://")
144
148
  });
145
149
  var streamableHttpTransport = import_zod.z.object({
146
150
  transport: import_zod.z.literal("streamable-http"),
151
+ description,
147
152
  url: httpUrl,
148
153
  headers: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional()
149
154
  }).strict();
150
155
  var sseTransport = import_zod.z.object({
151
156
  transport: import_zod.z.literal("sse"),
157
+ description,
152
158
  url: httpUrl,
153
159
  headers: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional()
154
160
  }).strict();
@@ -421,124 +427,87 @@ function aggregateCapabilities(upstreams) {
421
427
  return aggregated;
422
428
  }
423
429
 
424
- // src/proxy/tool-catalog.ts
425
- var DISCOVER_TOOL_PREAMBLE = `Use this tool to look up the full schema of a tool before calling it with use_tool.
426
- Call discover_tool with a tool name from the list below to get its complete description,
427
- input parameters, and output schema. Always discover a tool before using it.`;
428
- var ToolCatalog = class _ToolCatalog {
429
- tools;
430
- discoverToolDescription;
431
- constructor(tools, description) {
432
- this.tools = tools;
433
- this.discoverToolDescription = description;
434
- }
435
- static fromFlat(upstreamTools) {
436
- const toolMap = /* @__PURE__ */ new Map();
437
- for (const tool of upstreamTools) {
438
- toolMap.set(tool.name, tool);
430
+ // src/proxy/lazy-registry.ts
431
+ var LazyRegistry = class {
432
+ entries = /* @__PURE__ */ new Map();
433
+ failureCounts = /* @__PURE__ */ new Map();
434
+ register(name, entry) {
435
+ if (this.entries.has(name)) {
436
+ throw new Error(`LazyRegistry: duplicate registration for "${name}"`);
439
437
  }
440
- const description = buildFlatDescription(upstreamTools);
441
- return new _ToolCatalog(toolMap, description);
438
+ this.entries.set(name, entry);
442
439
  }
443
- static fromGrouped(groups) {
444
- const toolMap = /* @__PURE__ */ new Map();
445
- for (const [mcpName2, tools] of groups) {
446
- for (const tool of tools) {
447
- toolMap.set(`${mcpName2}/${tool.name}`, tool);
448
- }
449
- }
450
- const description = buildGroupedDescription(groups);
451
- return new _ToolCatalog(toolMap, description);
440
+ has(name) {
441
+ return this.entries.has(name);
452
442
  }
453
- getToolDetails(toolName) {
454
- const tool = this.tools.get(toolName);
455
- if (tool === void 0) {
456
- const sortedNames = [...this.tools.keys()].sort().join(", ");
457
- return `Unknown tool: "${toolName}". Available tools: ${sortedNames}`;
458
- }
459
- return buildToolDetailsString(toolName, tool);
443
+ get(name) {
444
+ return this.entries.get(name);
460
445
  }
461
- };
462
- function buildFlatDescription(tools) {
463
- const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name));
464
- const toolLines = sortedTools.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n");
465
- return `${DISCOVER_TOOL_PREAMBLE}
466
-
467
- <tools>
468
- ${toolLines}
469
- </tools>`;
470
- }
471
- function buildGroupedDescription(groups) {
472
- const sortedMcpNames = [...groups.keys()].sort();
473
- const sections = sortedMcpNames.map((mcpName2) => {
474
- const tools = groups.get(mcpName2);
475
- const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name));
476
- const toolLines = sortedTools.map((tool) => `- ${mcpName2}/${tool.name}: ${tool.description}`).join("\n");
477
- return `${mcpName2}:
478
- ${toolLines}`;
479
- });
480
- return `${DISCOVER_TOOL_PREAMBLE}
481
-
482
- <tools>
483
- ${sections.join("\n\n")}
484
- </tools>`;
485
- }
486
- function buildToolDetailsString(displayName, tool) {
487
- const lines = [
488
- `Tool: ${displayName}`,
489
- `Description: ${tool.description}`,
490
- "",
491
- "Input Schema:",
492
- JSON.stringify(tool.inputSchema, null, 2)
493
- ];
494
- if (tool.outputSchema !== void 0) {
495
- lines.push("", "Output Schema:", JSON.stringify(tool.outputSchema, null, 2));
496
- }
497
- const annotationLines = buildAnnotationLines(tool);
498
- if (annotationLines.length > 0) {
499
- lines.push("", "Annotations:", ...annotationLines);
500
- }
501
- return lines.join("\n");
502
- }
503
- function buildAnnotationLines(tool) {
504
- if (tool.annotations === void 0) {
505
- return [];
446
+ /**
447
+ * Records a failed load attempt and returns the new total. The orchestrator's
448
+ * retry-budget logic uses this to decide whether to evict the entry. A
449
+ * subsequent successful load (or {@link take}) clears the count.
450
+ */
451
+ recordFailure(name) {
452
+ const next = (this.failureCounts.get(name) ?? 0) + 1;
453
+ this.failureCounts.set(name, next);
454
+ return next;
506
455
  }
507
- const { annotations } = tool;
508
- const lines = [];
509
- if (annotations.title !== void 0) {
510
- lines.push(`- title: ${annotations.title}`);
456
+ failureCount(name) {
457
+ return this.failureCounts.get(name) ?? 0;
511
458
  }
512
- if (annotations.readOnlyHint !== void 0) {
513
- lines.push(`- readOnlyHint: ${annotations.readOnlyHint}`);
459
+ /**
460
+ * Returns the descriptions of every still-lazy MCP in insertion order. Consumed
461
+ * by {@link ToolCatalog.fromGroupedWithLazy} to render the `<mcp_servers>` block.
462
+ */
463
+ descriptions() {
464
+ const result = /* @__PURE__ */ new Map();
465
+ for (const [name, entry] of this.entries) {
466
+ result.set(name, entry.description);
467
+ }
468
+ return result;
514
469
  }
515
- if (annotations.destructiveHint !== void 0) {
516
- lines.push(`- destructiveHint: ${annotations.destructiveHint}`);
470
+ /**
471
+ * Returns the names of every still-lazy MCP in insertion order. Used by error
472
+ * messages that need to hint the agent at what is loadable.
473
+ */
474
+ names() {
475
+ return [...this.entries.keys()];
517
476
  }
518
- if (annotations.idempotentHint !== void 0) {
519
- lines.push(`- idempotentHint: ${annotations.idempotentHint}`);
477
+ size() {
478
+ return this.entries.size;
520
479
  }
521
- if (annotations.openWorldHint !== void 0) {
522
- lines.push(`- openWorldHint: ${annotations.openWorldHint}`);
480
+ /**
481
+ * Removes and returns the entry, signalling that the upstream has been (or is
482
+ * about to be) promoted to a connected client (or evicted after exhausting
483
+ * its retry budget). The caller is responsible for ensuring the promotion
484
+ * actually succeeds — failed loads should re-register via {@link register} to
485
+ * roll back the state. Clears the failure count along with the entry.
486
+ */
487
+ take(name) {
488
+ const entry = this.entries.get(name);
489
+ if (entry === void 0) return void 0;
490
+ this.entries.delete(name);
491
+ this.failureCounts.delete(name);
492
+ return entry;
523
493
  }
524
- return lines;
525
- }
494
+ };
526
495
 
527
496
  // src/proxy/notification-forwarder.ts
528
497
  var NotificationForwarder = class {
529
- constructor(registry, resourceRouter, promptRouter, toolsByMcp, setToolCatalog, namespaced) {
498
+ constructor(registry, resourceRouter, promptRouter, toolsByMcp, rebuildToolCatalog, namespaced) {
530
499
  this.registry = registry;
531
500
  this.resourceRouter = resourceRouter;
532
501
  this.promptRouter = promptRouter;
533
502
  this.toolsByMcp = toolsByMcp;
534
- this.setToolCatalog = setToolCatalog;
503
+ this.rebuildToolCatalog = rebuildToolCatalog;
535
504
  this.namespaced = namespaced;
536
505
  }
537
506
  registry;
538
507
  resourceRouter;
539
508
  promptRouter;
540
509
  toolsByMcp;
541
- setToolCatalog;
510
+ rebuildToolCatalog;
542
511
  namespaced;
543
512
  hostHandlers = {};
544
513
  setHostHandlers(handlers) {
@@ -549,8 +518,7 @@ var NotificationForwarder = class {
549
518
  if (client === void 0) return;
550
519
  const tools = await client.listTools().catch(() => []);
551
520
  this.toolsByMcp.set(mcpName2, tools);
552
- const rebuilt = this.namespaced ? ToolCatalog.fromGrouped(this.toolsByMcp) : ToolCatalog.fromFlat([...this.toolsByMcp.values()][0] ?? []);
553
- this.setToolCatalog(rebuilt);
521
+ this.rebuildToolCatalog();
554
522
  await this.hostHandlers.onToolsListChanged?.();
555
523
  }
556
524
  async handleResourcesListChanged(mcpName2) {
@@ -568,6 +536,22 @@ var NotificationForwarder = class {
568
536
  async handleResourceUpdated(params) {
569
537
  await this.hostHandlers.onResourceUpdated?.(params);
570
538
  }
539
+ /**
540
+ * Fire-only emitters. Unlike `handleXListChanged`, these do not re-fetch the
541
+ * affected data from any upstream — the caller has already populated the relevant
542
+ * state directly. Used by the orchestrator's load_mcp pipeline, which already has
543
+ * the freshly-queried tools/resources/prompts in hand and just needs to nudge the
544
+ * host to refetch.
545
+ */
546
+ async notifyToolsListChanged() {
547
+ await this.hostHandlers.onToolsListChanged?.();
548
+ }
549
+ async notifyResourcesListChanged() {
550
+ await this.hostHandlers.onResourcesListChanged?.();
551
+ }
552
+ async notifyPromptsListChanged() {
553
+ await this.hostHandlers.onPromptsListChanged?.();
554
+ }
571
555
  async handlePromptsListChanged(mcpName2) {
572
556
  const router = this.promptRouter();
573
557
  const client = this.registry.get(mcpName2);
@@ -630,6 +614,14 @@ var PromptRouter = class {
630
614
  collisions() {
631
615
  return this.detectedCollisions;
632
616
  }
617
+ /**
618
+ * Returns the prompts contributed by a single upstream MCP. Used by the load_mcp
619
+ * pipeline to construct its structured response. Returns an empty array if the MCP
620
+ * has not contributed any prompts (or if `mcpName` is unknown).
621
+ */
622
+ promptsFor(mcpName2) {
623
+ return this.perMcp.get(mcpName2) ?? [];
624
+ }
633
625
  rebuild() {
634
626
  this.nameOwners = /* @__PURE__ */ new Map();
635
627
  const collisions = [];
@@ -718,6 +710,19 @@ var ResourceRouter = class {
718
710
  collisions() {
719
711
  return this.detectedCollisions;
720
712
  }
713
+ /**
714
+ * Returns the resources contributed by a single upstream MCP. Used by the load_mcp
715
+ * pipeline to construct its structured response, which lists what a just-loaded MCP
716
+ * (or an already-loaded MCP, in the idempotent no-op path) brought to the proxy.
717
+ * Returns an empty array if the MCP has not contributed any resources (or if
718
+ * `mcpName` is unknown).
719
+ */
720
+ resourcesFor(mcpName2) {
721
+ return this.perMcp.get(mcpName2)?.resources ?? [];
722
+ }
723
+ templatesFor(mcpName2) {
724
+ return this.perMcp.get(mcpName2)?.templates ?? [];
725
+ }
721
726
  rebuild() {
722
727
  this.uriOwners = /* @__PURE__ */ new Map();
723
728
  this.templateOwners = [];
@@ -749,6 +754,145 @@ function literalPrefixOf(uriTemplate) {
749
754
  return idx === -1 ? uriTemplate : uriTemplate.slice(0, idx);
750
755
  }
751
756
 
757
+ // src/proxy/tool-catalog.ts
758
+ var DISCOVER_TOOL_PREAMBLE = `Use this tool to look up the full schema of a tool before calling it with use_tool.
759
+ Call discover_tool with a tool name from the list below to get its complete description,
760
+ input parameters, and output schema. Always discover a tool before using it.`;
761
+ var DYNAMIC_DISCOVERY_PREAMBLE = `Some MCP servers below are not loaded yet and are listed under <mcp_servers> with a
762
+ short description of what they do. To make a server's tools (and any resources or
763
+ prompts it exposes) available, call load_mcp with its name. Once loaded, the server's
764
+ tools will appear in the <tools> list and become callable via use_tool. Loading is
765
+ permanent for the remainder of this session.`;
766
+ var NO_TOOLS_LOADED_FOOTER = "No tools are currently loaded. Call load_mcp to make a server's tools available.";
767
+ var ToolCatalog = class _ToolCatalog {
768
+ tools;
769
+ discoverToolDescription;
770
+ constructor(tools, description2) {
771
+ this.tools = tools;
772
+ this.discoverToolDescription = description2;
773
+ }
774
+ static fromFlat(upstreamTools) {
775
+ const toolMap = /* @__PURE__ */ new Map();
776
+ for (const tool of upstreamTools) {
777
+ toolMap.set(tool.name, tool);
778
+ }
779
+ const description2 = buildFlatDescription(upstreamTools);
780
+ return new _ToolCatalog(toolMap, description2);
781
+ }
782
+ static fromGrouped(groups) {
783
+ return _ToolCatalog.fromGroupedWithLazy(groups, /* @__PURE__ */ new Map());
784
+ }
785
+ /**
786
+ * Same as {@link fromGrouped} but additionally accepts a map of lazy upstream MCPs
787
+ * (those declared with a `description` field but not yet loaded). When the map is
788
+ * non-empty, the rendered `discover_tool` description includes a `<mcp_servers>`
789
+ * block listing them with their descriptions and an explanatory paragraph telling
790
+ * the agent how to call `load_mcp`. When `groups` is empty, the `<tools>` block is
791
+ * omitted in favor of a trailing sentence directing the agent to `load_mcp`.
792
+ */
793
+ static fromGroupedWithLazy(groups, lazyDescriptions) {
794
+ const toolMap = /* @__PURE__ */ new Map();
795
+ for (const [mcpName2, tools] of groups) {
796
+ for (const tool of tools) {
797
+ toolMap.set(`${mcpName2}/${tool.name}`, tool);
798
+ }
799
+ }
800
+ const description2 = buildGroupedDescription(groups, lazyDescriptions);
801
+ return new _ToolCatalog(toolMap, description2);
802
+ }
803
+ getToolDetails(toolName) {
804
+ const tool = this.tools.get(toolName);
805
+ if (tool === void 0) {
806
+ const sortedNames = [...this.tools.keys()].sort().join(", ");
807
+ return `Unknown tool: "${toolName}". Available tools: ${sortedNames}`;
808
+ }
809
+ return buildToolDetailsString(toolName, tool);
810
+ }
811
+ };
812
+ function buildFlatDescription(tools) {
813
+ const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name));
814
+ const toolLines = sortedTools.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n");
815
+ return `${DISCOVER_TOOL_PREAMBLE}
816
+
817
+ <tools>
818
+ ${toolLines}
819
+ </tools>`;
820
+ }
821
+ function buildGroupedDescription(groups, lazyDescriptions) {
822
+ const parts = [DISCOVER_TOOL_PREAMBLE];
823
+ if (lazyDescriptions.size > 0) {
824
+ parts.push(DYNAMIC_DISCOVERY_PREAMBLE);
825
+ parts.push(buildMcpServersBlock(lazyDescriptions));
826
+ }
827
+ if (groups.size > 0) {
828
+ parts.push(buildToolsBlock(groups));
829
+ } else if (lazyDescriptions.size > 0) {
830
+ parts.push(NO_TOOLS_LOADED_FOOTER);
831
+ } else {
832
+ parts.push("<tools>\n</tools>");
833
+ }
834
+ return parts.join("\n\n");
835
+ }
836
+ function buildToolsBlock(groups) {
837
+ const sortedMcpNames = [...groups.keys()].sort();
838
+ const sections = sortedMcpNames.map((mcpName2) => {
839
+ const tools = groups.get(mcpName2);
840
+ const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name));
841
+ const toolLines = sortedTools.map((tool) => `- ${mcpName2}/${tool.name}: ${tool.description}`).join("\n");
842
+ return `${mcpName2}:
843
+ ${toolLines}`;
844
+ });
845
+ return `<tools>
846
+ ${sections.join("\n\n")}
847
+ </tools>`;
848
+ }
849
+ function buildMcpServersBlock(lazyDescriptions) {
850
+ const lines = [...lazyDescriptions].map(([name, desc]) => `- ${name}: ${desc}`).join("\n");
851
+ return `<mcp_servers>
852
+ ${lines}
853
+ </mcp_servers>`;
854
+ }
855
+ function buildToolDetailsString(displayName, tool) {
856
+ const lines = [
857
+ `Tool: ${displayName}`,
858
+ `Description: ${tool.description}`,
859
+ "",
860
+ "Input Schema:",
861
+ JSON.stringify(tool.inputSchema, null, 2)
862
+ ];
863
+ if (tool.outputSchema !== void 0) {
864
+ lines.push("", "Output Schema:", JSON.stringify(tool.outputSchema, null, 2));
865
+ }
866
+ const annotationLines = buildAnnotationLines(tool);
867
+ if (annotationLines.length > 0) {
868
+ lines.push("", "Annotations:", ...annotationLines);
869
+ }
870
+ return lines.join("\n");
871
+ }
872
+ function buildAnnotationLines(tool) {
873
+ if (tool.annotations === void 0) {
874
+ return [];
875
+ }
876
+ const { annotations } = tool;
877
+ const lines = [];
878
+ if (annotations.title !== void 0) {
879
+ lines.push(`- title: ${annotations.title}`);
880
+ }
881
+ if (annotations.readOnlyHint !== void 0) {
882
+ lines.push(`- readOnlyHint: ${annotations.readOnlyHint}`);
883
+ }
884
+ if (annotations.destructiveHint !== void 0) {
885
+ lines.push(`- destructiveHint: ${annotations.destructiveHint}`);
886
+ }
887
+ if (annotations.idempotentHint !== void 0) {
888
+ lines.push(`- idempotentHint: ${annotations.idempotentHint}`);
889
+ }
890
+ if (annotations.openWorldHint !== void 0) {
891
+ lines.push(`- openWorldHint: ${annotations.openWorldHint}`);
892
+ }
893
+ return lines;
894
+ }
895
+
752
896
  // src/proxy/upstream-client.ts
753
897
  var import_node_process3 = __toESM(require("process"), 1);
754
898
  var import_client = require("@modelcontextprotocol/sdk/client/index.js");
@@ -974,6 +1118,41 @@ var UpstreamRegistry = class {
974
1118
  throw error;
975
1119
  }
976
1120
  }
1121
+ /**
1122
+ * Connects a single additional upstream. Unlike {@link connectAll}, a failure here
1123
+ * leaves any other connected clients untouched — the caller (typically the
1124
+ * Orchestrator's `loadMcp` pipeline) needs that isolation because other lazy MCPs
1125
+ * may have already been promoted to loaded, or eager MCPs are still healthy.
1126
+ *
1127
+ * Throws if `mcpName` is already in the registry; callers should check beforehand
1128
+ * (the orchestrator handles the idempotency-success case before reaching here).
1129
+ */
1130
+ async connectOne(mcpName2, config) {
1131
+ if (this.clients.has(mcpName2)) {
1132
+ throw new Error(`UpstreamRegistry: "${mcpName2}" is already connected`);
1133
+ }
1134
+ const client = new UpstreamClient({
1135
+ name: mcpName2,
1136
+ transport: config.transport,
1137
+ onTransportError: config.onTransportError,
1138
+ notifications: config.notifications,
1139
+ serverRequests: config.serverRequests
1140
+ });
1141
+ await client.connect();
1142
+ this.clients.set(mcpName2, client);
1143
+ return client;
1144
+ }
1145
+ /**
1146
+ * Disconnects and removes a single upstream. Used by `loadMcp` to roll back a
1147
+ * partially-loaded MCP when a post-connect catalog query fails. No-op if the name
1148
+ * is not present.
1149
+ */
1150
+ async deleteOne(mcpName2) {
1151
+ const client = this.clients.get(mcpName2);
1152
+ if (client === void 0) return;
1153
+ this.clients.delete(mcpName2);
1154
+ await client.disconnect();
1155
+ }
977
1156
  get(mcpName2) {
978
1157
  return this.clients.get(mcpName2);
979
1158
  }
@@ -1003,10 +1182,18 @@ var UpstreamRegistry = class {
1003
1182
  };
1004
1183
 
1005
1184
  // src/proxy/orchestrator.ts
1185
+ var MAX_LOAD_ATTEMPTS = 3;
1006
1186
  var Orchestrator = class {
1007
1187
  config;
1008
1188
  registry = new UpstreamRegistry();
1189
+ lazyRegistry = new LazyRegistry();
1009
1190
  toolsByMcp = /* @__PURE__ */ new Map();
1191
+ /**
1192
+ * In-flight loads, keyed by mcpName. Used by {@link loadMcp} to coalesce concurrent
1193
+ * calls for the same name onto a single underlying connection attempt — per SPEC.md
1194
+ * § "Dynamic Discovery > Concurrency".
1195
+ */
1196
+ inFlightLoads = /* @__PURE__ */ new Map();
1010
1197
  resourceRouter = null;
1011
1198
  promptRouter = null;
1012
1199
  toolCatalog = null;
@@ -1014,9 +1201,14 @@ var Orchestrator = class {
1014
1201
  serverRequestForwarders = {};
1015
1202
  forwarder;
1016
1203
  constructor(config) {
1017
- if (!config.namespaced && config.mcps.size !== 1) {
1204
+ if (!config.namespaced && config.eagerMcps.size !== 1) {
1205
+ throw new Error(
1206
+ `Single-MCP (non-namespaced) mode requires exactly one upstream; got ${config.eagerMcps.size}.`
1207
+ );
1208
+ }
1209
+ if (!config.namespaced && config.lazyMcps !== void 0 && config.lazyMcps.size > 0) {
1018
1210
  throw new Error(
1019
- `Single-MCP (non-namespaced) mode requires exactly one upstream; got ${config.mcps.size}.`
1211
+ "Single-MCP (non-namespaced) mode does not support lazy upstreams (descriptions)."
1020
1212
  );
1021
1213
  }
1022
1214
  this.config = config;
@@ -1025,9 +1217,7 @@ var Orchestrator = class {
1025
1217
  () => this.resourceRouter,
1026
1218
  () => this.promptRouter,
1027
1219
  this.toolsByMcp,
1028
- (catalog) => {
1029
- this.toolCatalog = catalog;
1030
- },
1220
+ () => this.rebuildToolCatalog(),
1031
1221
  this.config.namespaced
1032
1222
  );
1033
1223
  }
@@ -1037,32 +1227,26 @@ var Orchestrator = class {
1037
1227
  setServerRequestForwarders(forwarders) {
1038
1228
  this.serverRequestForwarders = forwarders;
1039
1229
  }
1230
+ /**
1231
+ * True when the orchestrator was configured with at least one lazy upstream MCP.
1232
+ * The `index.ts` wiring uses this to decide whether to register the `load_mcp`
1233
+ * meta-tool with the host-facing {@link ProxyServer}.
1234
+ */
1235
+ get hasDynamicDiscovery() {
1236
+ return this.config.lazyMcps !== void 0 && this.config.lazyMcps.size > 0;
1237
+ }
1040
1238
  async connect() {
1041
- const resourceRouter = new ResourceRouter([...this.config.mcps.keys()]);
1042
- const promptRouter = new PromptRouter([...this.config.mcps.keys()]);
1043
- const upstreamEntries = [
1044
- ...this.config.mcps
1045
- ].map(([mcpName2, { transport }]) => [
1046
- mcpName2,
1047
- {
1048
- transport,
1049
- onTransportError: (error) => {
1050
- this.config.onTransportError?.(mcpName2, error);
1051
- },
1052
- notifications: {
1053
- onToolsListChanged: () => this.forwarder.handleToolsListChanged(mcpName2),
1054
- onResourcesListChanged: () => this.forwarder.handleResourcesListChanged(mcpName2),
1055
- onResourceUpdated: (params) => this.forwarder.handleResourceUpdated(params),
1056
- onPromptsListChanged: () => this.forwarder.handlePromptsListChanged(mcpName2),
1057
- onLogMessage: (params) => this.forwarder.handleLogMessage(mcpName2, params)
1058
- },
1059
- serverRequests: {
1060
- onCreateMessage: (params, opts) => this.forwardCreateMessage(params, opts),
1061
- onElicitInput: (params, opts) => this.forwardElicitInput(params, opts),
1062
- onListRoots: (params, opts) => this.forwardListRoots(params, opts)
1063
- }
1239
+ const allNames = [...this.config.eagerMcps.keys(), ...this.config.lazyMcps?.keys() ?? []];
1240
+ const resourceRouter = new ResourceRouter(allNames);
1241
+ const promptRouter = new PromptRouter(allNames);
1242
+ if (this.config.lazyMcps !== void 0) {
1243
+ for (const [name, { transport, description: description2 }] of this.config.lazyMcps) {
1244
+ this.lazyRegistry.register(name, { transport, description: description2 });
1064
1245
  }
1065
- ]);
1246
+ }
1247
+ const upstreamEntries = [
1248
+ ...this.config.eagerMcps
1249
+ ].map(([mcpName2, { transport }]) => [mcpName2, this.buildUpstreamConfig(mcpName2, transport)]);
1066
1250
  await this.registry.connectAll(upstreamEntries);
1067
1251
  const capabilityList = [];
1068
1252
  this.toolsByMcp.clear();
@@ -1084,10 +1268,10 @@ var Orchestrator = class {
1084
1268
  promptRouter.setPrompts(mcpName2, prompts);
1085
1269
  }
1086
1270
  }
1087
- this.toolCatalog = this.config.namespaced ? ToolCatalog.fromGrouped(this.toolsByMcp) : ToolCatalog.fromFlat([...this.toolsByMcp.values()][0] ?? []);
1088
- this.aggregatedCapabilities = aggregateCapabilities(capabilityList);
1089
1271
  this.resourceRouter = resourceRouter;
1090
1272
  this.promptRouter = promptRouter;
1273
+ this.aggregatedCapabilities = aggregateCapabilities(capabilityList);
1274
+ this.rebuildToolCatalog();
1091
1275
  logCollisions(resourceRouter, promptRouter);
1092
1276
  }
1093
1277
  async disconnectAll() {
@@ -1097,6 +1281,177 @@ var Orchestrator = class {
1097
1281
  this.aggregatedCapabilities = null;
1098
1282
  this.resourceRouter = null;
1099
1283
  this.promptRouter = null;
1284
+ this.inFlightLoads.clear();
1285
+ }
1286
+ // === Dynamic discovery ===
1287
+ /**
1288
+ * Loads a lazy upstream MCP on demand. Implements the semantics of SPEC.md §
1289
+ * "Tools > load_mcp" and § "Dynamic Discovery > Lifecycle of a Lazy MCP":
1290
+ *
1291
+ * - Already-loaded (eager or previously-loaded lazy) names succeed as a no-op
1292
+ * returning the current listing; no notifications fire.
1293
+ * - Unknown names throw an error that hints at the still-lazy alternatives.
1294
+ * - Concurrent calls for the same name coalesce onto the same in-flight load.
1295
+ * - A failure during connect/initialize/catalog-query rolls back atomically:
1296
+ * the upstream is disconnected, the lazy entry stays registered, no host
1297
+ * notifications fire.
1298
+ * - On success the loaded MCP is promoted into the connected registry, its
1299
+ * tools/resources/prompts populate the routers, the discover_tool catalog
1300
+ * is regenerated, and the host receives `tools/list_changed` plus
1301
+ * `resources/list_changed` and/or `prompts/list_changed` for any non-empty
1302
+ * surface the MCP contributed.
1303
+ */
1304
+ async loadMcp(mcpName2) {
1305
+ if (this.registry.get(mcpName2) !== void 0) {
1306
+ return this.getListing(mcpName2);
1307
+ }
1308
+ const inFlight = this.inFlightLoads.get(mcpName2);
1309
+ if (inFlight !== void 0) {
1310
+ return inFlight;
1311
+ }
1312
+ if (!this.lazyRegistry.has(mcpName2)) {
1313
+ const lazyNames = this.lazyRegistry.names().join(", ");
1314
+ const hint = lazyNames.length > 0 ? lazyNames : "(none)";
1315
+ throw new Error(`Unknown MCP server: "${mcpName2}". Available servers to load: ${hint}`);
1316
+ }
1317
+ const loadPromise = this.runLoadPipeline(mcpName2).finally(() => {
1318
+ this.inFlightLoads.delete(mcpName2);
1319
+ });
1320
+ this.inFlightLoads.set(mcpName2, loadPromise);
1321
+ return loadPromise;
1322
+ }
1323
+ async runLoadPipeline(mcpName2) {
1324
+ const entry = this.lazyRegistry.get(mcpName2);
1325
+ if (entry === void 0) {
1326
+ throw new Error(`Internal error: lazy entry "${mcpName2}" vanished mid-load.`);
1327
+ }
1328
+ const client = await this.registry.connectOne(
1329
+ mcpName2,
1330
+ this.buildUpstreamConfig(mcpName2, entry.transport)
1331
+ );
1332
+ let tools;
1333
+ let resources = [];
1334
+ let templates = [];
1335
+ let prompts = [];
1336
+ let caps;
1337
+ try {
1338
+ caps = client.getCapabilities();
1339
+ tools = await client.listTools();
1340
+ if (caps?.resources !== void 0) {
1341
+ [resources, templates] = await Promise.all([
1342
+ client.listResources(),
1343
+ client.listResourceTemplates()
1344
+ ]);
1345
+ }
1346
+ if (caps?.prompts !== void 0) {
1347
+ prompts = await client.listPrompts();
1348
+ }
1349
+ } catch (error) {
1350
+ await this.registry.deleteOne(mcpName2);
1351
+ const failures = this.lazyRegistry.recordFailure(mcpName2);
1352
+ if (failures >= MAX_LOAD_ATTEMPTS) {
1353
+ this.lazyRegistry.take(mcpName2);
1354
+ this.rebuildToolCatalog();
1355
+ await this.forwarder.notifyToolsListChanged();
1356
+ const base = error instanceof Error ? error.message : String(error);
1357
+ throw new Error(
1358
+ `Failed to load "${mcpName2}" after ${failures} attempts; the server will no longer be offered for discovery. Underlying error: ${base}`
1359
+ );
1360
+ }
1361
+ throw error;
1362
+ }
1363
+ this.lazyRegistry.take(mcpName2);
1364
+ this.toolsByMcp.set(mcpName2, tools);
1365
+ const resourceRouter = this.requireResourceRouter();
1366
+ const promptRouter = this.requirePromptRouter();
1367
+ if (caps?.resources !== void 0) {
1368
+ resourceRouter.setResources(mcpName2, resources);
1369
+ resourceRouter.setTemplates(mcpName2, templates);
1370
+ }
1371
+ if (caps?.prompts !== void 0) {
1372
+ promptRouter.setPrompts(mcpName2, prompts);
1373
+ }
1374
+ this.rebuildToolCatalog();
1375
+ await this.forwarder.notifyToolsListChanged();
1376
+ if (caps?.resources !== void 0 && (resources.length > 0 || templates.length > 0)) {
1377
+ await this.forwarder.notifyResourcesListChanged();
1378
+ }
1379
+ if (caps?.prompts !== void 0 && prompts.length > 0) {
1380
+ await this.forwarder.notifyPromptsListChanged();
1381
+ }
1382
+ return this.getListing(mcpName2);
1383
+ }
1384
+ /**
1385
+ * Builds the structured response shape documented for `load_mcp` from existing
1386
+ * per-MCP state. Pulled out into a helper so the no-op path (already-loaded)
1387
+ * and the success path share one source of truth.
1388
+ */
1389
+ getListing(mcpName2) {
1390
+ const tools = this.toolsByMcp.get(mcpName2) ?? [];
1391
+ const namespacedToolName = (name) => this.config.namespaced ? `${mcpName2}/${name}` : name;
1392
+ const resources = this.resourceRouter?.resourcesFor(mcpName2) ?? [];
1393
+ const templates = this.resourceRouter?.templatesFor(mcpName2) ?? [];
1394
+ const prompts = this.promptRouter?.promptsFor(mcpName2) ?? [];
1395
+ return {
1396
+ mcp_name: mcpName2,
1397
+ tools: tools.map((tool) => ({
1398
+ name: namespacedToolName(tool.name),
1399
+ description: tool.description
1400
+ })),
1401
+ resources: resources.map((resource) => ({
1402
+ uri: resource.uri,
1403
+ name: resource.name,
1404
+ description: resource.description,
1405
+ mimeType: resource.mimeType
1406
+ })),
1407
+ resource_templates: templates.map((template) => ({
1408
+ uriTemplate: template.uriTemplate,
1409
+ name: template.name,
1410
+ description: template.description,
1411
+ mimeType: template.mimeType
1412
+ })),
1413
+ prompts: prompts.map((prompt) => ({
1414
+ name: prompt.name,
1415
+ description: prompt.description,
1416
+ arguments: prompt.arguments
1417
+ }))
1418
+ };
1419
+ }
1420
+ // === Internal helpers used by connect() and loadMcp() ===
1421
+ buildUpstreamConfig(mcpName2, transport) {
1422
+ return {
1423
+ transport,
1424
+ onTransportError: (error) => {
1425
+ this.config.onTransportError?.(mcpName2, error);
1426
+ },
1427
+ notifications: {
1428
+ onToolsListChanged: () => this.forwarder.handleToolsListChanged(mcpName2),
1429
+ onResourcesListChanged: () => this.forwarder.handleResourcesListChanged(mcpName2),
1430
+ onResourceUpdated: (params) => this.forwarder.handleResourceUpdated(params),
1431
+ onPromptsListChanged: () => this.forwarder.handlePromptsListChanged(mcpName2),
1432
+ onLogMessage: (params) => this.forwarder.handleLogMessage(mcpName2, params)
1433
+ },
1434
+ serverRequests: {
1435
+ onCreateMessage: (params, opts) => this.forwardCreateMessage(params, opts),
1436
+ onElicitInput: (params, opts) => this.forwardElicitInput(params, opts),
1437
+ onListRoots: (params, opts) => this.forwardListRoots(params, opts)
1438
+ }
1439
+ };
1440
+ }
1441
+ /**
1442
+ * Rebuilds the `ToolCatalog` from current state. Called whenever `toolsByMcp` or the
1443
+ * lazy-registry membership changes — including initial connect, upstream-emitted
1444
+ * `tools/list_changed`, and successful `loadMcp`.
1445
+ */
1446
+ rebuildToolCatalog() {
1447
+ if (this.config.namespaced) {
1448
+ this.toolCatalog = ToolCatalog.fromGroupedWithLazy(
1449
+ this.toolsByMcp,
1450
+ this.lazyRegistry.descriptions()
1451
+ );
1452
+ } else {
1453
+ this.toolCatalog = ToolCatalog.fromFlat([...this.toolsByMcp.values()][0] ?? []);
1454
+ }
1100
1455
  }
1101
1456
  get catalog() {
1102
1457
  if (this.toolCatalog === null) {
@@ -1285,7 +1640,9 @@ var import_types2 = require("@modelcontextprotocol/sdk/types.js");
1285
1640
  var import_zod3 = require("zod");
1286
1641
  var DISCOVER_TOOL_NAME = "discover_tool";
1287
1642
  var USE_TOOL_NAME = "use_tool";
1643
+ var LOAD_MCP_NAME = "load_mcp";
1288
1644
  var USE_TOOL_DESCRIPTION = "Use a tool that was previously discovered with the discover_tool tool.";
1645
+ var LOAD_MCP_DESCRIPTION = "Load a previously-deferred MCP server so that its tools, resources, and prompts become available. Pass the server name as shown in the <mcp_servers> block of the discover_tool description. Loading is permanent for the remainder of this session.";
1289
1646
  var DISCOVER_TOOL_INPUT_SCHEMA = {
1290
1647
  type: "object",
1291
1648
  properties: {
@@ -1301,11 +1658,19 @@ var USE_TOOL_INPUT_SCHEMA = {
1301
1658
  },
1302
1659
  required: ["tool_name"]
1303
1660
  };
1661
+ var LOAD_MCP_INPUT_SCHEMA = {
1662
+ type: "object",
1663
+ properties: {
1664
+ mcp_name: { type: "string" }
1665
+ },
1666
+ required: ["mcp_name"]
1667
+ };
1304
1668
  var DiscoverToolArgsSchema = import_zod3.z.object({ tool_name: import_zod3.z.string() });
1305
1669
  var UseToolArgsSchema = import_zod3.z.object({
1306
1670
  tool_name: import_zod3.z.string(),
1307
1671
  tool_input: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()).default({})
1308
1672
  });
1673
+ var LoadMcpArgsSchema = import_zod3.z.object({ mcp_name: import_zod3.z.string() });
1309
1674
  var ProxyServer = class {
1310
1675
  catalog;
1311
1676
  callTool;
@@ -1315,6 +1680,7 @@ var ProxyServer = class {
1315
1680
  complete;
1316
1681
  setLoggingLevelCallback;
1317
1682
  onRootsListChangedCallback;
1683
+ loadMcpCallback;
1318
1684
  sdkServer = null;
1319
1685
  constructor({
1320
1686
  catalog,
@@ -1324,7 +1690,8 @@ var ProxyServer = class {
1324
1690
  prompts,
1325
1691
  complete,
1326
1692
  setLoggingLevel,
1327
- onRootsListChanged
1693
+ onRootsListChanged,
1694
+ loadMcp
1328
1695
  }) {
1329
1696
  this.catalog = catalog;
1330
1697
  this.callTool = callTool;
@@ -1334,6 +1701,7 @@ var ProxyServer = class {
1334
1701
  this.complete = complete;
1335
1702
  this.setLoggingLevelCallback = setLoggingLevel;
1336
1703
  this.onRootsListChangedCallback = onRootsListChanged;
1704
+ this.loadMcpCallback = loadMcp;
1337
1705
  }
1338
1706
  buildServer() {
1339
1707
  const server = new import_server.Server(
@@ -1466,8 +1834,8 @@ var ProxyServer = class {
1466
1834
  return options;
1467
1835
  }
1468
1836
  registerToolHandlers(server) {
1469
- server.setRequestHandler(import_types2.ListToolsRequestSchema, async () => ({
1470
- tools: [
1837
+ server.setRequestHandler(import_types2.ListToolsRequestSchema, async () => {
1838
+ const tools = [
1471
1839
  {
1472
1840
  name: DISCOVER_TOOL_NAME,
1473
1841
  description: this.catalog().discoverToolDescription,
@@ -1478,8 +1846,16 @@ var ProxyServer = class {
1478
1846
  description: USE_TOOL_DESCRIPTION,
1479
1847
  inputSchema: USE_TOOL_INPUT_SCHEMA
1480
1848
  }
1481
- ]
1482
- }));
1849
+ ];
1850
+ if (this.loadMcpCallback !== void 0) {
1851
+ tools.push({
1852
+ name: LOAD_MCP_NAME,
1853
+ description: LOAD_MCP_DESCRIPTION,
1854
+ inputSchema: LOAD_MCP_INPUT_SCHEMA
1855
+ });
1856
+ }
1857
+ return { tools };
1858
+ });
1483
1859
  server.setRequestHandler(
1484
1860
  import_types2.CallToolRequestSchema,
1485
1861
  async (request, extra) => {
@@ -1504,6 +1880,25 @@ var ProxyServer = class {
1504
1880
  this.buildCallOptions(request, extra)
1505
1881
  );
1506
1882
  }
1883
+ if (name === LOAD_MCP_NAME && this.loadMcpCallback !== void 0) {
1884
+ const args = LoadMcpArgsSchema.parse(rawArgs ?? {});
1885
+ try {
1886
+ const result = await this.loadMcpCallback(args.mcp_name);
1887
+ return {
1888
+ // The structured payload is JSON-serialized into a text block. We also
1889
+ // populate `structuredContent` so MCP clients that prefer typed data can
1890
+ // consume the same response without parsing the text body.
1891
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1892
+ structuredContent: result
1893
+ };
1894
+ } catch (error) {
1895
+ const message = error instanceof Error ? error.message : String(error);
1896
+ return {
1897
+ isError: true,
1898
+ content: [{ type: "text", text: message }]
1899
+ };
1900
+ }
1901
+ }
1507
1902
  return {
1508
1903
  isError: true,
1509
1904
  content: [{ type: "text", text: `Unknown tool: "${name}"` }]
@@ -1618,9 +2013,9 @@ function createTransport(config) {
1618
2013
  var SINGLE_MCP_NAME = "__default__";
1619
2014
  async function startProxy(command, args) {
1620
2015
  const transport = new import_stdio3.StdioClientTransport({ command, args });
1621
- const mcps = /* @__PURE__ */ new Map([[SINGLE_MCP_NAME, { transport }]]);
2016
+ const eagerMcps = /* @__PURE__ */ new Map([[SINGLE_MCP_NAME, { transport }]]);
1622
2017
  const orchestrator = buildOrchestrator({
1623
- mcps,
2018
+ eagerMcps,
1624
2019
  namespaced: false,
1625
2020
  transportErrorPrefix: () => "Upstream MCP"
1626
2021
  });
@@ -1628,12 +2023,19 @@ async function startProxy(command, args) {
1628
2023
  }
1629
2024
  async function startProxyFromConfig(options = {}) {
1630
2025
  const config = loadConfig(options);
1631
- const mcps = /* @__PURE__ */ new Map();
2026
+ const eagerMcps = /* @__PURE__ */ new Map();
2027
+ const lazyMcps = /* @__PURE__ */ new Map();
1632
2028
  for (const [name, entry] of Object.entries(config.mcp)) {
1633
- mcps.set(name, { transport: createTransport(entry) });
2029
+ const transport = createTransport(entry);
2030
+ if (entry.description !== void 0) {
2031
+ lazyMcps.set(name, { transport, description: entry.description });
2032
+ } else {
2033
+ eagerMcps.set(name, { transport });
2034
+ }
1634
2035
  }
1635
2036
  const orchestrator = buildOrchestrator({
1636
- mcps,
2037
+ eagerMcps,
2038
+ lazyMcps,
1637
2039
  namespaced: true,
1638
2040
  transportErrorPrefix: (mcpName2) => `Upstream MCP "${mcpName2}"`
1639
2041
  });
@@ -1642,7 +2044,8 @@ async function startProxyFromConfig(options = {}) {
1642
2044
  var activeShutdown = { shutdown: null };
1643
2045
  function buildOrchestrator(params) {
1644
2046
  return new Orchestrator({
1645
- mcps: params.mcps,
2047
+ eagerMcps: params.eagerMcps,
2048
+ lazyMcps: params.lazyMcps,
1646
2049
  namespaced: params.namespaced,
1647
2050
  onTransportError: (mcpName2, error) => {
1648
2051
  import_node_process6.default.stderr.write(
@@ -1691,7 +2094,10 @@ async function runProxy(orchestrator) {
1691
2094
  } : void 0,
1692
2095
  complete: orchestrator.capabilities.completions !== void 0 ? (params, options) => orchestrator.complete(params, options) : void 0,
1693
2096
  setLoggingLevel: orchestrator.capabilities.logging !== void 0 ? (level, options) => orchestrator.setLoggingLevel(level, options) : void 0,
1694
- onRootsListChanged: () => orchestrator.broadcastRootsListChanged()
2097
+ onRootsListChanged: () => orchestrator.broadcastRootsListChanged(),
2098
+ // Only register the `load_mcp` meta-tool when dynamic discovery is enabled —
2099
+ // i.e. when the config declared at least one lazy upstream MCP.
2100
+ loadMcp: orchestrator.hasDynamicDiscovery ? (mcpName2) => orchestrator.loadMcp(mcpName2) : void 0
1695
2101
  });
1696
2102
  orchestrator.setNotificationHandlers({
1697
2103
  onToolsListChanged: () => proxyServer.sendToolListChanged(),