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.js CHANGED
@@ -7,12 +7,12 @@ import { Command } from "commander";
7
7
  // package.json
8
8
  var package_default = {
9
9
  name: "dynmcp",
10
- version: "0.3.1",
10
+ version: "0.4.1",
11
11
  description: "Dynamic MCP context management tool for AI MCP-enabled agents and clients.",
12
12
  author: "Brandon Burrus <brandon@burrus.io>",
13
13
  license: "MIT",
14
14
  type: "module",
15
- homepage: "https://github.com/brandonburrus/dynamic-discovery-mcp#readme",
15
+ homepage: "https://dynamicmcp.tools",
16
16
  keywords: [
17
17
  "mcp",
18
18
  "model-context-protocol",
@@ -52,12 +52,10 @@ var package_default = {
52
52
  }
53
53
  },
54
54
  files: [
55
- "dist",
56
- "schema"
55
+ "dist"
57
56
  ],
58
57
  scripts: {
59
58
  "generate:schema": "tsx scripts/generate-schema.ts",
60
- prebuild: "tsx scripts/generate-schema.ts",
61
59
  build: "tsup",
62
60
  dev: "tsx src/index.ts",
63
61
  typecheck: "tsc --noEmit",
@@ -111,8 +109,12 @@ var mcpName = z.string().regex(MCP_NAME_PATTERN);
111
109
  var envModeSchema = z.enum(["enable", "dotenv", "process", "disable"]).describe(
112
110
  '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.'
113
111
  );
112
+ var description = z.string().min(1, { message: "description must be a non-empty string" }).refine((value) => value.trim().length > 0, {
113
+ message: "description must not be whitespace-only"
114
+ }).optional();
114
115
  var stdioTransport = z.object({
115
116
  transport: z.literal("stdio"),
117
+ description,
116
118
  command: z.string(),
117
119
  args: z.array(z.string()).optional(),
118
120
  env: z.record(z.string(), z.string()).optional()
@@ -122,11 +124,13 @@ var httpUrl = z.string().url().refine((u) => u.startsWith("http://") || u.starts
122
124
  });
123
125
  var streamableHttpTransport = z.object({
124
126
  transport: z.literal("streamable-http"),
127
+ description,
125
128
  url: httpUrl,
126
129
  headers: z.record(z.string(), z.string()).optional()
127
130
  }).strict();
128
131
  var sseTransport = z.object({
129
132
  transport: z.literal("sse"),
133
+ description,
130
134
  url: httpUrl,
131
135
  headers: z.record(z.string(), z.string()).optional()
132
136
  }).strict();
@@ -399,124 +403,87 @@ function aggregateCapabilities(upstreams) {
399
403
  return aggregated;
400
404
  }
401
405
 
402
- // src/proxy/tool-catalog.ts
403
- var DISCOVER_TOOL_PREAMBLE = `Use this tool to look up the full schema of a tool before calling it with use_tool.
404
- Call discover_tool with a tool name from the list below to get its complete description,
405
- input parameters, and output schema. Always discover a tool before using it.`;
406
- var ToolCatalog = class _ToolCatalog {
407
- tools;
408
- discoverToolDescription;
409
- constructor(tools, description) {
410
- this.tools = tools;
411
- this.discoverToolDescription = description;
412
- }
413
- static fromFlat(upstreamTools) {
414
- const toolMap = /* @__PURE__ */ new Map();
415
- for (const tool of upstreamTools) {
416
- toolMap.set(tool.name, tool);
406
+ // src/proxy/lazy-registry.ts
407
+ var LazyRegistry = class {
408
+ entries = /* @__PURE__ */ new Map();
409
+ failureCounts = /* @__PURE__ */ new Map();
410
+ register(name, entry) {
411
+ if (this.entries.has(name)) {
412
+ throw new Error(`LazyRegistry: duplicate registration for "${name}"`);
417
413
  }
418
- const description = buildFlatDescription(upstreamTools);
419
- return new _ToolCatalog(toolMap, description);
414
+ this.entries.set(name, entry);
420
415
  }
421
- static fromGrouped(groups) {
422
- const toolMap = /* @__PURE__ */ new Map();
423
- for (const [mcpName2, tools] of groups) {
424
- for (const tool of tools) {
425
- toolMap.set(`${mcpName2}/${tool.name}`, tool);
426
- }
427
- }
428
- const description = buildGroupedDescription(groups);
429
- return new _ToolCatalog(toolMap, description);
416
+ has(name) {
417
+ return this.entries.has(name);
430
418
  }
431
- getToolDetails(toolName) {
432
- const tool = this.tools.get(toolName);
433
- if (tool === void 0) {
434
- const sortedNames = [...this.tools.keys()].sort().join(", ");
435
- return `Unknown tool: "${toolName}". Available tools: ${sortedNames}`;
436
- }
437
- return buildToolDetailsString(toolName, tool);
419
+ get(name) {
420
+ return this.entries.get(name);
438
421
  }
439
- };
440
- function buildFlatDescription(tools) {
441
- const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name));
442
- const toolLines = sortedTools.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n");
443
- return `${DISCOVER_TOOL_PREAMBLE}
444
-
445
- <tools>
446
- ${toolLines}
447
- </tools>`;
448
- }
449
- function buildGroupedDescription(groups) {
450
- const sortedMcpNames = [...groups.keys()].sort();
451
- const sections = sortedMcpNames.map((mcpName2) => {
452
- const tools = groups.get(mcpName2);
453
- const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name));
454
- const toolLines = sortedTools.map((tool) => `- ${mcpName2}/${tool.name}: ${tool.description}`).join("\n");
455
- return `${mcpName2}:
456
- ${toolLines}`;
457
- });
458
- return `${DISCOVER_TOOL_PREAMBLE}
459
-
460
- <tools>
461
- ${sections.join("\n\n")}
462
- </tools>`;
463
- }
464
- function buildToolDetailsString(displayName, tool) {
465
- const lines = [
466
- `Tool: ${displayName}`,
467
- `Description: ${tool.description}`,
468
- "",
469
- "Input Schema:",
470
- JSON.stringify(tool.inputSchema, null, 2)
471
- ];
472
- if (tool.outputSchema !== void 0) {
473
- lines.push("", "Output Schema:", JSON.stringify(tool.outputSchema, null, 2));
474
- }
475
- const annotationLines = buildAnnotationLines(tool);
476
- if (annotationLines.length > 0) {
477
- lines.push("", "Annotations:", ...annotationLines);
422
+ /**
423
+ * Records a failed load attempt and returns the new total. The orchestrator's
424
+ * retry-budget logic uses this to decide whether to evict the entry. A
425
+ * subsequent successful load (or {@link take}) clears the count.
426
+ */
427
+ recordFailure(name) {
428
+ const next = (this.failureCounts.get(name) ?? 0) + 1;
429
+ this.failureCounts.set(name, next);
430
+ return next;
478
431
  }
479
- return lines.join("\n");
480
- }
481
- function buildAnnotationLines(tool) {
482
- if (tool.annotations === void 0) {
483
- return [];
432
+ failureCount(name) {
433
+ return this.failureCounts.get(name) ?? 0;
484
434
  }
485
- const { annotations } = tool;
486
- const lines = [];
487
- if (annotations.title !== void 0) {
488
- lines.push(`- title: ${annotations.title}`);
489
- }
490
- if (annotations.readOnlyHint !== void 0) {
491
- lines.push(`- readOnlyHint: ${annotations.readOnlyHint}`);
435
+ /**
436
+ * Returns the descriptions of every still-lazy MCP in insertion order. Consumed
437
+ * by {@link ToolCatalog.fromGroupedWithLazy} to render the `<mcp_servers>` block.
438
+ */
439
+ descriptions() {
440
+ const result = /* @__PURE__ */ new Map();
441
+ for (const [name, entry] of this.entries) {
442
+ result.set(name, entry.description);
443
+ }
444
+ return result;
492
445
  }
493
- if (annotations.destructiveHint !== void 0) {
494
- lines.push(`- destructiveHint: ${annotations.destructiveHint}`);
446
+ /**
447
+ * Returns the names of every still-lazy MCP in insertion order. Used by error
448
+ * messages that need to hint the agent at what is loadable.
449
+ */
450
+ names() {
451
+ return [...this.entries.keys()];
495
452
  }
496
- if (annotations.idempotentHint !== void 0) {
497
- lines.push(`- idempotentHint: ${annotations.idempotentHint}`);
453
+ size() {
454
+ return this.entries.size;
498
455
  }
499
- if (annotations.openWorldHint !== void 0) {
500
- lines.push(`- openWorldHint: ${annotations.openWorldHint}`);
456
+ /**
457
+ * Removes and returns the entry, signalling that the upstream has been (or is
458
+ * about to be) promoted to a connected client (or evicted after exhausting
459
+ * its retry budget). The caller is responsible for ensuring the promotion
460
+ * actually succeeds — failed loads should re-register via {@link register} to
461
+ * roll back the state. Clears the failure count along with the entry.
462
+ */
463
+ take(name) {
464
+ const entry = this.entries.get(name);
465
+ if (entry === void 0) return void 0;
466
+ this.entries.delete(name);
467
+ this.failureCounts.delete(name);
468
+ return entry;
501
469
  }
502
- return lines;
503
- }
470
+ };
504
471
 
505
472
  // src/proxy/notification-forwarder.ts
506
473
  var NotificationForwarder = class {
507
- constructor(registry, resourceRouter, promptRouter, toolsByMcp, setToolCatalog, namespaced) {
474
+ constructor(registry, resourceRouter, promptRouter, toolsByMcp, rebuildToolCatalog, namespaced) {
508
475
  this.registry = registry;
509
476
  this.resourceRouter = resourceRouter;
510
477
  this.promptRouter = promptRouter;
511
478
  this.toolsByMcp = toolsByMcp;
512
- this.setToolCatalog = setToolCatalog;
479
+ this.rebuildToolCatalog = rebuildToolCatalog;
513
480
  this.namespaced = namespaced;
514
481
  }
515
482
  registry;
516
483
  resourceRouter;
517
484
  promptRouter;
518
485
  toolsByMcp;
519
- setToolCatalog;
486
+ rebuildToolCatalog;
520
487
  namespaced;
521
488
  hostHandlers = {};
522
489
  setHostHandlers(handlers) {
@@ -527,8 +494,7 @@ var NotificationForwarder = class {
527
494
  if (client === void 0) return;
528
495
  const tools = await client.listTools().catch(() => []);
529
496
  this.toolsByMcp.set(mcpName2, tools);
530
- const rebuilt = this.namespaced ? ToolCatalog.fromGrouped(this.toolsByMcp) : ToolCatalog.fromFlat([...this.toolsByMcp.values()][0] ?? []);
531
- this.setToolCatalog(rebuilt);
497
+ this.rebuildToolCatalog();
532
498
  await this.hostHandlers.onToolsListChanged?.();
533
499
  }
534
500
  async handleResourcesListChanged(mcpName2) {
@@ -546,6 +512,22 @@ var NotificationForwarder = class {
546
512
  async handleResourceUpdated(params) {
547
513
  await this.hostHandlers.onResourceUpdated?.(params);
548
514
  }
515
+ /**
516
+ * Fire-only emitters. Unlike `handleXListChanged`, these do not re-fetch the
517
+ * affected data from any upstream — the caller has already populated the relevant
518
+ * state directly. Used by the orchestrator's load_mcp pipeline, which already has
519
+ * the freshly-queried tools/resources/prompts in hand and just needs to nudge the
520
+ * host to refetch.
521
+ */
522
+ async notifyToolsListChanged() {
523
+ await this.hostHandlers.onToolsListChanged?.();
524
+ }
525
+ async notifyResourcesListChanged() {
526
+ await this.hostHandlers.onResourcesListChanged?.();
527
+ }
528
+ async notifyPromptsListChanged() {
529
+ await this.hostHandlers.onPromptsListChanged?.();
530
+ }
549
531
  async handlePromptsListChanged(mcpName2) {
550
532
  const router = this.promptRouter();
551
533
  const client = this.registry.get(mcpName2);
@@ -608,6 +590,14 @@ var PromptRouter = class {
608
590
  collisions() {
609
591
  return this.detectedCollisions;
610
592
  }
593
+ /**
594
+ * Returns the prompts contributed by a single upstream MCP. Used by the load_mcp
595
+ * pipeline to construct its structured response. Returns an empty array if the MCP
596
+ * has not contributed any prompts (or if `mcpName` is unknown).
597
+ */
598
+ promptsFor(mcpName2) {
599
+ return this.perMcp.get(mcpName2) ?? [];
600
+ }
611
601
  rebuild() {
612
602
  this.nameOwners = /* @__PURE__ */ new Map();
613
603
  const collisions = [];
@@ -696,6 +686,19 @@ var ResourceRouter = class {
696
686
  collisions() {
697
687
  return this.detectedCollisions;
698
688
  }
689
+ /**
690
+ * Returns the resources contributed by a single upstream MCP. Used by the load_mcp
691
+ * pipeline to construct its structured response, which lists what a just-loaded MCP
692
+ * (or an already-loaded MCP, in the idempotent no-op path) brought to the proxy.
693
+ * Returns an empty array if the MCP has not contributed any resources (or if
694
+ * `mcpName` is unknown).
695
+ */
696
+ resourcesFor(mcpName2) {
697
+ return this.perMcp.get(mcpName2)?.resources ?? [];
698
+ }
699
+ templatesFor(mcpName2) {
700
+ return this.perMcp.get(mcpName2)?.templates ?? [];
701
+ }
699
702
  rebuild() {
700
703
  this.uriOwners = /* @__PURE__ */ new Map();
701
704
  this.templateOwners = [];
@@ -727,6 +730,145 @@ function literalPrefixOf(uriTemplate) {
727
730
  return idx === -1 ? uriTemplate : uriTemplate.slice(0, idx);
728
731
  }
729
732
 
733
+ // src/proxy/tool-catalog.ts
734
+ var DISCOVER_TOOL_PREAMBLE = `Use this tool to look up the full schema of a tool before calling it with use_tool.
735
+ Call discover_tool with a tool name from the list below to get its complete description,
736
+ input parameters, and output schema. Always discover a tool before using it.`;
737
+ var DYNAMIC_DISCOVERY_PREAMBLE = `Some MCP servers below are not loaded yet and are listed under <mcp_servers> with a
738
+ short description of what they do. To make a server's tools (and any resources or
739
+ prompts it exposes) available, call load_mcp with its name. Once loaded, the server's
740
+ tools will appear in the <tools> list and become callable via use_tool. Loading is
741
+ permanent for the remainder of this session.`;
742
+ var NO_TOOLS_LOADED_FOOTER = "No tools are currently loaded. Call load_mcp to make a server's tools available.";
743
+ var ToolCatalog = class _ToolCatalog {
744
+ tools;
745
+ discoverToolDescription;
746
+ constructor(tools, description2) {
747
+ this.tools = tools;
748
+ this.discoverToolDescription = description2;
749
+ }
750
+ static fromFlat(upstreamTools) {
751
+ const toolMap = /* @__PURE__ */ new Map();
752
+ for (const tool of upstreamTools) {
753
+ toolMap.set(tool.name, tool);
754
+ }
755
+ const description2 = buildFlatDescription(upstreamTools);
756
+ return new _ToolCatalog(toolMap, description2);
757
+ }
758
+ static fromGrouped(groups) {
759
+ return _ToolCatalog.fromGroupedWithLazy(groups, /* @__PURE__ */ new Map());
760
+ }
761
+ /**
762
+ * Same as {@link fromGrouped} but additionally accepts a map of lazy upstream MCPs
763
+ * (those declared with a `description` field but not yet loaded). When the map is
764
+ * non-empty, the rendered `discover_tool` description includes a `<mcp_servers>`
765
+ * block listing them with their descriptions and an explanatory paragraph telling
766
+ * the agent how to call `load_mcp`. When `groups` is empty, the `<tools>` block is
767
+ * omitted in favor of a trailing sentence directing the agent to `load_mcp`.
768
+ */
769
+ static fromGroupedWithLazy(groups, lazyDescriptions) {
770
+ const toolMap = /* @__PURE__ */ new Map();
771
+ for (const [mcpName2, tools] of groups) {
772
+ for (const tool of tools) {
773
+ toolMap.set(`${mcpName2}/${tool.name}`, tool);
774
+ }
775
+ }
776
+ const description2 = buildGroupedDescription(groups, lazyDescriptions);
777
+ return new _ToolCatalog(toolMap, description2);
778
+ }
779
+ getToolDetails(toolName) {
780
+ const tool = this.tools.get(toolName);
781
+ if (tool === void 0) {
782
+ const sortedNames = [...this.tools.keys()].sort().join(", ");
783
+ return `Unknown tool: "${toolName}". Available tools: ${sortedNames}`;
784
+ }
785
+ return buildToolDetailsString(toolName, tool);
786
+ }
787
+ };
788
+ function buildFlatDescription(tools) {
789
+ const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name));
790
+ const toolLines = sortedTools.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n");
791
+ return `${DISCOVER_TOOL_PREAMBLE}
792
+
793
+ <tools>
794
+ ${toolLines}
795
+ </tools>`;
796
+ }
797
+ function buildGroupedDescription(groups, lazyDescriptions) {
798
+ const parts = [DISCOVER_TOOL_PREAMBLE];
799
+ if (lazyDescriptions.size > 0) {
800
+ parts.push(DYNAMIC_DISCOVERY_PREAMBLE);
801
+ parts.push(buildMcpServersBlock(lazyDescriptions));
802
+ }
803
+ if (groups.size > 0) {
804
+ parts.push(buildToolsBlock(groups));
805
+ } else if (lazyDescriptions.size > 0) {
806
+ parts.push(NO_TOOLS_LOADED_FOOTER);
807
+ } else {
808
+ parts.push("<tools>\n</tools>");
809
+ }
810
+ return parts.join("\n\n");
811
+ }
812
+ function buildToolsBlock(groups) {
813
+ const sortedMcpNames = [...groups.keys()].sort();
814
+ const sections = sortedMcpNames.map((mcpName2) => {
815
+ const tools = groups.get(mcpName2);
816
+ const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name));
817
+ const toolLines = sortedTools.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n");
818
+ return `${mcpName2}:
819
+ ${toolLines}`;
820
+ });
821
+ return `<tools>
822
+ ${sections.join("\n\n")}
823
+ </tools>`;
824
+ }
825
+ function buildMcpServersBlock(lazyDescriptions) {
826
+ const lines = [...lazyDescriptions].map(([name, desc]) => `- ${name}: ${desc}`).join("\n");
827
+ return `<mcp_servers>
828
+ ${lines}
829
+ </mcp_servers>`;
830
+ }
831
+ function buildToolDetailsString(displayName, tool) {
832
+ const lines = [
833
+ `Tool: ${displayName}`,
834
+ `Description: ${tool.description}`,
835
+ "",
836
+ "Input Schema:",
837
+ JSON.stringify(tool.inputSchema, null, 2)
838
+ ];
839
+ if (tool.outputSchema !== void 0) {
840
+ lines.push("", "Output Schema:", JSON.stringify(tool.outputSchema, null, 2));
841
+ }
842
+ const annotationLines = buildAnnotationLines(tool);
843
+ if (annotationLines.length > 0) {
844
+ lines.push("", "Annotations:", ...annotationLines);
845
+ }
846
+ return lines.join("\n");
847
+ }
848
+ function buildAnnotationLines(tool) {
849
+ if (tool.annotations === void 0) {
850
+ return [];
851
+ }
852
+ const { annotations } = tool;
853
+ const lines = [];
854
+ if (annotations.title !== void 0) {
855
+ lines.push(`- title: ${annotations.title}`);
856
+ }
857
+ if (annotations.readOnlyHint !== void 0) {
858
+ lines.push(`- readOnlyHint: ${annotations.readOnlyHint}`);
859
+ }
860
+ if (annotations.destructiveHint !== void 0) {
861
+ lines.push(`- destructiveHint: ${annotations.destructiveHint}`);
862
+ }
863
+ if (annotations.idempotentHint !== void 0) {
864
+ lines.push(`- idempotentHint: ${annotations.idempotentHint}`);
865
+ }
866
+ if (annotations.openWorldHint !== void 0) {
867
+ lines.push(`- openWorldHint: ${annotations.openWorldHint}`);
868
+ }
869
+ return lines;
870
+ }
871
+
730
872
  // src/proxy/upstream-client.ts
731
873
  import process3 from "process";
732
874
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -961,6 +1103,41 @@ var UpstreamRegistry = class {
961
1103
  throw error;
962
1104
  }
963
1105
  }
1106
+ /**
1107
+ * Connects a single additional upstream. Unlike {@link connectAll}, a failure here
1108
+ * leaves any other connected clients untouched — the caller (typically the
1109
+ * Orchestrator's `loadMcp` pipeline) needs that isolation because other lazy MCPs
1110
+ * may have already been promoted to loaded, or eager MCPs are still healthy.
1111
+ *
1112
+ * Throws if `mcpName` is already in the registry; callers should check beforehand
1113
+ * (the orchestrator handles the idempotency-success case before reaching here).
1114
+ */
1115
+ async connectOne(mcpName2, config) {
1116
+ if (this.clients.has(mcpName2)) {
1117
+ throw new Error(`UpstreamRegistry: "${mcpName2}" is already connected`);
1118
+ }
1119
+ const client = new UpstreamClient({
1120
+ name: mcpName2,
1121
+ transport: config.transport,
1122
+ onTransportError: config.onTransportError,
1123
+ notifications: config.notifications,
1124
+ serverRequests: config.serverRequests
1125
+ });
1126
+ await client.connect();
1127
+ this.clients.set(mcpName2, client);
1128
+ return client;
1129
+ }
1130
+ /**
1131
+ * Disconnects and removes a single upstream. Used by `loadMcp` to roll back a
1132
+ * partially-loaded MCP when a post-connect catalog query fails. No-op if the name
1133
+ * is not present.
1134
+ */
1135
+ async deleteOne(mcpName2) {
1136
+ const client = this.clients.get(mcpName2);
1137
+ if (client === void 0) return;
1138
+ this.clients.delete(mcpName2);
1139
+ await client.disconnect();
1140
+ }
964
1141
  get(mcpName2) {
965
1142
  return this.clients.get(mcpName2);
966
1143
  }
@@ -990,10 +1167,18 @@ var UpstreamRegistry = class {
990
1167
  };
991
1168
 
992
1169
  // src/proxy/orchestrator.ts
1170
+ var MAX_LOAD_ATTEMPTS = 3;
993
1171
  var Orchestrator = class {
994
1172
  config;
995
1173
  registry = new UpstreamRegistry();
1174
+ lazyRegistry = new LazyRegistry();
996
1175
  toolsByMcp = /* @__PURE__ */ new Map();
1176
+ /**
1177
+ * In-flight loads, keyed by mcpName. Used by {@link loadMcp} to coalesce concurrent
1178
+ * calls for the same name onto a single underlying connection attempt — per SPEC.md
1179
+ * § "Dynamic Discovery > Concurrency".
1180
+ */
1181
+ inFlightLoads = /* @__PURE__ */ new Map();
997
1182
  resourceRouter = null;
998
1183
  promptRouter = null;
999
1184
  toolCatalog = null;
@@ -1001,9 +1186,14 @@ var Orchestrator = class {
1001
1186
  serverRequestForwarders = {};
1002
1187
  forwarder;
1003
1188
  constructor(config) {
1004
- if (!config.namespaced && config.mcps.size !== 1) {
1189
+ if (!config.namespaced && config.eagerMcps.size !== 1) {
1190
+ throw new Error(
1191
+ `Single-MCP (non-namespaced) mode requires exactly one upstream; got ${config.eagerMcps.size}.`
1192
+ );
1193
+ }
1194
+ if (!config.namespaced && config.lazyMcps !== void 0 && config.lazyMcps.size > 0) {
1005
1195
  throw new Error(
1006
- `Single-MCP (non-namespaced) mode requires exactly one upstream; got ${config.mcps.size}.`
1196
+ "Single-MCP (non-namespaced) mode does not support lazy upstreams (descriptions)."
1007
1197
  );
1008
1198
  }
1009
1199
  this.config = config;
@@ -1012,9 +1202,7 @@ var Orchestrator = class {
1012
1202
  () => this.resourceRouter,
1013
1203
  () => this.promptRouter,
1014
1204
  this.toolsByMcp,
1015
- (catalog) => {
1016
- this.toolCatalog = catalog;
1017
- },
1205
+ () => this.rebuildToolCatalog(),
1018
1206
  this.config.namespaced
1019
1207
  );
1020
1208
  }
@@ -1024,32 +1212,26 @@ var Orchestrator = class {
1024
1212
  setServerRequestForwarders(forwarders) {
1025
1213
  this.serverRequestForwarders = forwarders;
1026
1214
  }
1215
+ /**
1216
+ * True when the orchestrator was configured with at least one lazy upstream MCP.
1217
+ * The `index.ts` wiring uses this to decide whether to register the `load_mcp`
1218
+ * meta-tool with the host-facing {@link ProxyServer}.
1219
+ */
1220
+ get hasDynamicDiscovery() {
1221
+ return this.config.lazyMcps !== void 0 && this.config.lazyMcps.size > 0;
1222
+ }
1027
1223
  async connect() {
1028
- const resourceRouter = new ResourceRouter([...this.config.mcps.keys()]);
1029
- const promptRouter = new PromptRouter([...this.config.mcps.keys()]);
1030
- const upstreamEntries = [
1031
- ...this.config.mcps
1032
- ].map(([mcpName2, { transport }]) => [
1033
- mcpName2,
1034
- {
1035
- transport,
1036
- onTransportError: (error) => {
1037
- this.config.onTransportError?.(mcpName2, error);
1038
- },
1039
- notifications: {
1040
- onToolsListChanged: () => this.forwarder.handleToolsListChanged(mcpName2),
1041
- onResourcesListChanged: () => this.forwarder.handleResourcesListChanged(mcpName2),
1042
- onResourceUpdated: (params) => this.forwarder.handleResourceUpdated(params),
1043
- onPromptsListChanged: () => this.forwarder.handlePromptsListChanged(mcpName2),
1044
- onLogMessage: (params) => this.forwarder.handleLogMessage(mcpName2, params)
1045
- },
1046
- serverRequests: {
1047
- onCreateMessage: (params, opts) => this.forwardCreateMessage(params, opts),
1048
- onElicitInput: (params, opts) => this.forwardElicitInput(params, opts),
1049
- onListRoots: (params, opts) => this.forwardListRoots(params, opts)
1050
- }
1224
+ const allNames = [...this.config.eagerMcps.keys(), ...this.config.lazyMcps?.keys() ?? []];
1225
+ const resourceRouter = new ResourceRouter(allNames);
1226
+ const promptRouter = new PromptRouter(allNames);
1227
+ if (this.config.lazyMcps !== void 0) {
1228
+ for (const [name, { transport, description: description2 }] of this.config.lazyMcps) {
1229
+ this.lazyRegistry.register(name, { transport, description: description2 });
1051
1230
  }
1052
- ]);
1231
+ }
1232
+ const upstreamEntries = [
1233
+ ...this.config.eagerMcps
1234
+ ].map(([mcpName2, { transport }]) => [mcpName2, this.buildUpstreamConfig(mcpName2, transport)]);
1053
1235
  await this.registry.connectAll(upstreamEntries);
1054
1236
  const capabilityList = [];
1055
1237
  this.toolsByMcp.clear();
@@ -1071,10 +1253,10 @@ var Orchestrator = class {
1071
1253
  promptRouter.setPrompts(mcpName2, prompts);
1072
1254
  }
1073
1255
  }
1074
- this.toolCatalog = this.config.namespaced ? ToolCatalog.fromGrouped(this.toolsByMcp) : ToolCatalog.fromFlat([...this.toolsByMcp.values()][0] ?? []);
1075
- this.aggregatedCapabilities = aggregateCapabilities(capabilityList);
1076
1256
  this.resourceRouter = resourceRouter;
1077
1257
  this.promptRouter = promptRouter;
1258
+ this.aggregatedCapabilities = aggregateCapabilities(capabilityList);
1259
+ this.rebuildToolCatalog();
1078
1260
  logCollisions(resourceRouter, promptRouter);
1079
1261
  }
1080
1262
  async disconnectAll() {
@@ -1084,6 +1266,177 @@ var Orchestrator = class {
1084
1266
  this.aggregatedCapabilities = null;
1085
1267
  this.resourceRouter = null;
1086
1268
  this.promptRouter = null;
1269
+ this.inFlightLoads.clear();
1270
+ }
1271
+ // === Dynamic discovery ===
1272
+ /**
1273
+ * Loads a lazy upstream MCP on demand. Implements the semantics of SPEC.md §
1274
+ * "Tools > load_mcp" and § "Dynamic Discovery > Lifecycle of a Lazy MCP":
1275
+ *
1276
+ * - Already-loaded (eager or previously-loaded lazy) names succeed as a no-op
1277
+ * returning the current listing; no notifications fire.
1278
+ * - Unknown names throw an error that hints at the still-lazy alternatives.
1279
+ * - Concurrent calls for the same name coalesce onto the same in-flight load.
1280
+ * - A failure during connect/initialize/catalog-query rolls back atomically:
1281
+ * the upstream is disconnected, the lazy entry stays registered, no host
1282
+ * notifications fire.
1283
+ * - On success the loaded MCP is promoted into the connected registry, its
1284
+ * tools/resources/prompts populate the routers, the discover_tool catalog
1285
+ * is regenerated, and the host receives `tools/list_changed` plus
1286
+ * `resources/list_changed` and/or `prompts/list_changed` for any non-empty
1287
+ * surface the MCP contributed.
1288
+ */
1289
+ async loadMcp(mcpName2) {
1290
+ if (this.registry.get(mcpName2) !== void 0) {
1291
+ return this.getListing(mcpName2);
1292
+ }
1293
+ const inFlight = this.inFlightLoads.get(mcpName2);
1294
+ if (inFlight !== void 0) {
1295
+ return inFlight;
1296
+ }
1297
+ if (!this.lazyRegistry.has(mcpName2)) {
1298
+ const lazyNames = this.lazyRegistry.names().join(", ");
1299
+ const hint = lazyNames.length > 0 ? lazyNames : "(none)";
1300
+ throw new Error(`Unknown MCP server: "${mcpName2}". Available servers to load: ${hint}`);
1301
+ }
1302
+ const loadPromise = this.runLoadPipeline(mcpName2).finally(() => {
1303
+ this.inFlightLoads.delete(mcpName2);
1304
+ });
1305
+ this.inFlightLoads.set(mcpName2, loadPromise);
1306
+ return loadPromise;
1307
+ }
1308
+ async runLoadPipeline(mcpName2) {
1309
+ const entry = this.lazyRegistry.get(mcpName2);
1310
+ if (entry === void 0) {
1311
+ throw new Error(`Internal error: lazy entry "${mcpName2}" vanished mid-load.`);
1312
+ }
1313
+ const client = await this.registry.connectOne(
1314
+ mcpName2,
1315
+ this.buildUpstreamConfig(mcpName2, entry.transport)
1316
+ );
1317
+ let tools;
1318
+ let resources = [];
1319
+ let templates = [];
1320
+ let prompts = [];
1321
+ let caps;
1322
+ try {
1323
+ caps = client.getCapabilities();
1324
+ tools = await client.listTools();
1325
+ if (caps?.resources !== void 0) {
1326
+ [resources, templates] = await Promise.all([
1327
+ client.listResources(),
1328
+ client.listResourceTemplates()
1329
+ ]);
1330
+ }
1331
+ if (caps?.prompts !== void 0) {
1332
+ prompts = await client.listPrompts();
1333
+ }
1334
+ } catch (error) {
1335
+ await this.registry.deleteOne(mcpName2);
1336
+ const failures = this.lazyRegistry.recordFailure(mcpName2);
1337
+ if (failures >= MAX_LOAD_ATTEMPTS) {
1338
+ this.lazyRegistry.take(mcpName2);
1339
+ this.rebuildToolCatalog();
1340
+ await this.forwarder.notifyToolsListChanged();
1341
+ const base = error instanceof Error ? error.message : String(error);
1342
+ throw new Error(
1343
+ `Failed to load "${mcpName2}" after ${failures} attempts; the server will no longer be offered for discovery. Underlying error: ${base}`
1344
+ );
1345
+ }
1346
+ throw error;
1347
+ }
1348
+ this.lazyRegistry.take(mcpName2);
1349
+ this.toolsByMcp.set(mcpName2, tools);
1350
+ const resourceRouter = this.requireResourceRouter();
1351
+ const promptRouter = this.requirePromptRouter();
1352
+ if (caps?.resources !== void 0) {
1353
+ resourceRouter.setResources(mcpName2, resources);
1354
+ resourceRouter.setTemplates(mcpName2, templates);
1355
+ }
1356
+ if (caps?.prompts !== void 0) {
1357
+ promptRouter.setPrompts(mcpName2, prompts);
1358
+ }
1359
+ this.rebuildToolCatalog();
1360
+ await this.forwarder.notifyToolsListChanged();
1361
+ if (caps?.resources !== void 0 && (resources.length > 0 || templates.length > 0)) {
1362
+ await this.forwarder.notifyResourcesListChanged();
1363
+ }
1364
+ if (caps?.prompts !== void 0 && prompts.length > 0) {
1365
+ await this.forwarder.notifyPromptsListChanged();
1366
+ }
1367
+ return this.getListing(mcpName2);
1368
+ }
1369
+ /**
1370
+ * Builds the structured response shape documented for `load_mcp` from existing
1371
+ * per-MCP state. Pulled out into a helper so the no-op path (already-loaded)
1372
+ * and the success path share one source of truth.
1373
+ */
1374
+ getListing(mcpName2) {
1375
+ const tools = this.toolsByMcp.get(mcpName2) ?? [];
1376
+ const namespacedToolName = (name) => this.config.namespaced ? `${mcpName2}/${name}` : name;
1377
+ const resources = this.resourceRouter?.resourcesFor(mcpName2) ?? [];
1378
+ const templates = this.resourceRouter?.templatesFor(mcpName2) ?? [];
1379
+ const prompts = this.promptRouter?.promptsFor(mcpName2) ?? [];
1380
+ return {
1381
+ mcp_name: mcpName2,
1382
+ tools: tools.map((tool) => ({
1383
+ name: namespacedToolName(tool.name),
1384
+ description: tool.description
1385
+ })),
1386
+ resources: resources.map((resource) => ({
1387
+ uri: resource.uri,
1388
+ name: resource.name,
1389
+ description: resource.description,
1390
+ mimeType: resource.mimeType
1391
+ })),
1392
+ resource_templates: templates.map((template) => ({
1393
+ uriTemplate: template.uriTemplate,
1394
+ name: template.name,
1395
+ description: template.description,
1396
+ mimeType: template.mimeType
1397
+ })),
1398
+ prompts: prompts.map((prompt) => ({
1399
+ name: prompt.name,
1400
+ description: prompt.description,
1401
+ arguments: prompt.arguments
1402
+ }))
1403
+ };
1404
+ }
1405
+ // === Internal helpers used by connect() and loadMcp() ===
1406
+ buildUpstreamConfig(mcpName2, transport) {
1407
+ return {
1408
+ transport,
1409
+ onTransportError: (error) => {
1410
+ this.config.onTransportError?.(mcpName2, error);
1411
+ },
1412
+ notifications: {
1413
+ onToolsListChanged: () => this.forwarder.handleToolsListChanged(mcpName2),
1414
+ onResourcesListChanged: () => this.forwarder.handleResourcesListChanged(mcpName2),
1415
+ onResourceUpdated: (params) => this.forwarder.handleResourceUpdated(params),
1416
+ onPromptsListChanged: () => this.forwarder.handlePromptsListChanged(mcpName2),
1417
+ onLogMessage: (params) => this.forwarder.handleLogMessage(mcpName2, params)
1418
+ },
1419
+ serverRequests: {
1420
+ onCreateMessage: (params, opts) => this.forwardCreateMessage(params, opts),
1421
+ onElicitInput: (params, opts) => this.forwardElicitInput(params, opts),
1422
+ onListRoots: (params, opts) => this.forwardListRoots(params, opts)
1423
+ }
1424
+ };
1425
+ }
1426
+ /**
1427
+ * Rebuilds the `ToolCatalog` from current state. Called whenever `toolsByMcp` or the
1428
+ * lazy-registry membership changes — including initial connect, upstream-emitted
1429
+ * `tools/list_changed`, and successful `loadMcp`.
1430
+ */
1431
+ rebuildToolCatalog() {
1432
+ if (this.config.namespaced) {
1433
+ this.toolCatalog = ToolCatalog.fromGroupedWithLazy(
1434
+ this.toolsByMcp,
1435
+ this.lazyRegistry.descriptions()
1436
+ );
1437
+ } else {
1438
+ this.toolCatalog = ToolCatalog.fromFlat([...this.toolsByMcp.values()][0] ?? []);
1439
+ }
1087
1440
  }
1088
1441
  get catalog() {
1089
1442
  if (this.toolCatalog === null) {
@@ -1285,7 +1638,9 @@ import {
1285
1638
  import { z as z3 } from "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 = z3.object({ tool_name: z3.string() });
1305
1667
  var UseToolArgsSchema = z3.object({
1306
1668
  tool_name: z3.string(),
1307
1669
  tool_input: z3.record(z3.string(), z3.unknown()).default({})
1308
1670
  });
1671
+ var LoadMcpArgsSchema = z3.object({ mcp_name: z3.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 Server(
@@ -1466,8 +1832,8 @@ var ProxyServer = class {
1466
1832
  return options;
1467
1833
  }
1468
1834
  registerToolHandlers(server) {
1469
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
1470
- tools: [
1835
+ server.setRequestHandler(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
  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 StdioClientTransport2({ 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
  process6.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(),