@skilder-ai/runtime 0.7.3 → 0.7.5

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
@@ -138392,6 +138392,7 @@ var McpServerStatus = /* @__PURE__ */ ((McpServerStatus2) => {
138392
138392
  McpServerStatus2["Connected"] = "CONNECTED";
138393
138393
  McpServerStatus2["Disconnected"] = "DISCONNECTED";
138394
138394
  McpServerStatus2["Error"] = "ERROR";
138395
+ McpServerStatus2["Idle"] = "IDLE";
138395
138396
  McpServerStatus2["Pending"] = "PENDING";
138396
138397
  McpServerStatus2["Starting"] = "STARTING";
138397
138398
  return McpServerStatus2;
@@ -138461,6 +138462,34 @@ var WorkspaceRole = /* @__PURE__ */ ((WorkspaceRole2) => {
138461
138462
  return WorkspaceRole2;
138462
138463
  })(WorkspaceRole || {});
138463
138464
 
138465
+ // ../common/src/utils/parse-script-args.ts
138466
+ function parseScriptArgs(argsString) {
138467
+ const args = [];
138468
+ let current = "";
138469
+ let inQuotes = false;
138470
+ let quoteChar = "";
138471
+ for (const char of argsString) {
138472
+ if ((char === '"' || char === "'") && !inQuotes) {
138473
+ inQuotes = true;
138474
+ quoteChar = char;
138475
+ } else if (char === quoteChar && inQuotes) {
138476
+ inQuotes = false;
138477
+ quoteChar = "";
138478
+ } else if (char === " " && !inQuotes) {
138479
+ if (current) {
138480
+ args.push(current);
138481
+ current = "";
138482
+ }
138483
+ } else {
138484
+ current += char;
138485
+ }
138486
+ }
138487
+ if (current) {
138488
+ args.push(current);
138489
+ }
138490
+ return args;
138491
+ }
138492
+
138464
138493
  // ../common/src/related-content-detector.ts
138465
138494
  function escapeRegex2(str) {
138466
138495
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -144820,39 +144849,9 @@ For other resources, use the \`learn\` tool instead.`
144820
144849
  };
144821
144850
  }
144822
144851
  const script = scriptEntry;
144823
- const args = lookupArgs ? this.parseScriptArgs(lookupArgs) : [];
144852
+ const args = lookupArgs ? parseScriptArgs(lookupArgs) : [];
144824
144853
  return await this.routeScriptToRuntime(script, skill, args);
144825
144854
  }
