@enactprotocol/mcp-server 2.2.4 → 2.3.4

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.4",
3
+ "version": "2.3.4",
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.4",
28
- "@enactprotocol/execution": "2.2.4",
29
- "@enactprotocol/shared": "2.2.4",
27
+ "@enactprotocol/api": "2.3.4",
28
+ "@enactprotocol/execution": "2.3.4",
29
+ "@enactprotocol/shared": "2.3.4",
30
30
  "@modelcontextprotocol/sdk": "^1.10.0"
31
31
  },
32
32
  "devDependencies": {
package/src/index.ts CHANGED
@@ -26,15 +26,21 @@ import {
26
26
  searchTools,
27
27
  verifyAllAttestations,
28
28
  } from "@enactprotocol/api";
29
- import { DaggerExecutionProvider } from "@enactprotocol/execution";
29
+ import {
30
+ DaggerExecutionProvider,
31
+ DockerExecutionProvider,
32
+ ExecutionRouter,
33
+ LocalExecutionProvider,
34
+ } from "@enactprotocol/execution";
35
+ import type { ExecutionResult } from "@enactprotocol/execution";
30
36
  import { resolveSecret } from "@enactprotocol/secrets";
31
37
  import {
38
+ type ActionsManifest,
32
39
  type ToolManifest,
33
40
  addMcpTool,
34
41
  addToolToRegistry,
35
42
  applyDefaults,
36
43
  getActiveToolset,
37
- getCacheDir,
38
44
  getMcpToolInfo,
39
45
  getMinimumAttestations,
40
46
  getToolCachePath,
@@ -42,7 +48,8 @@ import {
42
48
  isIdentityTrusted,
43
49
  listMcpTools,
44
50
  loadConfig,
45
- loadManifestFromDir,
51
+ loadManifestWithActions,
52
+ parseActionSpecifier,
46
53
  pathExists,
47
54
  validateInputs,
48
55
  } from "@enactprotocol/shared";
@@ -97,21 +104,6 @@ async function resolveManifestSecrets(
97
104
  return envOverrides;
98
105
  }
99
106
 
100
- /**
101
- * Convert Enact JSON Schema to MCP tool input schema
102
- */
103
- function convertInputSchema(manifest: ToolManifest): Tool["inputSchema"] {
104
- if (!manifest.inputSchema) {
105
- return {
106
- type: "object",
107
- properties: {},
108
- };
109
- }
110
-
111
- // Return the inputSchema directly - it should already be JSON Schema compatible
112
- return manifest.inputSchema as Tool["inputSchema"];
113
- }
114
-
115
107
  /**
116
108
  * Get API client for registry access
117
109
  */
@@ -138,7 +130,8 @@ function getApiClient() {
138
130
  * Extract a tar.gz bundle to a directory
139
131
  */
140
132
  async function extractBundle(bundleData: ArrayBuffer, destPath: string): Promise<void> {
141
- 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`);
142
135
  mkdirSync(dirname(tempFile), { recursive: true });
143
136
  writeFileSync(tempFile, Buffer.from(bundleData));
144
137
 
@@ -323,10 +316,6 @@ async function handleMetaTool(
323
316
  let response = `# ${toolNameArg}@${targetVersion}\n\n`;
324
317
  response += `**Description:** ${versionInfo.description}\n\n`;
325
318
 
326
- if (manifest.inputSchema) {
327
- response += `## Input Schema\n\`\`\`json\n${JSON.stringify(manifest.inputSchema, null, 2)}\n\`\`\`\n\n`;
328
- }
329
-
330
319
  if (manifest.outputSchema) {
331
320
  response += `## Output Schema\n\`\`\`json\n${JSON.stringify(manifest.outputSchema, null, 2)}\n\`\`\`\n\n`;
332
321
  }
@@ -343,6 +332,27 @@ async function handleMetaTool(
343
332
  }
344
333
  }
345
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
+
346
356
  return {
347
357
  content: [{ type: "text", text: response }],
348
358
  };
@@ -363,10 +373,14 @@ async function handleMetaTool(
363
373
  const toolNameArg = args.tool as string;
364
374
  const toolArgs = (args.args as Record<string, unknown>) || {};
365
375
 
376
+ // Parse action specifier (owner/skill/action or owner/skill)
377
+ const { skillName, actionName } = parseActionSpecifier(toolNameArg);
378
+
366
379
  try {
367
380
  // Check if tool is already installed
368
- const toolInfo = getMcpToolInfo(toolNameArg);
381
+ const toolInfo = getMcpToolInfo(skillName);
369
382
  let manifest: ToolManifest;
383
+ let actionsManifest: ActionsManifest | undefined;
370
384
  let cachePath: string;
371
385
  let bundleHash: string | undefined;
372
386
  let toolVersion: string | undefined;
@@ -375,7 +389,7 @@ async function handleMetaTool(
375
389
 
376
390
  if (toolInfo) {
377
391
  // Tool is installed, use cached version
378
- const loaded = loadManifestFromDir(toolInfo.cachePath);
392
+ const loaded = loadManifestWithActions(toolInfo.cachePath);
379
393
  if (!loaded) {
380
394
  return {
381
395
  content: [{ type: "text", text: "Failed to load installed tool manifest" }],
@@ -383,38 +397,39 @@ async function handleMetaTool(
383
397
  };
384
398
  }
385
399
  manifest = loaded.manifest;
400
+ actionsManifest = loaded.actionsManifest;
386
401
  cachePath = toolInfo.cachePath;
387
402
  toolVersion = toolInfo.version;
388
403
 
389
404
  // Get bundle hash for installed tool from registry
390
405
  try {
391
- const versionInfo = await getToolVersion(apiClient, toolNameArg, toolVersion);
406
+ const versionInfo = await getToolVersion(apiClient, skillName, toolVersion);
392
407
  bundleHash = versionInfo.bundle.hash;
393
408
  } catch {
394
409
  // Continue without hash - will skip verification
395
410
  }
396
411
  } else {
397
412
  // Tool not installed - fetch and install temporarily
398
- const info = await getToolInfo(apiClient, toolNameArg);
413
+ const info = await getToolInfo(apiClient, skillName);
399
414
  toolVersion = info.latestVersion;
400
415
 
401
416
  // Download bundle
402
417
  const bundleResult = await downloadBundle(apiClient, {
403
- name: toolNameArg,
418
+ name: skillName,
404
419
  version: info.latestVersion,
405
420
  verify: true,
406
421
  });
407
422
  bundleHash = bundleResult.hash;
408
423
 
409
424
  // Extract to cache
410
- cachePath = getToolCachePath(toolNameArg, info.latestVersion);
425
+ cachePath = getToolCachePath(skillName, info.latestVersion);
411
426
  if (pathExists(cachePath)) {
412
427
  rmSync(cachePath, { recursive: true, force: true });
413
428
  }
414
429
  await extractBundle(bundleResult.data, cachePath);
415
430
 
416
- // Load manifest
417
- const loaded = loadManifestFromDir(cachePath);
431
+ // Load manifest with actions
432
+ const loaded = loadManifestWithActions(cachePath);
418
433
  if (!loaded) {
419
434
  return {
420
435
  content: [{ type: "text", text: "Failed to load downloaded tool manifest" }],
@@ -422,6 +437,7 @@ async function handleMetaTool(
422
437
  };
423
438
  }
424
439
  manifest = loaded.manifest;
440
+ actionsManifest = loaded.actionsManifest;
425
441
  }
426
442
 
427
443
  // Verify attestations before execution
@@ -431,7 +447,7 @@ async function handleMetaTool(
431
447
  try {
432
448
  const verified = await verifyAllAttestations(
433
449
  apiClient,
434
- toolNameArg,
450
+ skillName,
435
451
  toolVersion,
436
452
  bundleHash
437
453
  );
@@ -459,7 +475,7 @@ async function handleMetaTool(
459
475
  try {
460
476
  const verified = await verifyAllAttestations(
461
477
  apiClient,
462
- toolNameArg,
478
+ skillName,
463
479
  toolVersion,
464
480
  bundleHash
465
481
  );
@@ -497,34 +513,74 @@ async function handleMetaTool(
497
513
  // policy === 'allow' - continue execution with warning
498
514
  }
499
515
 
500
- // Validate and apply defaults
501
- const inputsWithDefaults = manifest.inputSchema
502
- ? applyDefaults(toolArgs, manifest.inputSchema)
503
- : toolArgs;
516
+ // Resolve secrets from keyring
517
+ const secretOverrides = await resolveManifestSecrets(skillName, manifest);
518
+
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 }));
504
529
 
505
- const validation = validateInputs(inputsWithDefaults, manifest.inputSchema);
506
- if (!validation.valid) {
507
- const errors = validation.errors.map((err) => `${err.path}: ${err.message}`).join(", ");
508
- return {
509
- content: [{ type: "text", text: `Input validation failed: ${errors}` }],
510
- isError: true,
511
- };
512
- }
530
+ const provider = await router.selectProvider(skillName);
531
+ await provider.initialize();
513
532
 
514
- const finalInputs = validation.coercedValues ?? inputsWithDefaults;
533
+ let result: ExecutionResult;
515
534
 
516
- // Resolve secrets from keyring
517
- const secretOverrides = await resolveManifestSecrets(toolNameArg, manifest);
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
+ }
518
551
 
519
- // Execute the tool
520
- const provider = new DaggerExecutionProvider({ verbose: false });
521
- await provider.initialize();
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);
522
555
 
523
- const result = await provider.execute(
524
- manifest,
525
- { params: finalInputs, envOverrides: secretOverrides },
526
- { mountDirs: { [cachePath]: "/workspace" } }
527
- );
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
+ }
528
584
 
529
585
  if (result.success) {
530
586
  const output = result.output?.stdout || "Tool executed successfully (no output)";
@@ -636,19 +692,43 @@ function createMcpServer(): Server {
636
692
  // Start with meta-tools for progressive discovery
637
693
  const tools: Tool[] = [...META_TOOLS];
638
694
 
639
- // Add installed tools
695
+ // Add installed tools and their actions
640
696
  for (const tool of mcpTools) {
641
- const loaded = loadManifestFromDir(tool.cachePath);
697
+ const loaded = loadManifestWithActions(tool.cachePath);
642
698
  const manifest = loaded?.manifest;
699
+ const actionsManifest = loaded?.actionsManifest;
643
700
 
644
- const description = manifest?.description || `Enact tool: ${tool.name}`;
645
701
  const toolsetNote = activeToolset ? ` [toolset: ${activeToolset}]` : "";
646
702
 
647
- tools.push({
648
- name: toMcpName(tool.name),
649
- description: description + toolsetNote,
650
- inputSchema: manifest ? convertInputSchema(manifest) : { type: "object", properties: {} },
651
- });
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
+ }
652
732
  }
653
733
 
654
734
  return { tools };
@@ -666,8 +746,11 @@ function createMcpServer(): Server {
666
746
  return metaResult;
667
747
  }
668
748
 
749
+ // Parse action specifier (owner/skill/action or owner/skill)
750
+ const { skillName, actionName } = parseActionSpecifier(enactToolName);
751
+
669
752
  // Find the tool in MCP registry (respects active toolset)
670
- const toolInfo = getMcpToolInfo(enactToolName);
753
+ const toolInfo = getMcpToolInfo(skillName);
671
754
 
672
755
  if (!toolInfo) {
673
756
  const activeToolset = getActiveToolset();
@@ -678,21 +761,21 @@ function createMcpServer(): Server {
678
761
  content: [
679
762
  {
680
763
  type: "text",
681
- 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.`,
682
765
  },
683
766
  ],
684
767
  isError: true,
685
768
  };
686
769
  }
687
770
 
688
- // Load manifest
689
- const loaded = loadManifestFromDir(toolInfo.cachePath);
771
+ // Load manifest with actions
772
+ const loaded = loadManifestWithActions(toolInfo.cachePath);
690
773
  if (!loaded) {
691
774
  return {
692
775
  content: [
693
776
  {
694
777
  type: "text",
695
- text: `Error: Failed to load manifest for "${enactToolName}"`,
778
+ text: `Error: Failed to load manifest for "${skillName}"`,
696
779
  },
697
780
  ],
698
781
  isError: true,
@@ -700,65 +783,97 @@ function createMcpServer(): Server {
700
783
  }
701
784
 
702
785
  const manifest = loaded.manifest;
786
+ const actionsManifest = loaded.actionsManifest;
703
787
 
704
- // Check if this is an instruction-based tool (no command)
705
- if (!manifest.command) {
706
- // Return the documentation/instructions for LLM interpretation
707
- const instructions = manifest.doc || manifest.description || "No instructions available.";
708
- return {
709
- content: [
710
- {
711
- type: "text",
712
- text: `[Instruction Tool: ${enactToolName}]\n\n${instructions}\n\nInputs provided: ${JSON.stringify(args, null, 2)}`,
713
- },
714
- ],
715
- };
716
- }
788
+ // Resolve secrets from keyring
789
+ const secretOverrides = await resolveManifestSecrets(skillName, manifest);
790
+
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,
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 }));
717
801
 
718
- // Apply defaults and validate inputs
719
- const inputsWithDefaults = manifest.inputSchema
720
- ? applyDefaults(args, manifest.inputSchema)
721
- : args;
802
+ const provider = await router2.selectProvider(skillName);
722
803
 
723
- const validation = validateInputs(inputsWithDefaults, manifest.inputSchema);
724
- if (!validation.valid) {
725
- const errors = validation.errors.map((err) => `${err.path}: ${err.message}`).join(", ");
726
- return {
727
- content: [
728
- {
729
- type: "text",
730
- text: `Input validation failed: ${errors}`,
731
- },
732
- ],
733
- isError: true,
734
- };
735
- }
804
+ try {
805
+ await provider.initialize();
736
806
 
737
- const finalInputs = validation.coercedValues ?? inputsWithDefaults;
807
+ let result: ExecutionResult;
738
808
 
739
- // Resolve secrets from keyring
740
- const secretOverrides = await resolveManifestSecrets(enactToolName, manifest);
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
+ };
824
+ }
741
825
 
742
- // Execute the tool using Dagger
743
- const provider = new DaggerExecutionProvider({
744
- verbose: false,
745
- });
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);
746
829
 
747
- try {
748
- await provider.initialize();
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
+ }
749
843
 
750
- const result = await provider.execute(
751
- manifest,
752
- {
753
- params: finalInputs,
754
- envOverrides: secretOverrides,
755
- },
756
- {
757
- mountDirs: {
758
- [toolInfo.cachePath]: "/workspace",
759
- },
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
+ };
760
868
  }
761
- );
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
+ }
762
877
 
763
878
  if (result.success) {
764
879
  return {
@@ -417,20 +417,14 @@ describe("Secret Resolution Integration", () => {
417
417
  version: "1.2.1",
418
418
  description: "Scrape websites using Firecrawl API",
419
419
  from: "node:20",
420
- command: "node /work/firecrawl.js",
421
420
  env: {
422
421
  FIRECRAWL_API_KEY: {
423
422
  description: "Your Firecrawl API key from firecrawl.dev",
424
423
  secret: true,
425
424
  },
426
425
  },
427
- inputSchema: {
428
- type: "object",
429
- properties: {
430
- url: { type: "string" },
431
- action: { type: "string" },
432
- },
433
- required: ["url"],
426
+ scripts: {
427
+ scrape: "node /work/firecrawl.js {{url}}",
434
428
  },
435
429
  };
436
430
 
@@ -448,12 +442,8 @@ describe("Secret Resolution Integration", () => {
448
442
  version: "1.0.0",
449
443
  description: "A simple greeting tool",
450
444
  from: "node:20",
451
- command: "node /work/greet.js",
452
- inputSchema: {
453
- type: "object",
454
- properties: {
455
- name: { type: "string" },
456
- },
445
+ scripts: {
446
+ greet: "node /work/greet.js {{name}}",
457
447
  },
458
448
  };
459
449