@enactprotocol/mcp-server 2.2.2 → 2.2.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.2",
3
+ "version": "2.2.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.2",
28
- "@enactprotocol/execution": "2.2.2",
29
- "@enactprotocol/shared": "2.2.2",
27
+ "@enactprotocol/api": "2.2.4",
28
+ "@enactprotocol/execution": "2.2.4",
29
+ "@enactprotocol/shared": "2.2.4",
30
30
  "@modelcontextprotocol/sdk": "^1.10.0"
31
31
  },
32
32
  "devDependencies": {
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  verifyAllAttestations,
28
28
  } from "@enactprotocol/api";
29
29
  import { DaggerExecutionProvider } from "@enactprotocol/execution";
30
+ import { resolveSecret } from "@enactprotocol/secrets";
30
31
  import {
31
32
  type ToolManifest,
32
33
  addMcpTool,
@@ -69,6 +70,33 @@ function fromMcpName(mcpName: string): string {
69
70
  return mcpName.replace(/__/g, "/");
70
71
  }
71
72
 
73
+ /**
74
+ * Resolve secrets from the keyring for a tool's environment variables
75
+ * Only resolves variables marked with secret: true in the manifest
76
+ */
77
+ async function resolveManifestSecrets(
78
+ toolName: string,
79
+ manifest: ToolManifest
80
+ ): Promise<Record<string, string>> {
81
+ const envOverrides: Record<string, string> = {};
82
+
83
+ if (!manifest.env) {
84
+ return envOverrides;
85
+ }
86
+
87
+ for (const [envName, envDecl] of Object.entries(manifest.env)) {
88
+ // Only resolve secrets (not regular env vars)
89
+ if (envDecl && typeof envDecl === "object" && envDecl.secret) {
90
+ const result = await resolveSecret(toolName, envName);
91
+ if (result.found && result.value) {
92
+ envOverrides[envName] = result.value;
93
+ }
94
+ }
95
+ }
96
+
97
+ return envOverrides;
98
+ }
99
+
72
100
  /**
73
101
  * Convert Enact JSON Schema to MCP tool input schema
74
102
  */
@@ -485,13 +513,16 @@ async function handleMetaTool(
485
513
 
486
514
  const finalInputs = validation.coercedValues ?? inputsWithDefaults;
487
515
 
516
+ // Resolve secrets from keyring
517
+ const secretOverrides = await resolveManifestSecrets(toolNameArg, manifest);
518
+
488
519
  // Execute the tool
489
520
  const provider = new DaggerExecutionProvider({ verbose: false });
490
521
  await provider.initialize();
491
522
 
492
523
  const result = await provider.execute(
493
524
  manifest,
494
- { params: finalInputs, envOverrides: {} },
525
+ { params: finalInputs, envOverrides: secretOverrides },
495
526
  { mountDirs: { [cachePath]: "/workspace" } }
496
527
  );
497
528
 
@@ -705,6 +736,9 @@ function createMcpServer(): Server {
705
736
 
706
737
  const finalInputs = validation.coercedValues ?? inputsWithDefaults;
707
738
 
739
+ // Resolve secrets from keyring
740
+ const secretOverrides = await resolveManifestSecrets(enactToolName, manifest);
741
+
708
742
  // Execute the tool using Dagger
709
743
  const provider = new DaggerExecutionProvider({
710
744
  verbose: false,
@@ -717,7 +751,7 @@ function createMcpServer(): Server {
717
751
  manifest,
718
752
  {
719
753
  params: finalInputs,
720
- envOverrides: {},
754
+ envOverrides: secretOverrides,
721
755
  },
722
756
  {
723
757
  mountDirs: {
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Tests for MCP server secret resolution from keyring
3
+ *
4
+ * These tests verify that the MCP server properly resolves secrets
5
+ * from the OS keyring for tools that declare secret environment variables.
6
+ */
7
+
8
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
9
+ import type { EnvVariable, ToolManifest } from "@enactprotocol/shared";
10
+
11
+ // Type for secret resolution result
12
+ type SecretResolutionResult =
13
+ | { found: true; key: string; value: string; namespace: string }
14
+ | { found: false; key: string; searchedNamespaces: string[] };
15
+
16
+ // Mock the resolveSecret function from @enactprotocol/secrets
17
+ const mockResolveSecret = mock(
18
+ async (_toolPath: string, _secretName: string): Promise<SecretResolutionResult> => ({
19
+ found: false,
20
+ key: _secretName,
21
+ searchedNamespaces: [_toolPath],
22
+ })
23
+ );
24
+
25
+ /**
26
+ * Simulates the resolveManifestSecrets function from the MCP server
27
+ * This is the logic we're testing
28
+ */
29
+ async function resolveManifestSecrets(
30
+ toolName: string,
31
+ manifest: ToolManifest
32
+ ): Promise<Record<string, string>> {
33
+ const envOverrides: Record<string, string> = {};
34
+
35
+ if (!manifest.env) {
36
+ return envOverrides;
37
+ }
38
+
39
+ for (const [envName, envDecl] of Object.entries(manifest.env)) {
40
+ // Only resolve secrets (not regular env vars)
41
+ if (envDecl && typeof envDecl === "object" && envDecl.secret) {
42
+ const result = (await mockResolveSecret(toolName, envName)) as SecretResolutionResult;
43
+ if (result.found && result.value) {
44
+ envOverrides[envName] = result.value;
45
+ }
46
+ }
47
+ }
48
+
49
+ return envOverrides;
50
+ }
51
+
52
+ describe("MCP Server Secret Resolution", () => {
53
+ beforeEach(() => {
54
+ mockResolveSecret.mockReset();
55
+ mockResolveSecret.mockImplementation(async (_toolPath: string, secretName: string) => ({
56
+ found: false as const,
57
+ key: secretName,
58
+ searchedNamespaces: [_toolPath],
59
+ }));
60
+ });
61
+
62
+ describe("resolveManifestSecrets", () => {
63
+ test("should return empty object when manifest has no env field", async () => {
64
+ const manifest: ToolManifest = {
65
+ name: "test/tool",
66
+ version: "1.0.0",
67
+ description: "Test tool",
68
+ from: "node:20",
69
+ command: "node test.js",
70
+ };
71
+
72
+ const result = await resolveManifestSecrets("test/tool", manifest);
73
+
74
+ expect(result).toEqual({});
75
+ expect(mockResolveSecret).not.toHaveBeenCalled();
76
+ });
77
+
78
+ test("should return empty object when manifest has empty env field", async () => {
79
+ const manifest: ToolManifest = {
80
+ name: "test/tool",
81
+ version: "1.0.0",
82
+ description: "Test tool",
83
+ from: "node:20",
84
+ command: "node test.js",
85
+ env: {},
86
+ };
87
+
88
+ const result = await resolveManifestSecrets("test/tool", manifest);
89
+
90
+ expect(result).toEqual({});
91
+ expect(mockResolveSecret).not.toHaveBeenCalled();
92
+ });
93
+
94
+ test("should not resolve env vars without secret: true", async () => {
95
+ const manifest: ToolManifest = {
96
+ name: "test/tool",
97
+ version: "1.0.0",
98
+ description: "Test tool",
99
+ from: "node:20",
100
+ command: "node test.js",
101
+ env: {
102
+ REGULAR_VAR: {
103
+ description: "A regular environment variable",
104
+ },
105
+ ANOTHER_VAR: {
106
+ description: "Another regular var",
107
+ secret: false,
108
+ },
109
+ },
110
+ };
111
+
112
+ const result = await resolveManifestSecrets("test/tool", manifest);
113
+
114
+ expect(result).toEqual({});
115
+ expect(mockResolveSecret).not.toHaveBeenCalled();
116
+ });
117
+
118
+ test("should resolve env vars with secret: true", async () => {
119
+ mockResolveSecret.mockImplementation(async (_toolPath: string, secretName: string) => ({
120
+ found: true as const,
121
+ key: secretName,
122
+ value: "secret-value-123",
123
+ namespace: "enact",
124
+ }));
125
+
126
+ const manifest: ToolManifest = {
127
+ name: "enact/firecrawl",
128
+ version: "1.0.0",
129
+ description: "Firecrawl tool",
130
+ from: "node:20",
131
+ command: "node firecrawl.js",
132
+ env: {
133
+ FIRECRAWL_API_KEY: {
134
+ description: "Your Firecrawl API key",
135
+ secret: true,
136
+ },
137
+ },
138
+ };
139
+
140
+ const result = await resolveManifestSecrets("enact/firecrawl", manifest);
141
+
142
+ expect(result).toEqual({ FIRECRAWL_API_KEY: "secret-value-123" });
143
+ expect(mockResolveSecret).toHaveBeenCalledWith("enact/firecrawl", "FIRECRAWL_API_KEY");
144
+ });
145
+
146
+ test("should resolve multiple secrets", async () => {
147
+ mockResolveSecret.mockImplementation(async (_toolPath: string, secretName: string) => {
148
+ const secrets: Record<string, string> = {
149
+ API_KEY: "api-key-value",
150
+ API_SECRET: "api-secret-value",
151
+ };
152
+ return {
153
+ found: true as const,
154
+ key: secretName,
155
+ value: secrets[secretName] || "",
156
+ namespace: "enact",
157
+ };
158
+ });
159
+
160
+ const manifest: ToolManifest = {
161
+ name: "test/multi-secret",
162
+ version: "1.0.0",
163
+ description: "Tool with multiple secrets",
164
+ from: "node:20",
165
+ command: "node test.js",
166
+ env: {
167
+ API_KEY: {
168
+ description: "API key",
169
+ secret: true,
170
+ },
171
+ API_SECRET: {
172
+ description: "API secret",
173
+ secret: true,
174
+ },
175
+ REGULAR_VAR: {
176
+ description: "Not a secret",
177
+ },
178
+ },
179
+ };
180
+
181
+ const result = await resolveManifestSecrets("test/multi-secret", manifest);
182
+
183
+ expect(result).toEqual({
184
+ API_KEY: "api-key-value",
185
+ API_SECRET: "api-secret-value",
186
+ });
187
+ expect(mockResolveSecret).toHaveBeenCalledTimes(2);
188
+ });
189
+
190
+ test("should handle missing secrets gracefully", async () => {
191
+ mockResolveSecret.mockImplementation(async (_toolPath: string, secretName: string) => ({
192
+ found: false as const,
193
+ key: secretName,
194
+ searchedNamespaces: [_toolPath],
195
+ }));
196
+
197
+ const manifest: ToolManifest = {
198
+ name: "test/tool",
199
+ version: "1.0.0",
200
+ description: "Test tool",
201
+ from: "node:20",
202
+ command: "node test.js",
203
+ env: {
204
+ MISSING_SECRET: {
205
+ description: "A secret that is not configured",
206
+ secret: true,
207
+ },
208
+ },
209
+ };
210
+
211
+ const result = await resolveManifestSecrets("test/tool", manifest);
212
+
213
+ expect(result).toEqual({});
214
+ expect(mockResolveSecret).toHaveBeenCalledWith("test/tool", "MISSING_SECRET");
215
+ });
216
+
217
+ test("should handle partial secret availability", async () => {
218
+ mockResolveSecret.mockImplementation(async (_toolPath: string, secretName: string) => {
219
+ if (secretName === "AVAILABLE_SECRET") {
220
+ return {
221
+ found: true as const,
222
+ key: secretName,
223
+ value: "available-value",
224
+ namespace: "enact",
225
+ };
226
+ }
227
+ return {
228
+ found: false as const,
229
+ key: secretName,
230
+ searchedNamespaces: [_toolPath],
231
+ };
232
+ });
233
+
234
+ const manifest: ToolManifest = {
235
+ name: "test/tool",
236
+ version: "1.0.0",
237
+ description: "Test tool",
238
+ from: "node:20",
239
+ command: "node test.js",
240
+ env: {
241
+ AVAILABLE_SECRET: {
242
+ description: "This secret exists",
243
+ secret: true,
244
+ },
245
+ MISSING_SECRET: {
246
+ description: "This secret does not exist",
247
+ secret: true,
248
+ },
249
+ },
250
+ };
251
+
252
+ const result = await resolveManifestSecrets("test/tool", manifest);
253
+
254
+ expect(result).toEqual({ AVAILABLE_SECRET: "available-value" });
255
+ expect(mockResolveSecret).toHaveBeenCalledTimes(2);
256
+ });
257
+ });
258
+
259
+ describe("Namespace Inheritance", () => {
260
+ test("should pass correct tool name for namespace resolution", async () => {
261
+ mockResolveSecret.mockImplementation(
262
+ async (toolPath: string, secretName: string): Promise<SecretResolutionResult> => ({
263
+ found: true,
264
+ key: secretName,
265
+ value: `resolved-from-${toolPath}`,
266
+ namespace: toolPath.split("/")[0] || toolPath,
267
+ })
268
+ );
269
+
270
+ const manifest: ToolManifest = {
271
+ name: "enact/firecrawl",
272
+ version: "1.0.0",
273
+ description: "Firecrawl tool",
274
+ from: "node:20",
275
+ command: "node firecrawl.js",
276
+ env: {
277
+ FIRECRAWL_API_KEY: {
278
+ description: "API key",
279
+ secret: true,
280
+ },
281
+ },
282
+ };
283
+
284
+ await resolveManifestSecrets("enact/firecrawl", manifest);
285
+
286
+ // Verify the tool name is passed correctly for namespace chain resolution
287
+ expect(mockResolveSecret).toHaveBeenCalledWith("enact/firecrawl", "FIRECRAWL_API_KEY");
288
+ });
289
+
290
+ test("should work with deeply nested tool paths", async () => {
291
+ mockResolveSecret.mockImplementation(async (_toolPath: string, secretName: string) => ({
292
+ found: true as const,
293
+ key: secretName,
294
+ value: "deep-secret",
295
+ namespace: "alice/api",
296
+ }));
297
+
298
+ const manifest: ToolManifest = {
299
+ name: "alice/api/slack/notifier",
300
+ version: "1.0.0",
301
+ description: "Slack notifier",
302
+ from: "node:20",
303
+ command: "node notify.js",
304
+ env: {
305
+ SLACK_TOKEN: {
306
+ description: "Slack API token",
307
+ secret: true,
308
+ },
309
+ },
310
+ };
311
+
312
+ const result = await resolveManifestSecrets("alice/api/slack/notifier", manifest);
313
+
314
+ expect(result).toEqual({ SLACK_TOKEN: "deep-secret" });
315
+ expect(mockResolveSecret).toHaveBeenCalledWith("alice/api/slack/notifier", "SLACK_TOKEN");
316
+ });
317
+ });
318
+
319
+ describe("Edge Cases", () => {
320
+ test("should handle empty string secret values", async () => {
321
+ mockResolveSecret.mockImplementation(async (_toolPath: string, secretName: string) => ({
322
+ found: true as const,
323
+ key: secretName,
324
+ value: "",
325
+ namespace: "enact",
326
+ }));
327
+
328
+ const manifest: ToolManifest = {
329
+ name: "test/tool",
330
+ version: "1.0.0",
331
+ description: "Test tool",
332
+ from: "node:20",
333
+ command: "node test.js",
334
+ env: {
335
+ EMPTY_SECRET: {
336
+ description: "An empty secret",
337
+ secret: true,
338
+ },
339
+ },
340
+ };
341
+
342
+ const result = await resolveManifestSecrets("test/tool", manifest);
343
+
344
+ // Empty string should not be included (falsy value check)
345
+ expect(result).toEqual({});
346
+ });
347
+
348
+ test("should handle env declarations that are just strings", async () => {
349
+ const manifest: ToolManifest = {
350
+ name: "test/tool",
351
+ version: "1.0.0",
352
+ description: "Test tool",
353
+ from: "node:20",
354
+ command: "node test.js",
355
+ env: {
356
+ // Some manifests might have string values
357
+ STRING_VAR: "default-value" as unknown as EnvVariable,
358
+ },
359
+ };
360
+
361
+ const result = await resolveManifestSecrets("test/tool", manifest);
362
+
363
+ expect(result).toEqual({});
364
+ expect(mockResolveSecret).not.toHaveBeenCalled();
365
+ });
366
+
367
+ test("should handle null env declaration values", async () => {
368
+ const manifest: ToolManifest = {
369
+ name: "test/tool",
370
+ version: "1.0.0",
371
+ description: "Test tool",
372
+ from: "node:20",
373
+ command: "node test.js",
374
+ env: {
375
+ NULL_VAR: null as unknown as EnvVariable,
376
+ },
377
+ };
378
+
379
+ const result = await resolveManifestSecrets("test/tool", manifest);
380
+
381
+ expect(result).toEqual({});
382
+ expect(mockResolveSecret).not.toHaveBeenCalled();
383
+ });
384
+ });
385
+ });
386
+
387
+ describe("Secret Resolution Integration", () => {
388
+ beforeEach(() => {
389
+ mockResolveSecret.mockReset();
390
+ mockResolveSecret.mockImplementation(async (_toolPath: string, secretName: string) => ({
391
+ found: false as const,
392
+ key: secretName,
393
+ searchedNamespaces: [_toolPath],
394
+ }));
395
+ });
396
+
397
+ test("complete flow: tool with API key secret should receive it in envOverrides", async () => {
398
+ // Simulate a real firecrawl-like scenario
399
+ mockResolveSecret.mockImplementation(async (_toolPath: string, secretName: string) => {
400
+ if (secretName === "FIRECRAWL_API_KEY") {
401
+ return {
402
+ found: true as const,
403
+ key: secretName,
404
+ value: "fc-abc123xyz",
405
+ namespace: "enact",
406
+ };
407
+ }
408
+ return {
409
+ found: false as const,
410
+ key: secretName,
411
+ searchedNamespaces: [_toolPath],
412
+ };
413
+ });
414
+
415
+ const firecrawlManifest: ToolManifest = {
416
+ name: "enact/firecrawl",
417
+ version: "1.2.1",
418
+ description: "Scrape websites using Firecrawl API",
419
+ from: "node:20",
420
+ command: "node /work/firecrawl.js",
421
+ env: {
422
+ FIRECRAWL_API_KEY: {
423
+ description: "Your Firecrawl API key from firecrawl.dev",
424
+ secret: true,
425
+ },
426
+ },
427
+ inputSchema: {
428
+ type: "object",
429
+ properties: {
430
+ url: { type: "string" },
431
+ action: { type: "string" },
432
+ },
433
+ required: ["url"],
434
+ },
435
+ };
436
+
437
+ const envOverrides = await resolveManifestSecrets("enact/firecrawl", firecrawlManifest);
438
+
439
+ // This is what would be passed to the execution provider
440
+ expect(envOverrides).toEqual({
441
+ FIRECRAWL_API_KEY: "fc-abc123xyz",
442
+ });
443
+ });
444
+
445
+ test("complete flow: tool without secrets should get empty envOverrides", async () => {
446
+ const helloManifest: ToolManifest = {
447
+ name: "enact/hello-js",
448
+ version: "1.0.0",
449
+ description: "A simple greeting tool",
450
+ from: "node:20",
451
+ command: "node /work/greet.js",
452
+ inputSchema: {
453
+ type: "object",
454
+ properties: {
455
+ name: { type: "string" },
456
+ },
457
+ },
458
+ };
459
+
460
+ const envOverrides = await resolveManifestSecrets("enact/hello-js", helloManifest);
461
+
462
+ expect(envOverrides).toEqual({});
463
+ expect(mockResolveSecret).not.toHaveBeenCalled();
464
+ });
465
+ });