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