@enactprotocol/mcp-server 2.2.2 → 2.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enactprotocol/mcp-server",
3
- "version": "2.2.2",
3
+ "version": "2.3.1",
4
4
  "description": "MCP protocol server for Enact tool integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -24,9 +24,9 @@
24
24
  "dev:http": "bun run src/index.ts --http"
25
25
  },
26
26
  "dependencies": {
27
- "@enactprotocol/api": "2.2.2",
28
- "@enactprotocol/execution": "2.2.2",
29
- "@enactprotocol/shared": "2.2.2",
27
+ "@enactprotocol/api": "2.3.1",
28
+ "@enactprotocol/execution": "2.3.1",
29
+ "@enactprotocol/shared": "2.3.1",
30
30
  "@modelcontextprotocol/sdk": "^1.10.0"
31
31
  },
32
32
  "devDependencies": {
package/src/index.ts CHANGED
@@ -26,14 +26,21 @@ import {
26
26
  searchTools,
27
27
  verifyAllAttestations,
28
28
  } from "@enactprotocol/api";
29
- import { DaggerExecutionProvider } from "@enactprotocol/execution";
30
29
  import {
30
+ DaggerExecutionProvider,
31
+ DockerExecutionProvider,
32
+ ExecutionRouter,
33
+ LocalExecutionProvider,
34
+ } from "@enactprotocol/execution";
35
+ import type { ExecutionResult } from "@enactprotocol/execution";
36
+ import { resolveSecret } from "@enactprotocol/secrets";
37
+ import {
38
+ type ActionsManifest,
31
39
  type ToolManifest,
32
40
  addMcpTool,
33
41
  addToolToRegistry,
34
42
  applyDefaults,
35
43
  getActiveToolset,
36
- getCacheDir,
37
44
  getMcpToolInfo,
38
45
  getMinimumAttestations,
39
46
  getToolCachePath,
@@ -41,7 +48,8 @@ import {
41
48
  isIdentityTrusted,
42
49
  listMcpTools,
43
50
  loadConfig,
44
- loadManifestFromDir,
51
+ loadManifestWithActions,
52
+ parseActionSpecifier,
45
53
  pathExists,
46
54
  validateInputs,
47
55
  } from "@enactprotocol/shared";
@@ -70,18 +78,30 @@ function fromMcpName(mcpName: string): string {
70
78
  }
71
79
 
72
80
  /**
73
- * Convert Enact JSON Schema to MCP tool input schema
81
+ * Resolve secrets from the keyring for a tool's environment variables
82
+ * Only resolves variables marked with secret: true in the manifest
74
83
  */
75
- function convertInputSchema(manifest: ToolManifest): Tool["inputSchema"] {
76
- if (!manifest.inputSchema) {
77
- return {
78
- type: "object",
79
- properties: {},
80
- };
84
+ async function resolveManifestSecrets(
85
+ toolName: string,
86
+ manifest: ToolManifest
87
+ ): Promise<Record<string, string>> {
88
+ const envOverrides: Record<string, string> = {};
89
+
90
+ if (!manifest.env) {
91
+ return envOverrides;
92
+ }
93
+
94
+ for (const [envName, envDecl] of Object.entries(manifest.env)) {
95
+ // Only resolve secrets (not regular env vars)
96
+ if (envDecl && typeof envDecl === "object" && envDecl.secret) {
97
+ const result = await resolveSecret(toolName, envName);
98
+ if (result.found && result.value) {
99
+ envOverrides[envName] = result.value;
100
+ }
101
+ }
81
102
  }
82
103
 
83
- // Return the inputSchema directly - it should already be JSON Schema compatible
84
- return manifest.inputSchema as Tool["inputSchema"];
104
+ return envOverrides;
85
105
  }
86
106
 
87
107
  /**
@@ -110,7 +130,8 @@ function getApiClient() {
110
130
  * Extract a tar.gz bundle to a directory
111
131
  */
112
132
  async function extractBundle(bundleData: ArrayBuffer, destPath: string): Promise<void> {
113
- const tempFile = join(getCacheDir(), `bundle-${Date.now()}.tar.gz`);
133
+ const { tmpdir } = await import("node:os");
134
+ const tempFile = join(tmpdir(), `enact-bundle-${Date.now()}.tar.gz`);
114
135
  mkdirSync(dirname(tempFile), { recursive: true });
115
136
  writeFileSync(tempFile, Buffer.from(bundleData));
116
137
 
@@ -295,10 +316,6 @@ async function handleMetaTool(
295
316
  let response = `# ${toolNameArg}@${targetVersion}\n\n`;
296
317
  response += `**Description:** ${versionInfo.description}\n\n`;
297
318
 
298
- if (manifest.inputSchema) {
299
- response += `## Input Schema\n\`\`\`json\n${JSON.stringify(manifest.inputSchema, null, 2)}\n\`\`\`\n\n`;
300
- }
301
-
302
319
  if (manifest.outputSchema) {
303
320
  response += `## Output Schema\n\`\`\`json\n${JSON.stringify(manifest.outputSchema, null, 2)}\n\`\`\`\n\n`;
304
321
  }
@@ -315,6 +332,27 @@ async function handleMetaTool(
315
332
  }
316
333
  }
317
334
 
335
+ // Check for actions in the manifest
336
+ const actionsManifest = manifest.actions as ActionsManifest | undefined;
337
+ if (
338
+ actionsManifest &&
339
+ typeof actionsManifest.actions === "object" &&
340
+ Object.keys(actionsManifest.actions).length > 0
341
+ ) {
342
+ response += "\n## Available Actions\n\n";
343
+ response += `This tool supports the following actions. Run with \`enact_run\` using \`${toolNameArg}:<action>\` format.\n\n`;
344
+
345
+ for (const [actionName, action] of Object.entries(actionsManifest.actions)) {
346
+ response += `### ${actionName}\n`;
347
+ if (action.description) {
348
+ response += `${action.description}\n\n`;
349
+ }
350
+ if (action.inputSchema) {
351
+ response += `**Input Schema:**\n\`\`\`json\n${JSON.stringify(action.inputSchema, null, 2)}\n\`\`\`\n\n`;
352
+ }
353
+ }
354
+ }
355
+
318
356
  return {
319
357
  content: [{ type: "text", text: response }],
320
358
  };
@@ -335,10 +373,14 @@ async function handleMetaTool(
335
373
  const toolNameArg = args.tool as string;
336
374
  const toolArgs = (args.args as Record<string, unknown>) || {};
337
375
 
376
+ // Parse action specifier (owner/skill/action or owner/skill)
377
+ const { skillName, actionName } = parseActionSpecifier(toolNameArg);
378
+
338
379
  try {
339
380
  // Check if tool is already installed
340
- const toolInfo = getMcpToolInfo(toolNameArg);
381
+ const toolInfo = getMcpToolInfo(skillName);
341
382
  let manifest: ToolManifest;
383
+ let actionsManifest: ActionsManifest | undefined;
342
384
  let cachePath: string;
343
385
  let bundleHash: string | undefined;
344
386
  let toolVersion: string | undefined;
@@ -347,7 +389,7 @@ async function handleMetaTool(
347
389
 
348
390
  if (toolInfo) {
349
391
  // Tool is installed, use cached version
350
- const loaded = loadManifestFromDir(toolInfo.cachePath);
392
+ const loaded = loadManifestWithActions(toolInfo.cachePath);
351
393
  if (!loaded) {
352
394
  return {
353
395
  content: [{ type: "text", text: "Failed to load installed tool manifest" }],
@@ -355,38 +397,39 @@ async function handleMetaTool(
355
397
  };
356
398
  }
357
399
  manifest = loaded.manifest;
400
+ actionsManifest = loaded.actionsManifest;
358
401
  cachePath = toolInfo.cachePath;
359
402
  toolVersion = toolInfo.version;
360
403
 
361
404
  // Get bundle hash for installed tool from registry
362
405
  try {
363
- const versionInfo = await getToolVersion(apiClient, toolNameArg, toolVersion);
406
+ const versionInfo = await getToolVersion(apiClient, skillName, toolVersion);
364
407
  bundleHash = versionInfo.bundle.hash;
365
408
  } catch {
366
409
  // Continue without hash - will skip verification
367
410
  }
368
411
  } else {
369
412
  // Tool not installed - fetch and install temporarily
370
- const info = await getToolInfo(apiClient, toolNameArg);
413
+ const info = await getToolInfo(apiClient, skillName);
371
414
  toolVersion = info.latestVersion;
372
415
 
373
416
  // Download bundle
374
417
  const bundleResult = await downloadBundle(apiClient, {
375
- name: toolNameArg,
418
+ name: skillName,
376
419
  version: info.latestVersion,
377
420
  verify: true,
378
421
  });
379
422
  bundleHash = bundleResult.hash;
380
423
 
381
424
  // Extract to cache
382
- cachePath = getToolCachePath(toolNameArg, info.latestVersion);
425
+ cachePath = getToolCachePath(skillName, info.latestVersion);
383
426
  if (pathExists(cachePath)) {
384
427
  rmSync(cachePath, { recursive: true, force: true });
385
428
  }
386
429
  await extractBundle(bundleResult.data, cachePath);
387
430
 
388
- // Load manifest
389
- const loaded = loadManifestFromDir(cachePath);
431
+ // Load manifest with actions
432
+ const loaded = loadManifestWithActions(cachePath);
390
433
  if (!loaded) {
391
434
  return {
392
435
  content: [{ type: "text", text: "Failed to load downloaded tool manifest" }],
@@ -394,6 +437,7 @@ async function handleMetaTool(
394
437
  };
395
438
  }
396
439
  manifest = loaded.manifest;
440
+ actionsManifest = loaded.actionsManifest;
397
441
  }
398
442
 
399
443
  // Verify attestations before execution
@@ -403,7 +447,7 @@ async function handleMetaTool(
403
447
  try {
404
448
  const verified = await verifyAllAttestations(
405
449
  apiClient,
406
- toolNameArg,
450
+ skillName,
407
451
  toolVersion,
408
452
  bundleHash
409
453
  );
@@ -431,7 +475,7 @@ async function handleMetaTool(
431
475
  try {
432
476
  const verified = await verifyAllAttestations(
433
477
  apiClient,
434
- toolNameArg,
478
+ skillName,
435
479
  toolVersion,
436
480
  bundleHash
437
481
  );
@@ -469,31 +513,74 @@ async function handleMetaTool(
469
513
  // policy === 'allow' - continue execution with warning
470
514
  }
471
515
 
472
- // Validate and apply defaults
473
- const inputsWithDefaults = manifest.inputSchema
474
- ? applyDefaults(toolArgs, manifest.inputSchema)
475
- : toolArgs;
476
-
477
- const validation = validateInputs(inputsWithDefaults, manifest.inputSchema);
478
- if (!validation.valid) {
479
- const errors = validation.errors.map((err) => `${err.path}: ${err.message}`).join(", ");
480
- return {
481
- content: [{ type: "text", text: `Input validation failed: ${errors}` }],
482
- isError: true,
483
- };
484
- }
516
+ // Resolve secrets from keyring
517
+ const secretOverrides = await resolveManifestSecrets(skillName, manifest);
485
518
 
486
- const finalInputs = validation.coercedValues ?? inputsWithDefaults;
519
+ // Execute the tool or action — select backend via execution router
520
+ const execConfig = loadConfig();
521
+ const router = new ExecutionRouter({
522
+ default: execConfig.execution?.default,
523
+ fallback: execConfig.execution?.fallback,
524
+ trusted_scopes: execConfig.execution?.trusted_scopes,
525
+ });
526
+ router.registerProvider("local", new LocalExecutionProvider({ verbose: false }));
527
+ router.registerProvider("docker", new DockerExecutionProvider({ verbose: false }));
528
+ router.registerProvider("dagger", new DaggerExecutionProvider({ verbose: false }));
487
529
 
488
- // Execute the tool
489
- const provider = new DaggerExecutionProvider({ verbose: false });
530
+ const provider = await router.selectProvider(skillName);
490
531
  await provider.initialize();
491
532
 
492
- const result = await provider.execute(
493
- manifest,
494
- { params: finalInputs, envOverrides: {} },
495
- { mountDirs: { [cachePath]: "/workspace" } }
496
- );
533
+ let result: ExecutionResult;
534
+
535
+ // Check if we need to execute an action
536
+ if (actionName && actionsManifest) {
537
+ // Find the action in the manifest (map lookup)
538
+ const action = actionsManifest.actions[actionName];
539
+ if (!action) {
540
+ const availableActions = Object.keys(actionsManifest.actions).join(", ");
541
+ return {
542
+ content: [
543
+ {
544
+ type: "text",
545
+ text: `Action "${actionName}" not found in ${skillName}. Available actions: ${availableActions}`,
546
+ },
547
+ ],
548
+ isError: true,
549
+ };
550
+ }
551
+
552
+ // Use action's inputSchema for validation (optional, default to empty)
553
+ const effectiveSchema = action.inputSchema ?? { type: "object" as const, properties: {} };
554
+ const inputsWithDefaults = applyDefaults(toolArgs, effectiveSchema);
555
+
556
+ const validation = validateInputs(inputsWithDefaults, effectiveSchema);
557
+ if (!validation.valid) {
558
+ const errors = validation.errors.map((err) => `${err.path}: ${err.message}`).join(", ");
559
+ return {
560
+ content: [{ type: "text", text: `Input validation failed: ${errors}` }],
561
+ isError: true,
562
+ };
563
+ }
564
+
565
+ const finalInputs = validation.coercedValues ?? inputsWithDefaults;
566
+
567
+ // Execute the action
568
+ result = await provider.executeAction(
569
+ manifest,
570
+ actionsManifest,
571
+ actionName,
572
+ action,
573
+ { params: finalInputs, envOverrides: secretOverrides },
574
+ { mountDirs: { [cachePath]: "/workspace" } }
575
+ );
576
+ } else {
577
+ // Execute the tool normally (no per-script schema — pass params through)
578
+ result = await provider.execute(
579
+ manifest,
580
+ { params: toolArgs, envOverrides: secretOverrides },
581
+ { mountDirs: { [cachePath]: "/workspace" } }
582
+ );
583
+ }
497
584
 
498
585
  if (result.success) {
499
586
  const output = result.output?.stdout || "Tool executed successfully (no output)";
@@ -605,19 +692,43 @@ function createMcpServer(): Server {
605
692
  // Start with meta-tools for progressive discovery
606
693
  const tools: Tool[] = [...META_TOOLS];
607
694
 
608
- // Add installed tools
695
+ // Add installed tools and their actions
609
696
  for (const tool of mcpTools) {
610
- const loaded = loadManifestFromDir(tool.cachePath);
697
+ const loaded = loadManifestWithActions(tool.cachePath);
611
698
  const manifest = loaded?.manifest;
699
+ const actionsManifest = loaded?.actionsManifest;
612
700
 
613
- const description = manifest?.description || `Enact tool: ${tool.name}`;
614
701
  const toolsetNote = activeToolset ? ` [toolset: ${activeToolset}]` : "";
615
702
 
616
- tools.push({
617
- name: toMcpName(tool.name),
618
- description: description + toolsetNote,
619
- inputSchema: manifest ? convertInputSchema(manifest) : { type: "object", properties: {} },
620
- });
703
+ // If the tool has actions, expose each action as a separate MCP tool
704
+ if (
705
+ actionsManifest &&
706
+ typeof actionsManifest.actions === "object" &&
707
+ Object.keys(actionsManifest.actions).length > 0
708
+ ) {
709
+ for (const [actionName, action] of Object.entries(actionsManifest.actions)) {
710
+ // Action tool name format: owner__skill__action (uses colon internally but double underscore for MCP)
711
+ const actionMcpName = toMcpName(`${tool.name}:${actionName}`);
712
+
713
+ tools.push({
714
+ name: actionMcpName,
715
+ description:
716
+ (action.description || `Action ${actionName} of ${tool.name}`) + toolsetNote,
717
+ inputSchema: action.inputSchema
718
+ ? (action.inputSchema as Tool["inputSchema"])
719
+ : { type: "object", properties: {} },
720
+ });
721
+ }
722
+ } else {
723
+ // No actions, expose the tool itself
724
+ const description = manifest?.description || `Enact tool: ${tool.name}`;
725
+
726
+ tools.push({
727
+ name: toMcpName(tool.name),
728
+ description: description + toolsetNote,
729
+ inputSchema: { type: "object", properties: {} },
730
+ });
731
+ }
621
732
  }
622
733
 
623
734
  return { tools };
@@ -635,8 +746,11 @@ function createMcpServer(): Server {
635
746
  return metaResult;
636
747
  }
637
748
 
749
+ // Parse action specifier (owner/skill/action or owner/skill)
750
+ const { skillName, actionName } = parseActionSpecifier(enactToolName);
751
+
638
752
  // Find the tool in MCP registry (respects active toolset)
639
- const toolInfo = getMcpToolInfo(enactToolName);
753
+ const toolInfo = getMcpToolInfo(skillName);
640
754
 
641
755
  if (!toolInfo) {
642
756
  const activeToolset = getActiveToolset();
@@ -647,21 +761,21 @@ function createMcpServer(): Server {
647
761
  content: [
648
762
  {
649
763
  type: "text",
650
- text: `Error: Tool "${enactToolName}" not found.${toolsetHint} Use 'enact mcp add <tool>' to add it.`,
764
+ text: `Error: Tool "${skillName}" not found.${toolsetHint} Use 'enact mcp add <tool>' to add it.`,
651
765
  },
652
766
  ],
653
767
  isError: true,
654
768
  };
655
769
  }
656
770
 
657
- // Load manifest
658
- const loaded = loadManifestFromDir(toolInfo.cachePath);
771
+ // Load manifest with actions
772
+ const loaded = loadManifestWithActions(toolInfo.cachePath);
659
773
  if (!loaded) {
660
774
  return {
661
775
  content: [
662
776
  {
663
777
  type: "text",
664
- text: `Error: Failed to load manifest for "${enactToolName}"`,
778
+ text: `Error: Failed to load manifest for "${skillName}"`,
665
779
  },
666
780
  ],
667
781
  isError: true,
@@ -669,62 +783,97 @@ function createMcpServer(): Server {
669
783
  }
670
784
 
671
785
  const manifest = loaded.manifest;
786
+ const actionsManifest = loaded.actionsManifest;
672
787
 
673
- // Check if this is an instruction-based tool (no command)
674
- if (!manifest.command) {
675
- // Return the documentation/instructions for LLM interpretation
676
- const instructions = manifest.doc || manifest.description || "No instructions available.";
677
- return {
678
- content: [
679
- {
680
- type: "text",
681
- text: `[Instruction Tool: ${enactToolName}]\n\n${instructions}\n\nInputs provided: ${JSON.stringify(args, null, 2)}`,
682
- },
683
- ],
684
- };
685
- }
686
-
687
- // Apply defaults and validate inputs
688
- const inputsWithDefaults = manifest.inputSchema
689
- ? applyDefaults(args, manifest.inputSchema)
690
- : args;
691
-
692
- const validation = validateInputs(inputsWithDefaults, manifest.inputSchema);
693
- if (!validation.valid) {
694
- const errors = validation.errors.map((err) => `${err.path}: ${err.message}`).join(", ");
695
- return {
696
- content: [
697
- {
698
- type: "text",
699
- text: `Input validation failed: ${errors}`,
700
- },
701
- ],
702
- isError: true,
703
- };
704
- }
705
-
706
- const finalInputs = validation.coercedValues ?? inputsWithDefaults;
788
+ // Resolve secrets from keyring
789
+ const secretOverrides = await resolveManifestSecrets(skillName, manifest);
707
790
 
708
- // Execute the tool using Dagger
709
- const provider = new DaggerExecutionProvider({
710
- verbose: false,
791
+ // Execute the tool select backend via execution router
792
+ const execConfig2 = loadConfig();
793
+ const router2 = new ExecutionRouter({
794
+ default: execConfig2.execution?.default,
795
+ fallback: execConfig2.execution?.fallback,
796
+ trusted_scopes: execConfig2.execution?.trusted_scopes,
711
797
  });
798
+ router2.registerProvider("local", new LocalExecutionProvider({ verbose: false }));
799
+ router2.registerProvider("docker", new DockerExecutionProvider({ verbose: false }));
800
+ router2.registerProvider("dagger", new DaggerExecutionProvider({ verbose: false }));
801
+
802
+ const provider = await router2.selectProvider(skillName);
712
803
 
713
804
  try {
714
805
  await provider.initialize();
715
806
 
716
- const result = await provider.execute(
717
- manifest,
718
- {
719
- params: finalInputs,
720
- envOverrides: {},
721
- },
722
- {
723
- mountDirs: {
724
- [toolInfo.cachePath]: "/workspace",
725
- },
807
+ let result: ExecutionResult;
808
+
809
+ // Check if we need to execute an action
810
+ if (actionName && actionsManifest) {
811
+ // Find the action in the manifest (map lookup)
812
+ const action = actionsManifest.actions[actionName];
813
+ if (!action) {
814
+ const availableActions = Object.keys(actionsManifest.actions).join(", ");
815
+ return {
816
+ content: [
817
+ {
818
+ type: "text",
819
+ text: `Action "${actionName}" not found in ${skillName}. Available actions: ${availableActions}`,
820
+ },
821
+ ],
822
+ isError: true,
823
+ };
726
824
  }
727
- );
825
+
826
+ // Use action's inputSchema for validation (optional, default to empty)
827
+ const effectiveSchema = action.inputSchema ?? { type: "object" as const, properties: {} };
828
+ const inputsWithDefaults = applyDefaults(args, effectiveSchema);
829
+
830
+ const validation = validateInputs(inputsWithDefaults, effectiveSchema);
831
+ if (!validation.valid) {
832
+ const errors = validation.errors.map((err) => `${err.path}: ${err.message}`).join(", ");
833
+ return {
834
+ content: [
835
+ {
836
+ type: "text",
837
+ text: `Input validation failed: ${errors}`,
838
+ },
839
+ ],
840
+ isError: true,
841
+ };
842
+ }
843
+
844
+ const finalInputs = validation.coercedValues ?? inputsWithDefaults;
845
+
846
+ // Execute the action
847
+ result = await provider.executeAction(
848
+ manifest,
849
+ actionsManifest,
850
+ actionName,
851
+ action,
852
+ { params: finalInputs, envOverrides: secretOverrides },
853
+ { mountDirs: { [toolInfo.cachePath]: "/workspace" } }
854
+ );
855
+ } else {
856
+ // Check if this is an instruction-based tool (no command)
857
+ if (!manifest.command) {
858
+ // Return the documentation/instructions for LLM interpretation
859
+ const instructions = manifest.doc || manifest.description || "No instructions available.";
860
+ return {
861
+ content: [
862
+ {
863
+ type: "text",
864
+ text: `[Instruction Tool: ${skillName}]\n\n${instructions}\n\nInputs provided: ${JSON.stringify(args, null, 2)}`,
865
+ },
866
+ ],
867
+ };
868
+ }
869
+
870
+ // Execute the tool normally (no per-script schema — pass params through)
871
+ result = await provider.execute(
872
+ manifest,
873
+ { params: args, envOverrides: secretOverrides },
874
+ { mountDirs: { [toolInfo.cachePath]: "/workspace" } }
875
+ );
876
+ }
728
877
 
729
878
  if (result.success) {
730
879
  return {