dynmcp 0.3.0 → 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.js CHANGED
@@ -7,7 +7,7 @@ import { Command } from "commander";
7
7
  // package.json
8
8
  var package_default = {
9
9
  name: "dynmcp",
10
- version: "0.3.0",
10
+ version: "0.4.0",
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",
@@ -111,8 +111,12 @@ var mcpName = z.string().regex(MCP_NAME_PATTERN);
111
111
  var envModeSchema = z.enum(["enable", "dotenv", "process", "disable"]).describe(
112
112
  '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
113
  );
114
+ var description = z.string().min(1, { message: "description must be a non-empty string" }).refine((value) => value.trim().length > 0, {
115
+ message: "description must not be whitespace-only"
116
+ }).optional();
114
117
  var stdioTransport = z.object({
115
118
  transport: z.literal("stdio"),
119
+ description,
116
120
  command: z.string(),
117
121
  args: z.array(z.string()).optional(),
118
122
  env: z.record(z.string(), z.string()).optional()
@@ -122,11 +126,13 @@ var httpUrl = z.string().url().refine((u) => u.startsWith("http://") || u.starts
122
126
  });
123
127
  var streamableHttpTransport = z.object({
124
128
  transport: z.literal("streamable-http"),
129
+ description,
125
130
  url: httpUrl,
126
131
  headers: z.record(z.string(), z.string()).optional()
127
132
  }).strict();
128
133
  var sseTransport = z.object({
129
134
  transport: z.literal("sse"),
135
+ description,
130
136
  url: httpUrl,
131
137
  headers: z.record(z.string(), z.string()).optional()
132
138
  }).strict();
@@ -399,124 +405,87 @@ function aggregateCapabilities(upstreams) {
399
405
  return aggregated;
400
406
  }
401
407
 
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);
408
+ // src/proxy/lazy-registry.ts
409
+ var LazyRegistry = class {
410
+ entries = /* @__PURE__ */ new Map();
411
+ failureCounts = /* @__PURE__ */ new Map();
412
+ register(name, entry) {
413
+ if (this.entries.has(name)) {
414
+ throw new Error(`LazyRegistry: duplicate registration for "${name}"`);
417
415
  }
418
- const description = buildFlatDescription(upstreamTools);
419
- return new _ToolCatalog(toolMap, description);
416
+ this.entries.set(name, entry);
420
417
  }
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);
418
+ has(name) {
419
+ return this.entries.has(name);
430
420
  }
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);
421
+ get(name) {
422
+ return this.entries.get(name);
438
423
  }
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);
478
- }
479
- return lines.join("\n");
480
- }
481
- function buildAnnotationLines(tool) {
482
- if (tool.annotations === void 0) {
483
- return [];
424
+ /**
425
+ * Records a failed load attempt and returns the new total. The orchestrator's
426
+ * retry-budget logic uses this to decide whether to evict the entry. A
427
+ * subsequent successful load (or {@link take}) clears the count.
428
+ */
429
+ recordFailure(name) {
430
+ const next = (this.failureCounts.get(name) ?? 0) + 1;
431
+ this.failureCounts.set(name, next);
432
+ return next;
484
433
  }
485
- const { annotations } = tool;
486
- const lines = [];
487
- if (annotations.title !== void 0) {
488
- lines.push(`- title: ${annotations.title}`);
434
+ failureCount(name) {
435
+ return this.failureCounts.get(name) ?? 0;
489
436
  }
490
- if (annotations.readOnlyHint !== void 0) {
491
- lines.push(`- readOnlyHint: ${annotations.readOnlyHint}`);
437
+ /**
438
+ * Returns the descriptions of every still-lazy MCP in insertion order. Consumed
439
+ * by {@link ToolCatalog.fromGroupedWithLazy} to render the `<mcp_servers>` block.
440
+ */
441
+ descriptions() {
442
+ const result = /* @__PURE__ */ new Map();
443
+ for (const [name, entry] of this.entries) {
444
+ result.set(name, entry.description);
445
+ }
446
+ return result;
492
447
  }
493
- if (annotations.destructiveHint !== void 0) {
494
- lines.push(`- destructiveHint: ${annotations.destructiveHint}`);
448
+ /**
449
+ * Returns the names of every still-lazy MCP in insertion order. Used by error
450
+ * messages that need to hint the agent at what is loadable.
451
+ */
452
+ names() {
453
+ return [...this.entries.keys()];
495
454
  }
496
- if (annotations.idempotentHint !== void 0) {
497
- lines.push(`- idempotentHint: ${annotations.idempotentHint}`);
455
+ size() {
456
+ return this.entries.size;
498
457
  }
499
- if (annotations.openWorldHint !== void 0) {
500
- lines.push(`- openWorldHint: ${annotations.openWorldHint}`);
458
+ /**
459
+ * Removes and returns the entry, signalling that the upstream has been (or is
460
+ * about to be) promoted to a connected client (or evicted after exhausting
461
+ * its retry budget). The caller is responsible for ensuring the promotion
462
+ * actually succeeds — failed loads should re-register via {@link register} to
463
+ * roll back the state. Clears the failure count along with the entry.
464
+ */
465
+ take(name) {
466
+ const entry = this.entries.get(name);
467
+ if (entry === void 0) return void 0;
468
+ this.entries.delete(name);
469
+ this.failureCounts.delete(name);
470
+ return entry;
501
471
  }
502
- return lines;
503
- }
472
+ };
504
473
 
505
474
  // src/proxy/notification-forwarder.ts
506
475
  var NotificationForwarder = class {
507
- constructor(registry, resourceRouter, promptRouter, toolsByMcp, setToolCatalog, namespaced) {
476
+ constructor(registry, resourceRouter, promptRouter, toolsByMcp, rebuildToolCatalog, namespaced) {
508
477
  this.registry = registry;
509
478
  this.resourceRouter = resourceRouter;
510
479
  this.promptRouter = promptRouter;
511
480
  this.toolsByMcp = toolsByMcp;
512
- this.setToolCatalog = setToolCatalog;
481
+ this.rebuildToolCatalog = rebuildToolCatalog;
513
482
  this.namespaced = namespaced;
514
483
  }
515
484
  registry;
516
485
  resourceRouter;
517
486
  promptRouter;
518
487
  toolsByMcp;
519
- setToolCatalog;
488
+ rebuildToolCatalog;
520
489
  namespaced;
521
490
  hostHandlers = {};
522
491
  setHostHandlers(handlers) {
@@ -527,8 +496,7 @@ var NotificationForwarder = class {
527
496
  if (client === void 0) return;
528
497
  const tools = await client.listTools().catch(() => []);
529
498
  this.toolsByMcp.set(mcpName2, tools);
530
- const rebuilt = this.namespaced ? ToolCatalog.fromGrouped(this.toolsByMcp) : ToolCatalog.fromFlat([...this.toolsByMcp.values()][0] ?? []);
531
- this.setToolCatalog(rebuilt);
499
+ this.rebuildToolCatalog();
532
500
  await this.hostHandlers.onToolsListChanged?.();
533
501
  }
534
502
  async handleResourcesListChanged(mcpName2) {
@@ -546,6 +514,22 @@ var NotificationForwarder = class {
546
514
  async handleResourceUpdated(params) {
547
515
  await this.hostHandlers.onResourceUpdated?.(params);
548
516
  }
517
+ /**
518
+ * Fire-only emitters. Unlike `handleXListChanged`, these do not re-fetch the
519
+ * affected data from any upstream — the caller has already populated the relevant
520
+ * state directly. Used by the orchestrator's load_mcp pipeline, which already has
521
+ * the freshly-queried tools/resources/prompts in hand and just needs to nudge the
522
+ * host to refetch.
523
+ */
524
+ async notifyToolsListChanged() {
525
+ await this.hostHandlers.onToolsListChanged?.();
526
+ }
527
+ async notifyResourcesListChanged() {
528
+ await this.hostHandlers.onResourcesListChanged?.();
529
+ }
530
+ async notifyPromptsListChanged() {
531
+ await this.hostHandlers.onPromptsListChanged?.();
532
+ }
549
533
  async handlePromptsListChanged(mcpName2) {
550
534
  const router = this.promptRouter();
551
535
  const client = this.registry.get(mcpName2);
@@ -608,6 +592,14 @@ var PromptRouter = class {
608
592
  collisions() {
609
593
  return this.detectedCollisions;
610
594
  }
595
+ /**
596
+ * Returns the prompts contributed by a single upstream MCP. Used by the load_mcp
597
+ * pipeline to construct its structured response. Returns an empty array if the MCP
598
+ * has not contributed any prompts (or if `mcpName` is unknown).
599
+ */
600
+ promptsFor(mcpName2) {
601
+ return this.perMcp.get(mcpName2) ?? [];
602
+ }
611
603
  rebuild() {
612
604
  this.nameOwners = /* @__PURE__ */ new Map();
613
605
  const collisions = [];
@@ -696,6 +688,19 @@ var ResourceRouter = class {
696
688
  collisions() {
697
689
  return this.detectedCollisions;
698
690
  }
691
+ /**
692
+ * Returns the resources contributed by a single upstream MCP. Used by the load_mcp
693
+ * pipeline to construct its structured response, which lists what a just-loaded MCP
694
+ * (or an already-loaded MCP, in the idempotent no-op path) brought to the proxy.
695
+ * Returns an empty array if the MCP has not contributed any resources (or if
696
+ * `mcpName` is unknown).
697
+ */
698
+ resourcesFor(mcpName2) {
699
+ return this.perMcp.get(mcpName2)?.resources ?? [];
700
+ }
701
+ templatesFor(mcpName2) {
702
+ return this.perMcp.get(mcpName2)?.templates ?? [];
703
+ }
699
704
  rebuild() {
700
705
  this.uriOwners = /* @__PURE__ */ new Map();
701
706
  this.templateOwners = [];
@@ -727,6 +732,145 @@ function literalPrefixOf(uriTemplate) {
727
732
  return idx === -1 ? uriTemplate : uriTemplate.slice(0, idx);
728
733
  }
729
734
 
735
+ // src/proxy/tool-catalog.ts
736
+ var DISCOVER_TOOL_PREAMBLE = `Use this tool to look up the full schema of a tool before calling it with use_tool.
737
+ Call discover_tool with a tool name from the list below to get its complete description,
738
+ input parameters, and output schema. Always discover a tool before using it.`;
739
+ var DYNAMIC_DISCOVERY_PREAMBLE = `Some MCP servers below are not loaded yet and are listed under <mcp_servers> with a
740
+ short description of what they do. To make a server's tools (and any resources or
741
+ prompts it exposes) available, call load_mcp with its name. Once loaded, the server's
742
+ tools will appear in the <tools> list and become callable via use_tool. Loading is
743
+ permanent for the remainder of this session.`;
744
+ var NO_TOOLS_LOADED_FOOTER = "No tools are currently loaded. Call load_mcp to make a server's tools available.";
745
+ var ToolCatalog = class _ToolCatalog {
746
+ tools;
747
+ discoverToolDescription;
748
+ constructor(tools, description2) {
749
+ this.tools = tools;
750
+ this.discoverToolDescription = description2;
751
+ }
752
+ static fromFlat(upstreamTools) {
753
+ const toolMap = /* @__PURE__ */ new Map();
754
+ for (const tool of upstreamTools) {
755
+ toolMap.set(tool.name, tool);
756
+ }
757
+ const description2 = buildFlatDescription(upstreamTools);
758
+ return new _ToolCatalog(toolMap, description2);
759
+ }
760
+ static fromGrouped(groups) {
761
+ return _ToolCatalog.fromGroupedWithLazy(groups, /* @__PURE__ */ new Map());
762
+ }
763
+ /**
764
+ * Same as {@link fromGrouped} but additionally accepts a map of lazy upstream MCPs
765
+ * (those declared with a `description` field but not yet loaded). When the map is
766
+ * non-empty, the rendered `discover_tool` description includes a `<mcp_servers>`
767
+ * block listing them with their descriptions and an explanatory paragraph telling
768
+ * the agent how to call `load_mcp`. When `groups` is empty, the `<tools>` block is
769
+ * omitted in favor of a trailing sentence directing the agent to `load_mcp`.
770
+ */
771
+ static fromGroupedWithLazy(groups, lazyDescriptions) {
772
+ const toolMap = /* @__PURE__ */ new Map();
773
+ for (const [mcpName2, tools] of groups) {
774
+ for (const tool of tools) {
775
+ toolMap.set(`${mcpName2}/${tool.name}`, tool);
776
+ }
777
+ }
778
+ const description2 = buildGroupedDescription(groups, lazyDescriptions);
779
+ return new _ToolCatalog(toolMap, description2);
780
+ }
781
+ getToolDetails(toolName) {
782
+ const tool = this.tools.get(toolName);
783
+ if (tool === void 0) {
784
+ const sortedNames = [...this.tools.keys()].sort().join(", ");
785
+ return `Unknown tool: "${toolName}". Available tools: ${sortedNames}`;
786
+ }
787
+ return buildToolDetailsString(toolName, tool);
788
+ }
789
+ };
790
+ function buildFlatDescription(tools) {
791
+ const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name));
792
+ const toolLines = sortedTools.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n");
793
+ return `${DISCOVER_TOOL_PREAMBLE}
794
+
795
+ <tools>
796
+ ${toolLines}
797
+ </tools>`;
798
+ }
799
+ function buildGroupedDescription(groups, lazyDescriptions) {
800
+ const parts = [DISCOVER_TOOL_PREAMBLE];
801
+ if (lazyDescriptions.size > 0) {
802
+ parts.push(DYNAMIC_DISCOVERY_PREAMBLE);
803
+ parts.push(buildMcpServersBlock(lazyDescriptions));
804
+ }
805
+ if (groups.size > 0) {
806
+ parts.push(buildToolsBlock(groups));
807
+ } else if (lazyDescriptions.size > 0) {
808
+ parts.push(NO_TOOLS_LOADED_FOOTER);
809
+ } else {
810
+ parts.push("<tools>\n</tools>");
811
+ }
812
+ return parts.join("\n\n");
813
+ }
814
+ function buildToolsBlock(groups) {
815
+ const sortedMcpNames = [...groups.keys()].sort();
816
+ const sections = sortedMcpNames.map((mcpName2) => {
817
+ const tools = groups.get(mcpName2);
818
+ const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name));
819
+ const toolLines = sortedTools.map((tool) => `- ${mcpName2}/${tool.name}: ${tool.description}`).join("\n");
820
+ return `${mcpName2}:
821
+ ${toolLines}`;
822
+ });
823
+ return `<tools>
824
+ ${sections.join("\n\n")}
825
+ </tools>`;
826
+ }
827
+ function buildMcpServersBlock(lazyDescriptions) {
828
+ const lines = [...lazyDescriptions].map(([name, desc]) => `- ${name}: ${desc}`).join("\n");
829
+ return `<mcp_servers>
830
+ ${lines}
831
+ </mcp_servers>`;
832
+ }
833
+ function buildToolDetailsString(displayName, tool) {
834
+ const lines = [
835
+ `Tool: ${displayName}`,
836
+ `Description: ${tool.description}`,
837
+ "",
838
+ "Input Schema:",
839
+ JSON.stringify(tool.inputSchema, null, 2)
840
+ ];
841
+ if (tool.outputSchema !== void 0) {
842
+ lines.push("", "Output Schema:", JSON.stringify(tool.outputSchema, null, 2));
843
+ }
844
+ const annotationLines = buildAnnotationLines(tool);
845
+ if (annotationLines.length > 0) {
846
+ lines.push("", "Annotations:", ...annotationLines);
847
+ }
848
+ return lines.join("\n");
849
+ }
850
+ function buildAnnotationLines(tool) {
851
+ if (tool.annotations === void 0) {
852
+ return [];
853
+ }
854
+ const { annotations } = tool;
855
+ const lines = [];
856
+ if (annotations.title !== void 0) {
857
+ lines.push(`- title: ${annotations.title}`);
858
+ }
859
+ if (annotations.readOnlyHint !== void 0) {
860
+ lines.push(`- readOnlyHint: ${annotations.readOnlyHint}`);
861
+ }
862
+ if (annotations.destructiveHint !== void 0) {
863
+ lines.push(`- destructiveHint: ${annotations.destructiveHint}`);
864
+ }
865
+ if (annotations.idempotentHint !== void 0) {
866
+ lines.push(`- idempotentHint: ${annotations.idempotentHint}`);
867
+ }
868
+ if (annotations.openWorldHint !== void 0) {
869
+ lines.push(`- openWorldHint: ${annotations.openWorldHint}`);
870
+ }
871
+ return lines;
872
+ }
873
+
730
874
  // src/proxy/upstream-client.ts
731
875
  import process3 from "process";
732
876
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -961,6 +1105,41 @@ var UpstreamRegistry = class {
961
1105
  throw error;
962
1106
  }
963
1107
  }
1108
+ /**
1109
+ * Connects a single additional upstream. Unlike {@link connectAll}, a failure here
1110
+ * leaves any other connected clients untouched — the caller (typically the
1111
+ * Orchestrator's `loadMcp` pipeline) needs that isolation because other lazy MCPs
1112
+ * may have already been promoted to loaded, or eager MCPs are still healthy.
1113
+ *
1114
+ * Throws if `mcpName` is already in the registry; callers should check beforehand
1115
+ * (the orchestrator handles the idempotency-success case before reaching here).
1116
+ */
1117
+ async connectOne(mcpName2, config) {
1118
+ if (this.clients.has(mcpName2)) {
1119
+ throw new Error(`UpstreamRegistry: "${mcpName2}" is already connected`);
1120
+ }
1121
+ const client = new UpstreamClient({
1122
+ name: mcpName2,
1123
+ transport: config.transport,
1124
+ onTransportError: config.onTransportError,
1125
+ notifications: config.notifications,
1126
+ serverRequests: config.serverRequests
1127
+ });
1128
+ await client.connect();
1129
+ this.clients.set(mcpName2, client);
1130
+ return client;
1131
+ }
1132
+ /**
1133
+ * Disconnects and removes a single upstream. Used by `loadMcp` to roll back a
1134
+ * partially-loaded MCP when a post-connect catalog query fails. No-op if the name
1135
+ * is not present.
1136
+ */
1137
+ async deleteOne(mcpName2) {
1138
+ const client = this.clients.get(mcpName2);
1139
+ if (client === void 0) return;
1140
+ this.clients.delete(mcpName2);
1141
+ await client.disconnect();
1142
+ }
964
1143
  get(mcpName2) {
965
1144
  return this.clients.get(mcpName2);
966
1145
  }
@@ -990,10 +1169,18 @@ var UpstreamRegistry = class {
990
1169
  };
991
1170
 
992
1171
  // src/proxy/orchestrator.ts
1172
+ var MAX_LOAD_ATTEMPTS = 3;
993
1173
  var Orchestrator = class {
994
1174
  config;
995
1175
  registry = new UpstreamRegistry();
1176
+ lazyRegistry = new LazyRegistry();
996
1177
  toolsByMcp = /* @__PURE__ */ new Map();
1178
+ /**
1179
+ * In-flight loads, keyed by mcpName. Used by {@link loadMcp} to coalesce concurrent
1180
+ * calls for the same name onto a single underlying connection attempt — per SPEC.md
1181
+ * § "Dynamic Discovery > Concurrency".
1182
+ */
1183
+ inFlightLoads = /* @__PURE__ */ new Map();
997
1184
  resourceRouter = null;
998
1185
  promptRouter = null;
999
1186
  toolCatalog = null;
@@ -1001,9 +1188,14 @@ var Orchestrator = class {
1001
1188
  serverRequestForwarders = {};
1002
1189
  forwarder;
1003
1190
  constructor(config) {
1004
- if (!config.namespaced && config.mcps.size !== 1) {
1191
+ if (!config.namespaced && config.eagerMcps.size !== 1) {
1005
1192
  throw new Error(
1006
- `Single-MCP (non-namespaced) mode requires exactly one upstream; got ${config.mcps.size}.`
1193
+ `Single-MCP (non-namespaced) mode requires exactly one upstream; got ${config.eagerMcps.size}.`
1194
+ );
1195
+ }
1196
+ if (!config.namespaced && config.lazyMcps !== void 0 && config.lazyMcps.size > 0) {
1197
+ throw new Error(
1198
+ "Single-MCP (non-namespaced) mode does not support lazy upstreams (descriptions)."
1007
1199
  );
1008
1200
  }
1009
1201
  this.config = config;
@@ -1012,9 +1204,7 @@ var Orchestrator = class {
1012
1204
  () => this.resourceRouter,
1013
1205
  () => this.promptRouter,
1014
1206
  this.toolsByMcp,
1015
- (catalog) => {
1016
- this.toolCatalog = catalog;
1017
- },
1207
+ () => this.rebuildToolCatalog(),
1018
1208
  this.config.namespaced
1019
1209
  );
1020
1210
  }
@@ -1024,32 +1214,26 @@ var Orchestrator = class {
1024
1214
  setServerRequestForwarders(forwarders) {
1025
1215
  this.serverRequestForwarders = forwarders;
1026
1216
  }
1217
+ /**
1218
+ * True when the orchestrator was configured with at least one lazy upstream MCP.
1219
+ * The `index.ts` wiring uses this to decide whether to register the `load_mcp`
1220
+ * meta-tool with the host-facing {@link ProxyServer}.
1221
+ */
1222
+ get hasDynamicDiscovery() {
1223
+ return this.config.lazyMcps !== void 0 && this.config.lazyMcps.size > 0;
1224
+ }
1027
1225
  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
- }
1226
+ const allNames = [...this.config.eagerMcps.keys(), ...this.config.lazyMcps?.keys() ?? []];
1227
+ const resourceRouter = new ResourceRouter(allNames);
1228
+ const promptRouter = new PromptRouter(allNames);
1229
+ if (this.config.lazyMcps !== void 0) {
1230
+ for (const [name, { transport, description: description2 }] of this.config.lazyMcps) {
1231
+ this.lazyRegistry.register(name, { transport, description: description2 });
1051
1232
  }
1052
- ]);
1233
+ }
1234
+ const upstreamEntries = [
1235
+ ...this.config.eagerMcps
1236
+ ].map(([mcpName2, { transport }]) => [mcpName2, this.buildUpstreamConfig(mcpName2, transport)]);
1053
1237
  await this.registry.connectAll(upstreamEntries);
1054
1238
  const capabilityList = [];
1055
1239
  this.toolsByMcp.clear();
@@ -1071,10 +1255,10 @@ var Orchestrator = class {
1071
1255
  promptRouter.setPrompts(mcpName2, prompts);
1072
1256
  }
1073
1257
  }
1074
- this.toolCatalog = this.config.namespaced ? ToolCatalog.fromGrouped(this.toolsByMcp) : ToolCatalog.fromFlat([...this.toolsByMcp.values()][0] ?? []);
1075
- this.aggregatedCapabilities = aggregateCapabilities(capabilityList);
1076
1258
  this.resourceRouter = resourceRouter;
1077
1259
  this.promptRouter = promptRouter;
1260
+ this.aggregatedCapabilities = aggregateCapabilities(capabilityList);
1261
+ this.rebuildToolCatalog();
1078
1262
  logCollisions(resourceRouter, promptRouter);
1079
1263
  }
1080
1264
  async disconnectAll() {
@@ -1084,6 +1268,177 @@ var Orchestrator = class {
1084
1268
  this.aggregatedCapabilities = null;
1085
1269
  this.resourceRouter = null;
1086
1270
  this.promptRouter = null;
1271
+ this.inFlightLoads.clear();
1272
+ }
1273
+ // === Dynamic discovery ===
1274
+ /**
1275
+ * Loads a lazy upstream MCP on demand. Implements the semantics of SPEC.md §
1276
+ * "Tools > load_mcp" and § "Dynamic Discovery > Lifecycle of a Lazy MCP":
1277
+ *
1278
+ * - Already-loaded (eager or previously-loaded lazy) names succeed as a no-op
1279
+ * returning the current listing; no notifications fire.
1280
+ * - Unknown names throw an error that hints at the still-lazy alternatives.
1281
+ * - Concurrent calls for the same name coalesce onto the same in-flight load.
1282
+ * - A failure during connect/initialize/catalog-query rolls back atomically:
1283
+ * the upstream is disconnected, the lazy entry stays registered, no host
1284
+ * notifications fire.
1285
+ * - On success the loaded MCP is promoted into the connected registry, its
1286
+ * tools/resources/prompts populate the routers, the discover_tool catalog
1287
+ * is regenerated, and the host receives `tools/list_changed` plus
1288
+ * `resources/list_changed` and/or `prompts/list_changed` for any non-empty
1289
+ * surface the MCP contributed.
1290
+ */
1291
+ async loadMcp(mcpName2) {
1292
+ if (this.registry.get(mcpName2) !== void 0) {
1293
+ return this.getListing(mcpName2);
1294
+ }
1295
+ const inFlight = this.inFlightLoads.get(mcpName2);
1296
+ if (inFlight !== void 0) {
1297
+ return inFlight;
1298
+ }
1299
+ if (!this.lazyRegistry.has(mcpName2)) {
1300
+ const lazyNames = this.lazyRegistry.names().join(", ");
1301
+ const hint = lazyNames.length > 0 ? lazyNames : "(none)";
1302
+ throw new Error(`Unknown MCP server: "${mcpName2}". Available servers to load: ${hint}`);
1303
+ }
1304
+ const loadPromise = this.runLoadPipeline(mcpName2).finally(() => {
1305
+ this.inFlightLoads.delete(mcpName2);
1306
+ });
1307
+ this.inFlightLoads.set(mcpName2, loadPromise);
1308
+ return loadPromise;
1309
+ }
1310
+ async runLoadPipeline(mcpName2) {
1311
+ const entry = this.lazyRegistry.get(mcpName2);
1312
+ if (entry === void 0) {
1313
+ throw new Error(`Internal error: lazy entry "${mcpName2}" vanished mid-load.`);
1314
+ }
1315
+ const client = await this.registry.connectOne(
1316
+ mcpName2,
1317
+ this.buildUpstreamConfig(mcpName2, entry.transport)
1318
+ );
1319
+ let tools;
1320
+ let resources = [];
1321
+ let templates = [];
1322
+ let prompts = [];
1323
+ let caps;
1324
+ try {
1325
+ caps = client.getCapabilities();
1326
+ tools = await client.listTools();
1327
+ if (caps?.resources !== void 0) {
1328
+ [resources, templates] = await Promise.all([
1329
+ client.listResources(),
1330
+ client.listResourceTemplates()
1331
+ ]);
1332
+ }
1333
+ if (caps?.prompts !== void 0) {
1334
+ prompts = await client.listPrompts();
1335
+ }
1336
+ } catch (error) {
1337
+ await this.registry.deleteOne(mcpName2);
1338
+ const failures = this.lazyRegistry.recordFailure(mcpName2);
1339
+ if (failures >= MAX_LOAD_ATTEMPTS) {
1340
+ this.lazyRegistry.take(mcpName2);
1341
+ this.rebuildToolCatalog();
1342
+ await this.forwarder.notifyToolsListChanged();
1343
+ const base = error instanceof Error ? error.message : String(error);
1344
+ throw new Error(
1345
+ `Failed to load "${mcpName2}" after ${failures} attempts; the server will no longer be offered for discovery. Underlying error: ${base}`
1346
+ );
1347
+ }
1348
+ throw error;
1349
+ }
1350
+ this.lazyRegistry.take(mcpName2);
1351
+ this.toolsByMcp.set(mcpName2, tools);
1352
+ const resourceRouter = this.requireResourceRouter();
1353
+ const promptRouter = this.requirePromptRouter();
1354
+ if (caps?.resources !== void 0) {
1355
+ resourceRouter.setResources(mcpName2, resources);
1356
+ resourceRouter.setTemplates(mcpName2, templates);
1357
+ }
1358
+ if (caps?.prompts !== void 0) {
1359
+ promptRouter.setPrompts(mcpName2, prompts);
1360
+ }
1361
+ this.rebuildToolCatalog();
1362
+ await this.forwarder.notifyToolsListChanged();
1363
+ if (caps?.resources !== void 0 && (resources.length > 0 || templates.length > 0)) {
1364
+ await this.forwarder.notifyResourcesListChanged();
1365
+ }
1366
+ if (caps?.prompts !== void 0 && prompts.length > 0) {
1367
+ await this.forwarder.notifyPromptsListChanged();
1368
+ }
1369
+ return this.getListing(mcpName2);
1370
+ }
1371
+ /**
1372
+ * Builds the structured response shape documented for `load_mcp` from existing
1373
+ * per-MCP state. Pulled out into a helper so the no-op path (already-loaded)
1374
+ * and the success path share one source of truth.
1375
+ */
1376
+ getListing(mcpName2) {
1377
+ const tools = this.toolsByMcp.get(mcpName2) ?? [];
1378
+ const namespacedToolName = (name) => this.config.namespaced ? `${mcpName2}/${name}` : name;
1379
+ const resources = this.resourceRouter?.resourcesFor(mcpName2) ?? [];
1380
+ const templates = this.resourceRouter?.templatesFor(mcpName2) ?? [];
1381
+ const prompts = this.promptRouter?.promptsFor(mcpName2) ?? [];
1382
+ return {
1383
+ mcp_name: mcpName2,
1384
+ tools: tools.map((tool) => ({
1385
+ name: namespacedToolName(tool.name),
1386
+ description: tool.description
1387
+ })),
1388
+ resources: resources.map((resource) => ({
1389
+ uri: resource.uri,
1390
+ name: resource.name,
1391
+ description: resource.description,
1392
+ mimeType: resource.mimeType
1393
+ })),
1394
+ resource_templates: templates.map((template) => ({
1395
+ uriTemplate: template.uriTemplate,
1396
+ name: template.name,
1397
+ description: template.description,
1398
+ mimeType: template.mimeType
1399
+ })),
1400
+ prompts: prompts.map((prompt) => ({
1401
+ name: prompt.name,
1402
+ description: prompt.description,
1403
+ arguments: prompt.arguments
1404
+ }))
1405
+ };
1406
+ }
1407
+ // === Internal helpers used by connect() and loadMcp() ===
1408
+ buildUpstreamConfig(mcpName2, transport) {
1409
+ return {
1410
+ transport,
1411
+ onTransportError: (error) => {
1412
+ this.config.onTransportError?.(mcpName2, error);
1413
+ },
1414
+ notifications: {
1415
+ onToolsListChanged: () => this.forwarder.handleToolsListChanged(mcpName2),
1416
+ onResourcesListChanged: () => this.forwarder.handleResourcesListChanged(mcpName2),
1417
+ onResourceUpdated: (params) => this.forwarder.handleResourceUpdated(params),
1418
+ onPromptsListChanged: () => this.forwarder.handlePromptsListChanged(mcpName2),
1419
+ onLogMessage: (params) => this.forwarder.handleLogMessage(mcpName2, params)
1420
+ },
1421
+ serverRequests: {
1422
+ onCreateMessage: (params, opts) => this.forwardCreateMessage(params, opts),
1423
+ onElicitInput: (params, opts) => this.forwardElicitInput(params, opts),
1424
+ onListRoots: (params, opts) => this.forwardListRoots(params, opts)
1425
+ }
1426
+ };
1427
+ }
1428
+ /**
1429
+ * Rebuilds the `ToolCatalog` from current state. Called whenever `toolsByMcp` or the
1430
+ * lazy-registry membership changes — including initial connect, upstream-emitted
1431
+ * `tools/list_changed`, and successful `loadMcp`.
1432
+ */
1433
+ rebuildToolCatalog() {
1434
+ if (this.config.namespaced) {
1435
+ this.toolCatalog = ToolCatalog.fromGroupedWithLazy(
1436
+ this.toolsByMcp,
1437
+ this.lazyRegistry.descriptions()
1438
+ );
1439
+ } else {
1440
+ this.toolCatalog = ToolCatalog.fromFlat([...this.toolsByMcp.values()][0] ?? []);
1441
+ }
1087
1442
  }
1088
1443
  get catalog() {
1089
1444
  if (this.toolCatalog === null) {
@@ -1285,7 +1640,9 @@ import {
1285
1640
  import { z as z3 } from "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 = z3.object({ tool_name: z3.string() });
1305
1669
  var UseToolArgsSchema = z3.object({
1306
1670
  tool_name: z3.string(),
1307
1671
  tool_input: z3.record(z3.string(), z3.unknown()).default({})
1308
1672
  });
1673
+ var LoadMcpArgsSchema = z3.object({ mcp_name: z3.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 Server(
@@ -1440,9 +1808,34 @@ var ProxyServer = class {
1440
1808
  await this.sdkServer.sendPromptListChanged();
1441
1809
  }
1442
1810
  }
1811
+ /**
1812
+ * Builds per-call options for a request handler. Extracts the host's
1813
+ * `progressToken` from `_meta` (if any) and wires an `onprogress` callback that
1814
+ * re-emits progress notifications back to the host under that same token. This
1815
+ * is the single seam where progress translation lives — every forward-direction
1816
+ * handler routes through here.
1817
+ */
1818
+ buildCallOptions(request, extra) {
1819
+ const options = { signal: extra.signal };
1820
+ const progressToken = request.params._meta?.progressToken;
1821
+ if (progressToken !== void 0) {
1822
+ options.onprogress = (progress) => {
1823
+ void extra.sendNotification({
1824
+ method: "notifications/progress",
1825
+ params: {
1826
+ progressToken,
1827
+ progress: progress.progress,
1828
+ total: progress.total,
1829
+ message: progress.message
1830
+ }
1831
+ });
1832
+ };
1833
+ }
1834
+ return options;
1835
+ }
1443
1836
  registerToolHandlers(server) {
1444
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
1445
- tools: [
1837
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
1838
+ const tools = [
1446
1839
  {
1447
1840
  name: DISCOVER_TOOL_NAME,
1448
1841
  description: this.catalog().discoverToolDescription,
@@ -1453,8 +1846,16 @@ var ProxyServer = class {
1453
1846
  description: USE_TOOL_DESCRIPTION,
1454
1847
  inputSchema: USE_TOOL_INPUT_SCHEMA
1455
1848
  }
1456
- ]
1457
- }));
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
+ });
1458
1859
  server.setRequestHandler(
1459
1860
  CallToolRequestSchema,
1460
1861
  async (request, extra) => {
@@ -1473,7 +1874,30 @@ var ProxyServer = class {
1473
1874
  content: [{ type: "text", text: catalog.getToolDetails(args.tool_name) }]
1474
1875
  };
1475
1876
  }
1476
- return await this.callTool(args.tool_name, args.tool_input, { signal: extra.signal });
1877
+ return await this.callTool(
1878
+ args.tool_name,
1879
+ args.tool_input,
1880
+ this.buildCallOptions(request, extra)
1881
+ );
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
+ }
1477
1901
  }
1478
1902
  return {
1479
1903
  isError: true,
@@ -1498,15 +1922,18 @@ var ProxyServer = class {
1498
1922
  server.setRequestHandler(
1499
1923
  ReadResourceRequestSchema,
1500
1924
  async (request, extra) => {
1501
- return callbacks.readResource(request.params.uri, { signal: extra.signal });
1925
+ return callbacks.readResource(request.params.uri, this.buildCallOptions(request, extra));
1502
1926
  }
1503
1927
  );
1504
1928
  server.setRequestHandler(SubscribeRequestSchema, async (request, extra) => {
1505
- await callbacks.subscribeResource(request.params.uri, { signal: extra.signal });
1929
+ await callbacks.subscribeResource(request.params.uri, this.buildCallOptions(request, extra));
1506
1930
  return {};
1507
1931
  });
1508
1932
  server.setRequestHandler(UnsubscribeRequestSchema, async (request, extra) => {
1509
- await callbacks.unsubscribeResource(request.params.uri, { signal: extra.signal });
1933
+ await callbacks.unsubscribeResource(
1934
+ request.params.uri,
1935
+ this.buildCallOptions(request, extra)
1936
+ );
1510
1937
  return {};
1511
1938
  });
1512
1939
  }
@@ -1520,9 +1947,11 @@ var ProxyServer = class {
1520
1947
  server.setRequestHandler(
1521
1948
  GetPromptRequestSchema,
1522
1949
  async (request, extra) => {
1523
- return callbacks.getPrompt(request.params.name, request.params.arguments, {
1524
- signal: extra.signal
1525
- });
1950
+ return callbacks.getPrompt(
1951
+ request.params.name,
1952
+ request.params.arguments,
1953
+ this.buildCallOptions(request, extra)
1954
+ );
1526
1955
  }
1527
1956
  );
1528
1957
  }
@@ -1530,13 +1959,13 @@ var ProxyServer = class {
1530
1959
  server.setRequestHandler(
1531
1960
  CompleteRequestSchema,
1532
1961
  async (request, extra) => {
1533
- return callback(request.params, { signal: extra.signal });
1962
+ return callback(request.params, this.buildCallOptions(request, extra));
1534
1963
  }
1535
1964
  );
1536
1965
  }
1537
1966
  registerLoggingHandler(server, callback) {
1538
1967
  server.setRequestHandler(SetLevelRequestSchema, async (request, extra) => {
1539
- await callback(request.params.level, { signal: extra.signal });
1968
+ await callback(request.params.level, this.buildCallOptions(request, extra));
1540
1969
  return {};
1541
1970
  });
1542
1971
  }
@@ -1584,9 +2013,9 @@ function createTransport(config) {
1584
2013
  var SINGLE_MCP_NAME = "__default__";
1585
2014
  async function startProxy(command, args) {
1586
2015
  const transport = new StdioClientTransport2({ command, args });
1587
- const mcps = /* @__PURE__ */ new Map([[SINGLE_MCP_NAME, { transport }]]);
2016
+ const eagerMcps = /* @__PURE__ */ new Map([[SINGLE_MCP_NAME, { transport }]]);
1588
2017
  const orchestrator = buildOrchestrator({
1589
- mcps,
2018
+ eagerMcps,
1590
2019
  namespaced: false,
1591
2020
  transportErrorPrefix: () => "Upstream MCP"
1592
2021
  });
@@ -1594,12 +2023,19 @@ async function startProxy(command, args) {
1594
2023
  }
1595
2024
  async function startProxyFromConfig(options = {}) {
1596
2025
  const config = loadConfig(options);
1597
- const mcps = /* @__PURE__ */ new Map();
2026
+ const eagerMcps = /* @__PURE__ */ new Map();
2027
+ const lazyMcps = /* @__PURE__ */ new Map();
1598
2028
  for (const [name, entry] of Object.entries(config.mcp)) {
1599
- 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
+ }
1600
2035
  }
1601
2036
  const orchestrator = buildOrchestrator({
1602
- mcps,
2037
+ eagerMcps,
2038
+ lazyMcps,
1603
2039
  namespaced: true,
1604
2040
  transportErrorPrefix: (mcpName2) => `Upstream MCP "${mcpName2}"`
1605
2041
  });
@@ -1608,7 +2044,8 @@ async function startProxyFromConfig(options = {}) {
1608
2044
  var activeShutdown = { shutdown: null };
1609
2045
  function buildOrchestrator(params) {
1610
2046
  return new Orchestrator({
1611
- mcps: params.mcps,
2047
+ eagerMcps: params.eagerMcps,
2048
+ lazyMcps: params.lazyMcps,
1612
2049
  namespaced: params.namespaced,
1613
2050
  onTransportError: (mcpName2, error) => {
1614
2051
  process6.stderr.write(
@@ -1657,7 +2094,10 @@ async function runProxy(orchestrator) {
1657
2094
  } : void 0,
1658
2095
  complete: orchestrator.capabilities.completions !== void 0 ? (params, options) => orchestrator.complete(params, options) : void 0,
1659
2096
  setLoggingLevel: orchestrator.capabilities.logging !== void 0 ? (level, options) => orchestrator.setLoggingLevel(level, options) : void 0,
1660
- 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
1661
2101
  });
1662
2102
  orchestrator.setNotificationHandlers({
1663
2103
  onToolsListChanged: () => proxyServer.sendToolListChanged(),