@enactprotocol/mcp-server 2.2.1 → 2.2.2

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.1",
3
+ "version": "2.2.2",
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.1",
28
- "@enactprotocol/execution": "2.2.1",
29
- "@enactprotocol/shared": "2.2.1",
27
+ "@enactprotocol/api": "2.2.2",
28
+ "@enactprotocol/execution": "2.2.2",
29
+ "@enactprotocol/shared": "2.2.2",
30
30
  "@modelcontextprotocol/sdk": "^1.10.0"
31
31
  },
32
32
  "devDependencies": {
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * @see https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
14
14
  */
15
15
 
16
+ import { spawn } from "node:child_process";
16
17
  import { randomUUID } from "node:crypto";
17
18
  import { mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
18
19
  import { type IncomingMessage, type ServerResponse, createServer } from "node:http";
@@ -23,6 +24,7 @@ import {
23
24
  getToolInfo,
24
25
  getToolVersion,
25
26
  searchTools,
27
+ verifyAllAttestations,
26
28
  } from "@enactprotocol/api";
27
29
  import { DaggerExecutionProvider } from "@enactprotocol/execution";
28
30
  import {
@@ -33,7 +35,10 @@ import {
33
35
  getActiveToolset,
34
36
  getCacheDir,
35
37
  getMcpToolInfo,
38
+ getMinimumAttestations,
36
39
  getToolCachePath,
40
+ getTrustPolicy,
41
+ isIdentityTrusted,
37
42
  listMcpTools,
38
43
  loadConfig,
39
44
  loadManifestFromDir,
@@ -111,23 +116,31 @@ async function extractBundle(bundleData: ArrayBuffer, destPath: string): Promise
111
116
 
112
117
  mkdirSync(destPath, { recursive: true });
113
118
 
114
- const proc = Bun.spawn(["tar", "-xzf", tempFile, "-C", destPath], {
115
- stdout: "pipe",
116
- stderr: "pipe",
117
- });
119
+ await new Promise<void>((resolve, reject) => {
120
+ const proc = spawn("tar", ["-xzf", tempFile, "-C", destPath], {
121
+ stdio: ["ignore", "pipe", "pipe"],
122
+ });
123
+
124
+ let stderr = "";
125
+ proc.stderr?.on("data", (data) => {
126
+ stderr += data.toString();
127
+ });
118
128
 
119
- const exitCode = await proc.exited;
129
+ proc.on("error", reject);
130
+ proc.on("close", (code) => {
131
+ if (code !== 0) {
132
+ reject(new Error(`Failed to extract bundle: ${stderr}`));
133
+ } else {
134
+ resolve();
135
+ }
136
+ });
137
+ });
120
138
 
121
139
  try {
122
140
  unlinkSync(tempFile);
123
141
  } catch {
124
142
  // Ignore cleanup errors
125
143
  }
126
-
127
- if (exitCode !== 0) {
128
- const stderr = await new Response(proc.stderr).text();
129
- throw new Error(`Failed to extract bundle: ${stderr}`);
130
- }
131
144
  }
132
145
 
133
146
  /**
@@ -327,6 +340,10 @@ async function handleMetaTool(
327
340
  const toolInfo = getMcpToolInfo(toolNameArg);
328
341
  let manifest: ToolManifest;
329
342
  let cachePath: string;
343
+ let bundleHash: string | undefined;
344
+ let toolVersion: string | undefined;
345
+
346
+ const apiClient = getApiClient();
330
347
 
331
348
  if (toolInfo) {
332
349
  // Tool is installed, use cached version
@@ -339,10 +356,19 @@ async function handleMetaTool(
339
356
  }
340
357
  manifest = loaded.manifest;
341
358
  cachePath = toolInfo.cachePath;
359
+ toolVersion = toolInfo.version;
360
+
361
+ // Get bundle hash for installed tool from registry
362
+ try {
363
+ const versionInfo = await getToolVersion(apiClient, toolNameArg, toolVersion);
364
+ bundleHash = versionInfo.bundle.hash;
365
+ } catch {
366
+ // Continue without hash - will skip verification
367
+ }
342
368
  } else {
343
369
  // Tool not installed - fetch and install temporarily
344
- const apiClient = getApiClient();
345
370
  const info = await getToolInfo(apiClient, toolNameArg);
371
+ toolVersion = info.latestVersion;
346
372
 
347
373
  // Download bundle
348
374
  const bundleResult = await downloadBundle(apiClient, {
@@ -350,6 +376,7 @@ async function handleMetaTool(
350
376
  version: info.latestVersion,
351
377
  verify: true,
352
378
  });
379
+ bundleHash = bundleResult.hash;
353
380
 
354
381
  // Extract to cache
355
382
  cachePath = getToolCachePath(toolNameArg, info.latestVersion);
@@ -369,6 +396,79 @@ async function handleMetaTool(
369
396
  manifest = loaded.manifest;
370
397
  }
371
398
 
399
+ // Verify attestations before execution
400
+ let verificationStatus = "⚠️ UNVERIFIED";
401
+
402
+ if (bundleHash && toolVersion) {
403
+ try {
404
+ const verified = await verifyAllAttestations(
405
+ apiClient,
406
+ toolNameArg,
407
+ toolVersion,
408
+ bundleHash
409
+ );
410
+
411
+ if (verified.length > 0) {
412
+ const auditorList = verified.map((v) => v.providerIdentity).join(", ");
413
+ verificationStatus = `✅ VERIFIED by: ${auditorList}`;
414
+ } else {
415
+ verificationStatus = "⚠️ UNVERIFIED - No valid attestations found";
416
+ }
417
+ } catch (verifyErr) {
418
+ verificationStatus = `⚠️ UNVERIFIED - Verification failed: ${verifyErr instanceof Error ? verifyErr.message : "Unknown error"}`;
419
+ }
420
+ } else {
421
+ verificationStatus = "⚠️ UNVERIFIED - Could not determine bundle hash";
422
+ }
423
+
424
+ // Enforce trust policy
425
+ const trustPolicy = getTrustPolicy();
426
+ const minimumAttestations = getMinimumAttestations();
427
+
428
+ // Count verified attestations from trusted auditors
429
+ let verifiedCount = 0;
430
+ if (bundleHash && toolVersion) {
431
+ try {
432
+ const verified = await verifyAllAttestations(
433
+ apiClient,
434
+ toolNameArg,
435
+ toolVersion,
436
+ bundleHash
437
+ );
438
+ verifiedCount = verified.filter((v) => isIdentityTrusted(v.providerIdentity)).length;
439
+ } catch {
440
+ // Already handled above in verificationStatus
441
+ }
442
+ }
443
+
444
+ // Check if trust requirements are met
445
+ if (verifiedCount < minimumAttestations) {
446
+ if (trustPolicy === "require_attestation") {
447
+ return {
448
+ content: [
449
+ {
450
+ type: "text",
451
+ text: `Trust policy violation: Tool requires ${minimumAttestations} attestation(s) from trusted auditors, but only ${verifiedCount} found.\n\nConfigured trust policy: ${trustPolicy}\nTo run unverified tools, update your ~/.enact/config.yaml trust policy to 'allow' or 'prompt'.`,
452
+ },
453
+ ],
454
+ isError: true,
455
+ };
456
+ }
457
+ if (trustPolicy === "prompt") {
458
+ // In MCP context, we can't prompt interactively, so we block with a clear message
459
+ return {
460
+ content: [
461
+ {
462
+ type: "text",
463
+ text: `Trust policy violation: Tool requires ${minimumAttestations} attestation(s) from trusted auditors, but only ${verifiedCount} found.\n\nConfigured trust policy: ${trustPolicy}\nMCP server cannot prompt interactively. To run unverified tools via MCP, update your ~/.enact/config.yaml trust policy to 'allow'.`,
464
+ },
465
+ ],
466
+ isError: true,
467
+ };
468
+ }
469
+ // policy === 'allow' - continue execution with warning
470
+ }
471
+
372
472
  // Validate and apply defaults
373
473
  const inputsWithDefaults = manifest.inputSchema
374
474
  ? applyDefaults(toolArgs, manifest.inputSchema)
@@ -396,11 +496,12 @@ async function handleMetaTool(
396
496
  );
397
497
 
398
498
  if (result.success) {
499
+ const output = result.output?.stdout || "Tool executed successfully (no output)";
399
500
  return {
400
501
  content: [
401
502
  {
402
503
  type: "text",
403
- text: result.output?.stdout || "Tool executed successfully (no output)",
504
+ text: `[${verificationStatus}]\n\n${output}`,
404
505
  },
405
506
  ],
406
507
  };
@@ -409,7 +510,7 @@ async function handleMetaTool(
409
510
  content: [
410
511
  {
411
512
  type: "text",
412
- text: `Tool execution failed: ${result.error?.message || "Unknown error"}\n\n${result.output?.stderr || ""}`,
513
+ text: `[${verificationStatus}]\n\nTool execution failed: ${result.error?.message || "Unknown error"}\n\n${result.output?.stderr || ""}`,
413
514
  },
414
515
  ],
415
516
  isError: true,
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Tests for MCP server trust policy enforcement
3
+ *
4
+ * These tests verify that the enact_run handler properly enforces
5
+ * trust policies based on attestation verification.
6
+ */
7
+
8
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
9
+
10
+ // Type for trust policy
11
+ type TrustPolicy = "require_attestation" | "prompt" | "allow";
12
+
13
+ // Mock the shared config functions
14
+ const mockGetTrustPolicy = mock((): TrustPolicy => "require_attestation");
15
+ const mockGetMinimumAttestations = mock(() => 1);
16
+ const mockIsIdentityTrusted = mock((_identity: string) => false);
17
+
18
+ // Mock the API functions
19
+ const mockVerifyAllAttestations = mock(async () => []);
20
+
21
+ describe("MCP Server Trust Policy Enforcement", () => {
22
+ beforeEach(() => {
23
+ // Reset mocks before each test
24
+ mockGetTrustPolicy.mockReset();
25
+ mockGetMinimumAttestations.mockReset();
26
+ mockIsIdentityTrusted.mockReset();
27
+ mockVerifyAllAttestations.mockReset();
28
+
29
+ // Set default mock implementations
30
+ mockGetTrustPolicy.mockImplementation((): TrustPolicy => "require_attestation");
31
+ mockGetMinimumAttestations.mockImplementation(() => 1);
32
+ mockIsIdentityTrusted.mockImplementation(() => false);
33
+ mockVerifyAllAttestations.mockImplementation(async () => []);
34
+ });
35
+
36
+ describe("Trust Policy Logic", () => {
37
+ test("should block execution when policy is 'require_attestation' and no attestations", () => {
38
+ const trustPolicy = mockGetTrustPolicy();
39
+ const minimumAttestations = mockGetMinimumAttestations();
40
+ const verifiedCount = 0;
41
+
42
+ // Simulate the trust check logic from enact_run
43
+ const shouldBlock =
44
+ verifiedCount < minimumAttestations && trustPolicy === "require_attestation";
45
+
46
+ expect(shouldBlock).toBe(true);
47
+ });
48
+
49
+ test("should block execution when policy is 'prompt' and no attestations", () => {
50
+ mockGetTrustPolicy.mockImplementation((): TrustPolicy => "prompt");
51
+
52
+ const trustPolicy = mockGetTrustPolicy();
53
+ const minimumAttestations = mockGetMinimumAttestations();
54
+ const verifiedCount = 0;
55
+
56
+ // Simulate the trust check logic from enact_run
57
+ const shouldBlock = verifiedCount < minimumAttestations && trustPolicy === "prompt";
58
+
59
+ expect(shouldBlock).toBe(true);
60
+ });
61
+
62
+ test("should allow execution when policy is 'allow' regardless of attestations", () => {
63
+ mockGetTrustPolicy.mockImplementation((): TrustPolicy => "allow");
64
+
65
+ const trustPolicy = mockGetTrustPolicy();
66
+ const minimumAttestations = mockGetMinimumAttestations();
67
+ const verifiedCount = 0;
68
+
69
+ // Simulate the trust check logic from enact_run
70
+ const shouldBlock =
71
+ verifiedCount < minimumAttestations &&
72
+ (trustPolicy === "require_attestation" || trustPolicy === "prompt");
73
+
74
+ expect(shouldBlock).toBe(false);
75
+ });
76
+
77
+ test("should allow execution when attestations meet minimum requirement", () => {
78
+ mockIsIdentityTrusted.mockImplementation(() => true);
79
+
80
+ const trustPolicy = mockGetTrustPolicy();
81
+ const minimumAttestations = mockGetMinimumAttestations();
82
+ const verifiedCount = 1; // Meets minimum
83
+
84
+ const shouldBlock =
85
+ verifiedCount < minimumAttestations &&
86
+ (trustPolicy === "require_attestation" || trustPolicy === "prompt");
87
+
88
+ expect(shouldBlock).toBe(false);
89
+ });
90
+
91
+ test("should require higher attestation count when minimum_attestations is increased", () => {
92
+ mockGetMinimumAttestations.mockImplementation(() => 3);
93
+
94
+ const trustPolicy = mockGetTrustPolicy();
95
+ const minimumAttestations = mockGetMinimumAttestations();
96
+ const verifiedCount = 2; // Below new minimum of 3
97
+
98
+ const shouldBlock =
99
+ verifiedCount < minimumAttestations && trustPolicy === "require_attestation";
100
+
101
+ expect(shouldBlock).toBe(true);
102
+ });
103
+ });
104
+
105
+ describe("Identity Trust Filtering", () => {
106
+ test("should only count attestations from trusted identities", () => {
107
+ // Mock attestations with various identities
108
+ const attestations = [
109
+ { providerIdentity: "github:trusted-user" },
110
+ { providerIdentity: "github:untrusted-user" },
111
+ { providerIdentity: "google:trusted@company.com" },
112
+ ];
113
+
114
+ // Only trust specific identities
115
+ mockIsIdentityTrusted.mockImplementation((identity: string) => {
116
+ return identity === "github:trusted-user" || identity === "google:trusted@company.com";
117
+ });
118
+
119
+ const verifiedCount = attestations.filter((v) =>
120
+ mockIsIdentityTrusted(v.providerIdentity)
121
+ ).length;
122
+
123
+ expect(verifiedCount).toBe(2);
124
+ });
125
+
126
+ test("should return zero when no attestations are from trusted identities", () => {
127
+ const attestations = [
128
+ { providerIdentity: "github:unknown-user" },
129
+ { providerIdentity: "github:another-unknown" },
130
+ ];
131
+
132
+ mockIsIdentityTrusted.mockImplementation(() => false);
133
+
134
+ const verifiedCount = attestations.filter((v) =>
135
+ mockIsIdentityTrusted(v.providerIdentity)
136
+ ).length;
137
+
138
+ expect(verifiedCount).toBe(0);
139
+ });
140
+ });
141
+
142
+ describe("Error Message Generation", () => {
143
+ test("should generate correct error message for require_attestation policy", () => {
144
+ const trustPolicy = "require_attestation";
145
+ const minimumAttestations = 1;
146
+ const verifiedCount = 0;
147
+
148
+ const errorMessage = `Trust policy violation: Tool requires ${minimumAttestations} attestation(s) from trusted auditors, but only ${verifiedCount} found.\n\nConfigured trust policy: ${trustPolicy}\nTo run unverified tools, update your ~/.enact/config.yaml trust policy to 'allow' or 'prompt'.`;
149
+
150
+ expect(errorMessage).toContain("Trust policy violation");
151
+ expect(errorMessage).toContain("require_attestation");
152
+ expect(errorMessage).toContain("~/.enact/config.yaml");
153
+ });
154
+
155
+ test("should generate correct error message for prompt policy", () => {
156
+ const trustPolicy = "prompt";
157
+ const minimumAttestations = 1;
158
+ const verifiedCount = 0;
159
+
160
+ const errorMessage = `Trust policy violation: Tool requires ${minimumAttestations} attestation(s) from trusted auditors, but only ${verifiedCount} found.\n\nConfigured trust policy: ${trustPolicy}\nMCP server cannot prompt interactively. To run unverified tools via MCP, update your ~/.enact/config.yaml trust policy to 'allow'.`;
161
+
162
+ expect(errorMessage).toContain("Trust policy violation");
163
+ expect(errorMessage).toContain("prompt");
164
+ expect(errorMessage).toContain("cannot prompt interactively");
165
+ });
166
+ });
167
+
168
+ describe("Edge Cases", () => {
169
+ test("should handle minimum_attestations of 0 (always allow)", () => {
170
+ mockGetMinimumAttestations.mockImplementation(() => 0);
171
+
172
+ const trustPolicy = mockGetTrustPolicy();
173
+ const minimumAttestations = mockGetMinimumAttestations();
174
+ const verifiedCount = 0;
175
+
176
+ // 0 < 0 is false, so should not block
177
+ const shouldBlock =
178
+ verifiedCount < minimumAttestations && trustPolicy === "require_attestation";
179
+
180
+ expect(shouldBlock).toBe(false);
181
+ });
182
+
183
+ test("should handle empty attestation list", async () => {
184
+ mockVerifyAllAttestations.mockImplementation(async () => []);
185
+
186
+ const attestations = await mockVerifyAllAttestations();
187
+ const verifiedCount = attestations.filter((v: { providerIdentity: string }) =>
188
+ mockIsIdentityTrusted(v.providerIdentity)
189
+ ).length;
190
+
191
+ expect(verifiedCount).toBe(0);
192
+ });
193
+
194
+ test("should handle attestation verification errors gracefully", async () => {
195
+ mockVerifyAllAttestations.mockImplementation(async () => {
196
+ throw new Error("Network error");
197
+ });
198
+
199
+ let verifiedCount = 0;
200
+ try {
201
+ const attestations = await mockVerifyAllAttestations();
202
+ verifiedCount = attestations.length;
203
+ } catch {
204
+ // Error is caught, verifiedCount stays 0
205
+ }
206
+
207
+ expect(verifiedCount).toBe(0);
208
+ });
209
+ });
210
+ });
211
+
212
+ describe("Trust Policy Integration", () => {
213
+ test("complete flow: unverified tool with require_attestation policy should be blocked", () => {
214
+ // Setup: require_attestation policy, minimum 1 attestation, no trusted attestations
215
+ mockGetTrustPolicy.mockImplementation(() => "require_attestation");
216
+ mockGetMinimumAttestations.mockImplementation(() => 1);
217
+ mockIsIdentityTrusted.mockImplementation(() => false);
218
+
219
+ const attestations = [{ providerIdentity: "github:unknown" }];
220
+
221
+ const trustPolicy = mockGetTrustPolicy();
222
+ const minimumAttestations = mockGetMinimumAttestations();
223
+ const verifiedCount = attestations.filter((v) =>
224
+ mockIsIdentityTrusted(v.providerIdentity)
225
+ ).length;
226
+
227
+ expect(verifiedCount).toBe(0);
228
+ expect(verifiedCount < minimumAttestations).toBe(true);
229
+ expect(trustPolicy).toBe("require_attestation");
230
+
231
+ // Should block
232
+ const shouldBlock =
233
+ verifiedCount < minimumAttestations &&
234
+ (trustPolicy === "require_attestation" || trustPolicy === "prompt");
235
+ expect(shouldBlock).toBe(true);
236
+ });
237
+
238
+ test("complete flow: verified tool with trusted attestation should be allowed", () => {
239
+ // Setup: require_attestation policy, minimum 1 attestation, one trusted attestation
240
+ mockGetTrustPolicy.mockImplementation(() => "require_attestation");
241
+ mockGetMinimumAttestations.mockImplementation(() => 1);
242
+ mockIsIdentityTrusted.mockImplementation(
243
+ (identity: string) => identity === "github:EnactProtocol"
244
+ );
245
+
246
+ const attestations = [{ providerIdentity: "github:EnactProtocol" }];
247
+
248
+ const trustPolicy = mockGetTrustPolicy();
249
+ const minimumAttestations = mockGetMinimumAttestations();
250
+ const verifiedCount = attestations.filter((v) =>
251
+ mockIsIdentityTrusted(v.providerIdentity)
252
+ ).length;
253
+
254
+ expect(verifiedCount).toBe(1);
255
+ expect(verifiedCount >= minimumAttestations).toBe(true);
256
+
257
+ // Should not block
258
+ const shouldBlock =
259
+ verifiedCount < minimumAttestations &&
260
+ (trustPolicy === "require_attestation" || trustPolicy === "prompt");
261
+ expect(shouldBlock).toBe(false);
262
+ });
263
+
264
+ test("complete flow: allow policy bypasses attestation check", () => {
265
+ // Setup: allow policy, no attestations
266
+ mockGetTrustPolicy.mockImplementation((): TrustPolicy => "allow");
267
+ mockGetMinimumAttestations.mockImplementation(() => 1);
268
+ mockIsIdentityTrusted.mockImplementation(() => false);
269
+
270
+ const trustPolicy = mockGetTrustPolicy();
271
+ const minimumAttestations = mockGetMinimumAttestations();
272
+ const verifiedCount = 0;
273
+
274
+ // Should not block because policy is 'allow'
275
+ const shouldBlock =
276
+ verifiedCount < minimumAttestations &&
277
+ (trustPolicy === "require_attestation" || trustPolicy === "prompt");
278
+ expect(shouldBlock).toBe(false);
279
+ });
280
+ });