144826
- /**
144827
- * Parse script arguments from the path string.
144828
- * Arguments can be space-separated, with quoted strings supported.
144829
- */
144830
- parseScriptArgs(argsString) {
144831
- const args = [];
144832
- let current = "";
144833
- let inQuotes = false;
144834
- let quoteChar = "";
144835
- for (const char of argsString) {
144836
- if ((char === '"' || char === "'") && !inQuotes) {
144837
- inQuotes = true;
144838
- quoteChar = char;
144839
- } else if (char === quoteChar && inQuotes) {
144840
- inQuotes = false;
144841
- quoteChar = "";
144842
- } else if (char === " " && !inQuotes) {
144843
- if (current) {
144844
- args.push(current);
144845
- current = "";
144846
- }
144847
- } else {
144848
- current += char;
144849
- }
144850
- }
144851
- if (current) {
144852
- args.push(current);
144853
- }
144854
- return args;
144855
- }
144856
144855
  /**
144857
144856
  * Route script execution to a runtime via NATS.
144858
144857
  * Scripts always execute on the runtime linked to the script.
@@ -145178,34 +145177,68 @@ This file can be used with other file I/O tools that accept \`@skilder-file:/\`
145178
145177
  const lines = [];
145179
145178
  lines.push(`# Skill: ${skill.name}`);
145180
145179
  lines.push("");
145180
+ lines.push(...this.formatSkillDetails(skill));
145181
+ return {
145182
+ response: { content: [{ type: "text", text: lines.join("\n") }] },
145183
+ skillIds: [skill.id]
145184
+ };
145185
+ }
145186
+ /**
145187
+ * Format full details for a single skill including description, instructions, tool schemas,
145188
+ * and referenced resources. Used by both handleLearnSkill and handleGetHat to ensure DRY
145189
+ * consistency when rendering skill content.
145190
+ *
145191
+ * @param skill - The skill catalog entry to format
145192
+ * @param options.headingLevel - Heading level for tools section (2 for standalone, 4 for within-hat)
145193
+ * @param options.renderedToolNames - Set of tool names already rendered (for deduplication across skills in a hat)
145194
+ */
145195
+ formatSkillDetails(skill, options) {
145196
+ const headingLevel = options?.headingLevel ?? 2;
145197
+ const heading = "#".repeat(headingLevel);
145198
+ const renderedToolNames = options?.renderedToolNames;
145199
+ const firstSkillForTool = options?.firstSkillForTool;
145200
+ const lines = [];
145181
145201
  if (skill.description) {
145182
145202
  lines.push(skill.description);
145183
145203
  lines.push("");
145184
145204
  }
145185
145205
  if (skill.instructions) {
145186
- lines.push("## Instructions");
145206
+ lines.push(`${heading} Instructions`);
145187
145207
  lines.push(skill.instructions);
145188
145208
  lines.push("");
145189
145209
  }
145190
145210
  if (skill.toolNames.length > 0) {
145191
- lines.push("## Available Tools");
145211
+ lines.push(`${heading} Available Tools`);
145192
145212
  lines.push("Use `call_tool` with the tool name and input to execute these tools:");
145193
145213
  lines.push("");
145194
145214
  for (const toolName of skill.toolNames) {
145195
- const tool2 = this.tools.getValue()?.find((t4) => t4.name === toolName);
145196
- if (tool2) {
145197
- lines.push(`### ${tool2.name}`);
145198
- lines.push(tool2.description || "No description");
145199
- lines.push("");
145200
- lines.push("**Input Schema:**");
145201
- lines.push("```json");
145202
- lines.push(JSON.stringify(tool2.inputSchema, null, 2));
145203
- lines.push("```");
145215
+ const subHeading = "#".repeat(headingLevel + 1);
145216
+ if (renderedToolNames?.has(toolName)) {
145217
+ const firstSkill = firstSkillForTool?.get(toolName);
145218
+ lines.push(`${subHeading} ${toolName}`);
145219
+ lines.push(firstSkill ? `(schema shown above under ${firstSkill})` : "(schema shown above)");
145204
145220
  lines.push("");
145205
145221
  } else {
145206
- lines.push(`### ${toolName}`);
145207
- lines.push("Tool definition not available");
145208
- lines.push("");
145222
+ if (renderedToolNames) {
145223
+ renderedToolNames.add(toolName);
145224
+ firstSkillForTool?.set(toolName, skill.name);
145225
+ }
145226
+ const tool2 = this.tools.getValue()?.find((t4) => t4.name === toolName);
145227
+ if (tool2) {
145228
+ lines.push(`${subHeading} ${tool2.name}`);
145229
+ lines.push(tool2.description || "No description");
145230
+ lines.push("");
145231
+ lines.push("**Input Schema:**");
145232
+ lines.push("```json");
145233
+ lines.push(JSON.stringify(tool2.inputSchema, null, 2));
145234
+ lines.push("```");
145235
+ lines.push("");
145236
+ } else {
145237
+ this.logger.warn({ toolName, skillId: skill.id, toolsInitialized: this.tools.getValue() != null }, `Tool "${toolName}" referenced by skill "${skill.name}" not found in tools list`);
145238
+ lines.push(`${subHeading} ${toolName}`);
145239
+ lines.push("Tool definition not available");
145240
+ lines.push("");
145241
+ }
145209
145242
  }
145210
145243
  }
145211
145244
  lines.push('Call these tools using: `call_tool(tool_name="<name>", tool_input={...})`');
@@ -145221,10 +145254,7 @@ This file can be used with other file I/O tools that accept \`@skilder-file:/\`
145221
145254
  } catch (error48) {
145222
145255
  this.logger.warn({ error: error48, skillId: skill.id }, "Failed to detect related mentions in skill instructions");
145223
145256
  }
145224
- return {
145225
- response: { content: [{ type: "text", text: lines.join("\n") }] },
145226
- skillIds: [skill.id]
145227
- };
145257
+ return lines;
145228
145258
  }
145229
145259
  /**
145230
145260
  * Handle feedback_skill tool call.
@@ -145496,27 +145526,19 @@ ${JSON.stringify(toolConfig.inputSchema, null, 2)}`
145496
145526
  }
145497
145527
  this.session.learnedHatIds.add(hat.id);
145498
145528
  const skills = [];
145499
- const allToolNames = /* @__PURE__ */ new Set();
145500
145529
  const missingSkillIds = [];
145501
145530
  for (const skillId of hat.skillIds) {
145502
145531
  const skill = this.catalog.skills.find((s) => s.id === skillId);
145503
145532
  if (skill) {
145504
145533
  this.session.learnedSkillIds.add(skill.id);
145505
- skills.push({
145506
- name: skill.name,
145507
- description: skill.description,
145508
- instructions: skill.instructions,
145509
- toolNames: skill.toolNames
145510
- });
145511
- for (const toolName of skill.toolNames) {
145512
- allToolNames.add(toolName);
145513
- }
145534
+ skills.push(skill);
145514
145535
  } else {
145515
- this.logger.warn(`Skill ID "${skillId}" referenced in hat "${hat.name}" not found in catalog`);
145536
+ this.logger.warn({ skillId, hatId: hat.id, hatName: hat.name }, "Skill referenced in hat not found in catalog");
145516
145537
  missingSkillIds.push(skillId);
145517
145538
  }
145518
145539
  }
145519
- this.logger.info(`User ${this.identity.id} retrieved hat "${hat.name}" with ${skills.length} skills and ${allToolNames.size} tools`);
145540
+ const totalToolCount = new Set(skills.flatMap((s) => s.toolNames)).size;
145541
+ this.logger.info(`User ${this.identity.id} retrieved hat "${hat.name}" with ${skills.length} skills and ${totalToolCount} tools`);
145520
145542
  const lines = [];
145521
145543
  lines.push(`# Hat: ${hat.name}`);
145522
145544
  lines.push("");
@@ -145532,43 +145554,18 @@ ${JSON.stringify(toolConfig.inputSchema, null, 2)}`
145532
145554
  if (skills.length > 0) {
145533
145555
  lines.push("## Skills Learned");
145534
145556
  lines.push("");
145557
+ const renderedToolNames = /* @__PURE__ */ new Set();
145558
+ const firstSkillForTool = /* @__PURE__ */ new Map();
145535
145559
  for (const skill of skills) {
145536
145560
  lines.push(`### ${skill.name}`);
145537
- if (skill.description) {
145538
- lines.push(skill.description);
145539
- }
145540
- if (skill.instructions) {
145541
- lines.push("");
145542
- lines.push("**Instructions:**");
145543
- lines.push(skill.instructions);
145544
- }
145545
- if (skill.toolNames.length > 0) {
145546
- lines.push("");
145547
- lines.push(`**Tools:** ${skill.toolNames.join(", ")}`);
145548
- }
145561
+ lines.push(...this.formatSkillDetails(skill, { headingLevel: 4, renderedToolNames, firstSkillForTool }));
145549
145562
  lines.push("");
145550
145563
  }
145551
145564
  } else {
145552
145565
  lines.push("This hat contains no skills.");
145553
145566
  lines.push("");
145554
145567
  }
