@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/dist/index.js +360 -337
- package/package.json +4 -4
- package/src/index.ts +258 -109
- package/tests/secrets.test.ts +455 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,455 @@
|
|
|
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
|
+
env: {
|
|
421
|
+
FIRECRAWL_API_KEY: {
|
|
422
|
+
description: "Your Firecrawl API key from firecrawl.dev",
|
|
423
|
+
secret: true,
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
scripts: {
|
|
427
|
+
scrape: "node /work/firecrawl.js {{url}}",
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const envOverrides = await resolveManifestSecrets("enact/firecrawl", firecrawlManifest);
|
|
432
|
+
|
|
433
|
+
// This is what would be passed to the execution provider
|
|
434
|
+
expect(envOverrides).toEqual({
|
|
435
|
+
FIRECRAWL_API_KEY: "fc-abc123xyz",
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("complete flow: tool without secrets should get empty envOverrides", async () => {
|
|
440
|
+
const helloManifest: ToolManifest = {
|
|
441
|
+
name: "enact/hello-js",
|
|
442
|
+
version: "1.0.0",
|
|
443
|
+
description: "A simple greeting tool",
|
|
444
|
+
from: "node:20",
|
|
445
|
+
scripts: {
|
|
446
|
+
greet: "node /work/greet.js {{name}}",
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const envOverrides = await resolveManifestSecrets("enact/hello-js", helloManifest);
|
|
451
|
+
|
|
452
|
+
expect(envOverrides).toEqual({});
|
|
453
|
+
expect(mockResolveSecret).not.toHaveBeenCalled();
|
|
454
|
+
});
|
|
455
|
+
});
|