145555
- if (allToolNames.size > 0) {
145556
- lines.push("## All Available Tools");
145557
- lines.push("");
145558
- lines.push("The following tools are now available:");
145559
- for (const toolName of allToolNames) {
145560
- const tool2 = this.tools.getValue()?.find((t4) => t4.name === toolName);
145561
- if (tool2) {
145562
- lines.push(`- **${tool2.name}**: ${tool2.description || "No description"}`);
145563
- } else {
145564
- lines.push(`- **${toolName}**`);
145565
- }
145566
- }
145567
- lines.push("");
145568
- lines.push("You can now use these tools to help the user.");
145569
- }
145570
145568
  if (missingSkillIds.length > 0) {
145571
- lines.push("");
145572
145569
  lines.push("## Warning");
145573
145570
  lines.push("");
145574
145571
  lines.push(`Some skills referenced by this hat could not be loaded: ${missingSkillIds.join(", ")}`);
@@ -152628,10 +152625,29 @@ var ToolService = class ToolService2 extends Service {
152628
152625
  this.rxSubscriptions = [];
152629
152626
  this.sdkBundleContent = null;
152630
152627
  this.sdkBundleLoadPromise = null;
152628
+ this.dormantConfigs = /* @__PURE__ */ new Map();
152629
+ this.idleTimers = /* @__PURE__ */ new Map();
152630
+ this.startingServers = /* @__PURE__ */ new Map();
152631
+ this.stoppingServers = /* @__PURE__ */ new Map();
152632
+ this.activationFailures = /* @__PURE__ */ new Map();
152633
+ this.maxActivationRetries = 3;
152634
+ this.activationCooldownMs = 6e4;
152635
+ this.lazyStartEnabled = true;
152636
+ this.idleTimeoutMs = 3e5;
152631
152637
  this.logger = this.loggerService.getLogger(this.name);
152632
152638
  }
152633
152639
  async initialize() {
152634
152640
  this.logger.info("Starting");
152641
+ const lazyStartRaw = (process.env.MCP_LAZY_START ?? "true").toLowerCase();
152642
+ this.lazyStartEnabled = !["false", "0", "no", "off"].includes(lazyStartRaw);
152643
+ const parsedTimeout = parseInt(process.env.MCP_IDLE_TIMEOUT_MS ?? "300000", 10);
152644
+ if (Number.isNaN(parsedTimeout) || parsedTimeout <= 0) {
152645
+ this.logger.warn({ rawValue: process.env.MCP_IDLE_TIMEOUT_MS }, "Invalid MCP_IDLE_TIMEOUT_MS value, falling back to default 300000ms");
152646
+ this.idleTimeoutMs = 3e5;
152647
+ } else {
152648
+ this.idleTimeoutMs = parsedTimeout;
152649
+ }
152650
+ this.logger.info({ lazyStart: this.lazyStartEnabled, idleTimeoutMs: this.idleTimeoutMs }, "Lazy-start configuration");
152635
152651
  await this.authService.waitForStarted();
152636
152652
  await this.natsService.waitForStarted();
152637
152653
  await this.healthService.waitForStarted();
@@ -152666,7 +152682,7 @@ var ToolService = class ToolService2 extends Service {
152666
152682
  this.natsService.publish(message);
152667
152683
  this.logger.debug(`Published MCP server status: ${mcpServerId} -> ${status}${error48 ? ` (${error48})` : ""}`);
152668
152684
  } catch (publishError) {
152669
- this.logger.error(`Failed to publish MCP server status ${mcpServerId} -> ${status}: ${publishError}`);
152685
+ this.logger.error({ mcpServerId, status, err: publishError }, "Failed to publish MCP server status");
152670
152686
  }
152671
152687
  }
152672
152688
  async shutdown() {
@@ -152697,19 +152713,25 @@ var ToolService = class ToolService2 extends Service {
152697
152713
  this.logger.debug(`Processing MCP Servers update with ${msg.data.mcpServers.length} MCP Servers`);
152698
152714
  this.roots = msg.data.roots;
152699
152715
  const mcpServerIds = msg.data.mcpServers.map((mcpServer) => mcpServer.id);
152700
- const mcpServersToStop = Array.from(this.mcpServers.keys()).filter((mcpServerId) => !mcpServerIds.includes(mcpServerId));
152701
- for (const mcpServerId of mcpServersToStop) {
152716
+ const runningToStop = Array.from(this.mcpServers.keys()).filter((id) => !mcpServerIds.includes(id));
152717
+ const dormantToRemove = Array.from(this.dormantConfigs.keys()).filter((id) => !mcpServerIds.includes(id));
152718
+ for (const mcpServerId of runningToStop) {
152702
152719
  const service = this.mcpServers.get(mcpServerId);
152703
152720
  await this.stopMCPServer({ id: mcpServerId, name: service.getName() });
152704
152721
  }
152722
+ for (const mcpServerId of dormantToRemove) {
152723
+ this.removeDormantServer(mcpServerId);
152724
+ }
152705
152725
  for (const mcpServer of msg.data.mcpServers) {
152706
- await this.spawnMCPServer(mcpServer).catch(async (error48) => {
152707
- this.logger.error(`Failed to spawn MCP Server ${mcpServer.name}: ${error48}`);
152726
+ await this.registerMCPServer(mcpServer).catch(async (error48) => {
152727
+ this.logger.error({ mcpServerId: mcpServer.id, mcpServerName: mcpServer.name, err: error48 }, "Failed to register MCP Server");
152728
+ this.publishMCPServerStatus(mcpServer.workspace?.id, mcpServer.id, "ERROR", error48 instanceof Error ? error48.message : String(error48));
152708
152729
  const service = this.mcpServers.get(mcpServer.id);
152709
152730
  if (service) {
152710
152731
  await this.stopService(service);
152711
152732
  }
152712
152733
  this.mcpServers.delete(mcpServer.id);
152734
+ this.removeDormantServer(mcpServer.id);
152713
152735
  });
152714
152736
  }
152715
152737
  }
@@ -152737,6 +152759,13 @@ var ToolService = class ToolService2 extends Service {
152737
152759
  }
152738
152760
  this.cacheSubscriptions = [];
152739
152761
  }
152762
+ if (this.startingServers.size > 0 || this.stoppingServers.size > 0) {
152763
+ this.logger.debug({ starting: this.startingServers.size, stopping: this.stoppingServers.size }, "Waiting for in-flight MCP server activations/stops to settle");
152764
+ await Promise.allSettled([
152765
+ ...Array.from(this.startingServers.values()),
152766
+ ...Array.from(this.stoppingServers.values())
152767
+ ]);
152768
+ }
152740
152769
  const serverIds = Array.from(this.mcpServers.keys());
152741
152770
  for (const mcpServerId of serverIds) {
152742
152771
  const service = this.mcpServers.get(mcpServerId);
@@ -152744,20 +152773,76 @@ var ToolService = class ToolService2 extends Service {
152744
152773
  continue;
152745
152774
  await this.stopMCPServer({ id: mcpServerId, name: service.getName() });
152746
152775
  }
152776
+ for (const mcpServerId of Array.from(this.dormantConfigs.keys())) {
152777
+ this.removeDormantServer(mcpServerId);
152778
+ }
152779
+ for (const timer of this.idleTimers.values()) {
152780
+ clearTimeout(timer);
152781
+ }
152782
+ this.idleTimers.clear();
152747
152783
  }
152748
152784
  /**
152749
- * Spawn an MCP Server
152750
- * - the mcpServer argument contains the list of tools and capabilities that the MCP Server advertises
152785
+ * Register an MCP Server. For STDIO servers with lazy-start enabled, the server is registered
152786
+ * as dormant (config stored, NATS tool subscriptions active, no subprocess spawned).
152787
+ * For non-STDIO servers or when lazy-start is disabled, the server is activated immediately.
152751
152788
  */
152752
- async spawnMCPServer(mcpServer) {
152789
+ async registerMCPServer(mcpServer) {
152753
152790
  const roots = this.getRoots();
152754
- const existingMcpServer = this.mcpServers.get(mcpServer.id);
152755
- if (existingMcpServer && getConfigSignature(existingMcpServer.getConfig(), existingMcpServer.getRoots()) === getConfigSignature(mcpServer, roots)) {
152756
- this.logger.debug(`MCPServer ${mcpServer.name} already running -> skipping spawn`);
152757
- const tools2 = mcpServer.tools ?? [];
152758
- this.ensureToolsSubscribed(mcpServer.id, tools2);
152791
+ const newSignature = getConfigSignature(mcpServer, roots);
152792
+ const existingRunning = this.mcpServers.get(mcpServer.id);
152793
+ if (existingRunning && getConfigSignature(existingRunning.getConfig(), existingRunning.getRoots()) === newSignature) {
152794
+ this.logger.debug(`MCPServer ${mcpServer.name} already running -> skipping`);
152795
+ this.ensureToolsSubscribed(mcpServer.id, mcpServer.tools ?? []);
152759
152796
  return;
152760
152797
  }
152798
+ const existingDormant = this.dormantConfigs.get(mcpServer.id);
152799
+ if (existingDormant && existingDormant.configSignature === newSignature) {
152800
+ this.logger.debug(`MCPServer ${mcpServer.name} already dormant with same config -> skipping`);
152801
+ this.ensureToolsSubscribed(mcpServer.id, mcpServer.tools ?? []);
152802
+ return;
152803
+ }
152804
+ if (existingRunning) {
152805
+ this.logger.debug(`MCPServer ${mcpServer.name} config changed -> stopping`);
152806
+ await this.stopMCPServer(mcpServer);
152807
+ }
152808
+ if (existingDormant) {
152809
+ this.dormantConfigs.delete(mcpServer.id);
152810
+ }
152811
+ this.activationFailures.delete(mcpServer.id);
152812
+ const isStdio = mcpServer.transport === dgraph_resolvers_types_exports.McpTransportType.Stdio;
152813
+ const hasKnownTools = (mcpServer.tools?.length ?? 0) > 0;
152814
+ if (this.lazyStartEnabled && isStdio && hasKnownTools) {
152815
+ this.logger.info(`Registering MCPServer ${mcpServer.name} as dormant (lazy-start)`);
152816
+ this.dormantConfigs.set(mcpServer.id, { config: mcpServer, configSignature: newSignature });
152817
+ if (mcpServer.workspace?.id) {
152818
+ this.mcpServerWorkspaces.set(mcpServer.id, mcpServer.workspace.id);
152819
+ }
152820
+ this.ensureToolsSubscribed(mcpServer.id, mcpServer.tools ?? []);
152821
+ this.publishMCPServerStatus(mcpServer.workspace?.id, mcpServer.id, "IDLE");
152822
+ return;
152823
+ }
152824
+ if (this.lazyStartEnabled && isStdio && !hasKnownTools) {
152825
+ this.logger.info(`MCPServer ${mcpServer.name} has no known tools -> activating eagerly for tool discovery`);
152826
+ }
152827
+ await this.activateMCPServer(mcpServer);
152828
+ }
152829
+ /**
152830
+ * Activate an MCP Server: spawn the subprocess, connect, and start observing tools.
152831
+ * Can be called eagerly (from registerMCPServer) or on-demand (from ensureServerActive).
152832
+ */
152833
+ async activateMCPServer(mcpServerOrId) {
152834
+ let mcpServer;
152835
+ let isDormantActivation = false;
152836
+ if (typeof mcpServerOrId === "string") {
152837
+ const dormant = this.dormantConfigs.get(mcpServerOrId);
152838
+ if (!dormant)
152839
+ throw new Error(`No dormant config found for MCP Server ${mcpServerOrId}`);
152840
+ mcpServer = dormant.config;
152841
+ isDormantActivation = true;
152842
+ } else {
152843
+ mcpServer = mcpServerOrId;
152844
+ }
152845
+ const roots = this.getRoots();
152761
152846
  let authProvider;
152762
152847
  if (mcpServer.oauthProvider === "MCP_OAUTH") {
152763
152848
  const identity = this.authService.getIdentity();
@@ -152790,7 +152875,7 @@ var ToolService = class ToolService2 extends Service {
152790
152875
  }
152791
152876
  }
152792
152877
  const mcpServerService = this.toolServerServiceFactory(mcpServer, roots, authProvider);
152793
- this.logger.info(`Spawning MCPServer: ${mcpServer.name} with ${mcpServer.tools?.length ?? 0} tools, and roots: ${JSON.stringify(roots)}`);
152878
+ this.logger.info(`Activating MCPServer: ${mcpServer.name} with ${mcpServer.tools?.length ?? 0} tools, and roots: ${JSON.stringify(roots)}`);
152794
152879
  if (this.mcpServers.has(mcpServer.id)) {
152795
152880
  this.logger.debug(`MCPServer ${mcpServer.name} already running -> shutting down`);
152796
152881
  await this.stopMCPServer(mcpServer);
@@ -152808,11 +152893,32 @@ var ToolService = class ToolService2 extends Service {
152808
152893
  this.mcpServerWorkspaces.delete(mcpServer.id);
152809
152894
  throw error48;
152810
152895
  }
152896
+ if (isDormantActivation) {
152897
+ this.dormantConfigs.delete(mcpServer.id);
152898
+ }
152811
152899
  let hasPublishedConnected = false;
152812
152900
  mcpServerService.observeTools().subscribe({
152813
152901
  error: (err) => {
152814
- this.logger.error(`Tools observable error for MCP Server ${mcpServer.id}: ${err}`);
152902
+ this.logger.error({ mcpServerId: mcpServer.id, err }, "Tools observable error for MCP Server");
152815
152903
  this.publishMCPServerStatus(mcpServer.workspace?.id, mcpServer.id, "ERROR", err instanceof Error ? err.message : String(err));
152904
+ this.mcpServers.delete(mcpServer.id);
152905
+ this.mcpTools.delete(mcpServer.id);
152906
+ const idleTimer = this.idleTimers.get(mcpServer.id);
152907
+ if (idleTimer) {
152908
+ clearTimeout(idleTimer);
152909
+ this.idleTimers.delete(mcpServer.id);
152910
+ }
152911
+ const stopPromise = this.stopService(mcpServerService).catch((stopErr) => this.logger.error({ mcpServerId: mcpServer.id, err: stopErr }, "Failed to stop errored MCP server")).then(() => {
152912
+ this.stoppingServers.delete(mcpServer.id);
152913
+ if (this.lazyStartEnabled && mcpServer.transport === dgraph_resolvers_types_exports.McpTransportType.Stdio) {
152914
+ this.dormantConfigs.set(mcpServer.id, {
152915
+ config: mcpServer,
152916
+ configSignature: getConfigSignature(mcpServer, roots)
152917
+ });
152918
+ this.publishMCPServerStatus(mcpServer.workspace?.id, mcpServer.id, "IDLE");
152919
+ }
152920
+ });
152921
+ this.stoppingServers.set(mcpServer.id, stopPromise);
152816
152922
  },
152817
152923
  next: (tools2) => {
152818
152924
  this.logger.debug(`Updating tools for MCP Server ${mcpServer.id} with ${tools2.length} tools`);
@@ -152833,13 +152939,119 @@ var ToolService = class ToolService2 extends Service {
152833
152939
  });
152834
152940
  this.natsService.publish(message);
152835
152941
  } catch (publishError) {
152836
- this.logger.warn(`Failed to publish discovered tools for ${mcpServer.id}: ${publishError}`);
152942
+ this.logger.error({ mcpServerId: mcpServer.id, err: publishError }, "Failed to publish discovered tools \u2014 backend may show stale tool list");
152837
152943
  }
152838
152944
  }
152839
152945
  });
152840
152946
  const tools = mcpServer.tools ?? [];
152841
152947
  this.ensureToolsSubscribed(mcpServer.id, tools);
152842
- this.logger.info(`MCPServer ${mcpServer.name} spawned`);
152948
+ if (this.lazyStartEnabled && mcpServer.transport === dgraph_resolvers_types_exports.McpTransportType.Stdio) {
152949
+ this.resetIdleTimer(mcpServer.id);
152950
+ }
152951
+ this.logger.info(`MCPServer ${mcpServer.name} activated`);
152952
+ return mcpServerService;
152953
+ }
152954
+ /**
152955
+ * Ensure a dormant server is activated, deduplicating concurrent activation requests.
152956
+ * Waits for any in-flight stop to complete before re-activating.
152957
+ */
152958
+ async ensureServerActive(mcpServerId) {
152959
+ const existing = this.startingServers.get(mcpServerId);
152960
+ if (existing)
152961
+ return existing;
152962
+ const stopping = this.stoppingServers.get(mcpServerId);
152963
+ if (stopping) {
152964
+ await stopping;
152965
+ }
152966
+ const promise2 = this.activateMCPServer(mcpServerId);
152967
+ this.startingServers.set(mcpServerId, promise2);
152968
+ try {
152969
+ const service = await promise2;
152970
+ if (!this.mcpServers.has(mcpServerId)) {
152971
+ throw new Error(`MCP server ${mcpServerId} was deregistered during activation`);
152972
+ }
152973
+ return service;
152974
+ } finally {
152975
+ this.startingServers.delete(mcpServerId);
152976
+ }
152977
+ }
152978
+ /**
152979
+ * Reset the idle timer for a lazy-started server.
152980
+ */
152981
+ resetIdleTimer(mcpServerId) {
152982
+ const existing = this.idleTimers.get(mcpServerId);
152983
+ if (existing)
152984
+ clearTimeout(existing);
152985
+ const timer = setTimeout(() => {
152986
+ this.idleShutdown(mcpServerId).catch((err) => this.logger.error({ mcpServerId, err }, "Idle shutdown failed"));
152987
+ }, this.idleTimeoutMs);
152988
+ this.idleTimers.set(mcpServerId, timer);
152989
+ }
152990
+ /**
152991
+ * Shut down an idle MCP server and move it back to dormant state.
152992
+ * NATS tool subscriptions and mcpTools are kept alive for on-demand re-activation.
152993
+ */
152994
+ async idleShutdown(mcpServerId) {
152995
+ if (this.startingServers.has(mcpServerId)) {
152996
+ this.resetIdleTimer(mcpServerId);
152997
+ return;
152998
+ }
152999
+ const server = this.mcpServers.get(mcpServerId);
153000
+ if (!server) {
153001
+ this.logger.debug({ mcpServerId }, "Idle timer fired for server not in mcpServers map, ignoring");
153002
+ this.idleTimers.delete(mcpServerId);
153003
+ return;
153004
+ }
153005
+ const config2 = server.getConfig();
153006
+ const roots = server.getRoots();
153007
+ this.logger.info(`Idle shutdown for MCP server ${config2.name}`);
153008
+ try {
153009
+ await this.stopService(server);
153010
+ } catch (err) {
153011
+ this.logger.error({ mcpServerId, err }, "Error stopping service during idle shutdown");
153012
+ this.mcpServers.delete(mcpServerId);
153013
+ this.idleTimers.delete(mcpServerId);
153014
+ this.dormantConfigs.set(mcpServerId, {
153015
+ config: config2,
153016
+ configSignature: getConfigSignature(config2, roots)
153017
+ });
153018
+ const workspaceId2 = this.mcpServerWorkspaces.get(mcpServerId);
153019
+ this.publishMCPServerStatus(workspaceId2, mcpServerId, "IDLE");
153020
+ return;
153021
+ }
153022
+ this.mcpServers.delete(mcpServerId);
153023
+ this.dormantConfigs.set(mcpServerId, {
153024
+ config: config2,
153025
+ configSignature: getConfigSignature(config2, roots)
153026
+ });
153027
+ const workspaceId = this.mcpServerWorkspaces.get(mcpServerId);
153028
+ this.publishMCPServerStatus(workspaceId, mcpServerId, "IDLE");
153029
+ this.idleTimers.delete(mcpServerId);
153030
+ }
153031
+ /**
153032
+ * Remove a dormant server completely: clean up NATS tool subscriptions, mcpTools, and config.
153033
+ */
153034
+ removeDormantServer(mcpServerId) {
153035
+ this.dormantConfigs.delete(mcpServerId);
153036
+ this.activationFailures.delete(mcpServerId);
153037
+ const serverToolSubs = this.toolSubscriptions.get(mcpServerId);
153038
+ if (serverToolSubs) {
153039
+ for (const [toolId, subscription] of serverToolSubs.entries()) {
153040
+ try {
153041
+ subscription.unsubscribe();
153042
+ } catch (error48) {
153043
+ this.logger.warn({ toolId, err: error48 }, "Failed to unsubscribe dormant tool");
153044
+ }
153045
+ }
153046
+ this.toolSubscriptions.delete(mcpServerId);
153047
+ }
153048
+ this.mcpTools.delete(mcpServerId);
153049
+ this.mcpServerWorkspaces.delete(mcpServerId);
153050
+ const timer = this.idleTimers.get(mcpServerId);
153051
+ if (timer) {
153052
+ clearTimeout(timer);
153053
+ this.idleTimers.delete(mcpServerId);
153054
+ }
152843
153055
  }
152844
153056
  // Subscribe to a tool and return the subscription
152845
153057
  subscribeToTool(tool2) {
@@ -152901,11 +153113,45 @@ var ToolService = class ToolService2 extends Service {
152901
153113
  continue;
152902
153114
  }
152903
153115
  this.logger.debug(`Found tool ${tool2.name} in MCP Server ${mcpServerId}`);
152904
- const mcpServer = this.mcpServers.get(mcpServerId);
153116
+ let mcpServer = this.mcpServers.get(mcpServerId);
153117
+ if (!mcpServer && this.dormantConfigs.has(mcpServerId)) {
153118
+ const failures = this.activationFailures.get(mcpServerId);
153119
+ if (failures && failures.count >= this.maxActivationRetries) {
153120
+ const elapsed = Date.now() - failures.lastAttempt;
153121
+ if (elapsed < this.activationCooldownMs) {
153122
+ const retryInSec = Math.ceil((this.activationCooldownMs - elapsed) / 1e3);
153123
+ msg.respond(new ErrorResponse({
153124
+ error: `MCP server failed to start after ${failures.count} attempts. Will retry in ${retryInSec}s.`
153125
+ }));
153126
+ toolCalled = true;
153127
+ continue;
153128
+ }
153129
+ this.activationFailures.delete(mcpServerId);
153130
+ }
153131
+ this.logger.info(`Activating dormant MCP server ${mcpServerId} for tool call`);
153132
+ try {
153133
+ mcpServer = await this.ensureServerActive(mcpServerId);
153134
+ this.activationFailures.delete(mcpServerId);
153135
+ } catch (activationError) {
153136
+ const prev = this.activationFailures.get(mcpServerId);
153137
+ this.activationFailures.set(mcpServerId, {
153138
+ count: (prev?.count ?? 0) + 1,
153139
+ lastAttempt: Date.now()
153140
+ });
153141
+ this.logger.error({ mcpServerId, err: activationError }, "Failed to activate dormant MCP server");
153142
+ const userMessage = activationError instanceof Error && activationError.message.includes("No dormant config") ? "MCP server is being reconfigured, please retry" : `MCP server activation failed: ${activationError instanceof Error ? activationError.message : String(activationError)}`;
153143
+ msg.respond(new ErrorResponse({ error: userMessage }));
153144
+ toolCalled = true;
153145
+ continue;
153146
+ }
153147
+ }
152905
153148
  if (!mcpServer) {
152906
153149
  this.logger.warn(`MCP Server ${mcpServerId} not found`);
152907
153150
  continue;
152908
153151
  }
153152
+ if (this.idleTimers.has(mcpServerId)) {
153153
+ this.resetIdleTimer(mcpServerId);
153154
+ }
152909
153155
  let _meta;
152910
153156
  const mcpServerConfig = mcpServer.getConfig();
152911
153157
  if (mcpServerConfig.oauthProvider && userId) {
@@ -153027,7 +153273,7 @@ var ToolService = class ToolService2 extends Service {
153027
153273
  executedBy
153028
153274
  }));
153029
153275
  } catch (callError) {
153030
- this.logger.error(`Tool call ${tool2.name} failed: ${callError}`);
153276
+ this.logger.error({ mcpServerId, toolName: tool2.name, err: callError }, "Tool call failed");
153031
153277
  msg.respond(new ErrorResponse({
153032
153278
  error: `Tool call failed: ${callError instanceof Error ? callError.message : String(callError)}`
153033
153279
  }));
@@ -153049,7 +153295,8 @@ var ToolService = class ToolService2 extends Service {
153049
153295
  */
153050
153296
  async handleScriptExecution(msg, executedBy) {
153051
153297
  const { scriptId, scriptName, workspaceId, arguments: toolArgs } = msg.data;
153052
- const args = toolArgs?.args || [];
153298
+ const rawArgs = toolArgs?.args;
153299
+ const args = Array.isArray(rawArgs) ? rawArgs : typeof rawArgs === "string" && rawArgs.trim() ? parseScriptArgs(rawArgs) : [];
153053
153300
  this.logger.info({ scriptId, scriptName, argsCount: args.length }, "Handling script execution request");
153054
153301
  const fetchRequest = SkillFetchScriptRequest.create({
153055
153302
  scriptId,
@@ -153313,6 +153560,11 @@ ${errorOutput}`;
153313
153560
  await this.stopService(service);
153314
153561
  }
153315
153562
  this.mcpServers.delete(mcpServer.id);
153563
+ const idleTimer = this.idleTimers.get(mcpServer.id);
153564
+ if (idleTimer) {
153565
+ clearTimeout(idleTimer);
153566
+ this.idleTimers.delete(mcpServer.id);
153567
+ }
153316
153568
  const workspaceId = this.mcpServerWorkspaces.get(mcpServer.id);
153317
153569
  this.publishMCPServerStatus(workspaceId, mcpServer.id, "DISCONNECTED");
153318
153570
  this.mcpServerWorkspaces.delete(mcpServer.id);