mcp-rubber-duck 1.7.0 → 1.9.0

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.
Files changed (169) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +274 -2
  3. package/audit-ci.json +2 -1
  4. package/dist/config/config.d.ts +2 -0
  5. package/dist/config/config.d.ts.map +1 -1
  6. package/dist/config/config.js +144 -1
  7. package/dist/config/config.js.map +1 -1
  8. package/dist/config/types.d.ts +1084 -2
  9. package/dist/config/types.d.ts.map +1 -1
  10. package/dist/config/types.js +59 -0
  11. package/dist/config/types.js.map +1 -1
  12. package/dist/guardrails/context.d.ts +10 -0
  13. package/dist/guardrails/context.d.ts.map +1 -0
  14. package/dist/guardrails/context.js +35 -0
  15. package/dist/guardrails/context.js.map +1 -0
  16. package/dist/guardrails/errors.d.ts +26 -0
  17. package/dist/guardrails/errors.d.ts.map +1 -0
  18. package/dist/guardrails/errors.js +42 -0
  19. package/dist/guardrails/errors.js.map +1 -0
  20. package/dist/guardrails/index.d.ts +6 -0
  21. package/dist/guardrails/index.d.ts.map +1 -0
  22. package/dist/guardrails/index.js +11 -0
  23. package/dist/guardrails/index.js.map +1 -0
  24. package/dist/guardrails/plugins/base-plugin.d.ts +35 -0
  25. package/dist/guardrails/plugins/base-plugin.d.ts.map +1 -0
  26. package/dist/guardrails/plugins/base-plugin.js +70 -0
  27. package/dist/guardrails/plugins/base-plugin.js.map +1 -0
  28. package/dist/guardrails/plugins/index.d.ts +6 -0
  29. package/dist/guardrails/plugins/index.d.ts.map +1 -0
  30. package/dist/guardrails/plugins/index.js +6 -0
  31. package/dist/guardrails/plugins/index.js.map +1 -0
  32. package/dist/guardrails/plugins/pattern-blocker.d.ts +27 -0
  33. package/dist/guardrails/plugins/pattern-blocker.d.ts.map +1 -0
  34. package/dist/guardrails/plugins/pattern-blocker.js +140 -0
  35. package/dist/guardrails/plugins/pattern-blocker.js.map +1 -0
  36. package/dist/guardrails/plugins/pii-redactor/detectors.d.ts +40 -0
  37. package/dist/guardrails/plugins/pii-redactor/detectors.d.ts.map +1 -0
  38. package/dist/guardrails/plugins/pii-redactor/detectors.js +134 -0
  39. package/dist/guardrails/plugins/pii-redactor/detectors.js.map +1 -0
  40. package/dist/guardrails/plugins/pii-redactor/index.d.ts +28 -0
  41. package/dist/guardrails/plugins/pii-redactor/index.d.ts.map +1 -0
  42. package/dist/guardrails/plugins/pii-redactor/index.js +157 -0
  43. package/dist/guardrails/plugins/pii-redactor/index.js.map +1 -0
  44. package/dist/guardrails/plugins/pii-redactor/pseudonymizer.d.ts +33 -0
  45. package/dist/guardrails/plugins/pii-redactor/pseudonymizer.d.ts.map +1 -0
  46. package/dist/guardrails/plugins/pii-redactor/pseudonymizer.js +70 -0
  47. package/dist/guardrails/plugins/pii-redactor/pseudonymizer.js.map +1 -0
  48. package/dist/guardrails/plugins/rate-limiter.d.ts +28 -0
  49. package/dist/guardrails/plugins/rate-limiter.d.ts.map +1 -0
  50. package/dist/guardrails/plugins/rate-limiter.js +91 -0
  51. package/dist/guardrails/plugins/rate-limiter.js.map +1 -0
  52. package/dist/guardrails/plugins/token-limiter.d.ts +30 -0
  53. package/dist/guardrails/plugins/token-limiter.d.ts.map +1 -0
  54. package/dist/guardrails/plugins/token-limiter.js +98 -0
  55. package/dist/guardrails/plugins/token-limiter.js.map +1 -0
  56. package/dist/guardrails/service.d.ts +38 -0
  57. package/dist/guardrails/service.d.ts.map +1 -0
  58. package/dist/guardrails/service.js +183 -0
  59. package/dist/guardrails/service.js.map +1 -0
  60. package/dist/guardrails/types.d.ts +96 -0
  61. package/dist/guardrails/types.d.ts.map +1 -0
  62. package/dist/guardrails/types.js +2 -0
  63. package/dist/guardrails/types.js.map +1 -0
  64. package/dist/prompts/architecture.d.ts +6 -0
  65. package/dist/prompts/architecture.d.ts.map +1 -0
  66. package/dist/prompts/architecture.js +103 -0
  67. package/dist/prompts/architecture.js.map +1 -0
  68. package/dist/prompts/assumptions.d.ts +6 -0
  69. package/dist/prompts/assumptions.d.ts.map +1 -0
  70. package/dist/prompts/assumptions.js +72 -0
  71. package/dist/prompts/assumptions.js.map +1 -0
  72. package/dist/prompts/blindspots.d.ts +6 -0
  73. package/dist/prompts/blindspots.d.ts.map +1 -0
  74. package/dist/prompts/blindspots.js +71 -0
  75. package/dist/prompts/blindspots.js.map +1 -0
  76. package/dist/prompts/diverge-converge.d.ts +6 -0
  77. package/dist/prompts/diverge-converge.d.ts.map +1 -0
  78. package/dist/prompts/diverge-converge.js +85 -0
  79. package/dist/prompts/diverge-converge.js.map +1 -0
  80. package/dist/prompts/index.d.ts +22 -0
  81. package/dist/prompts/index.d.ts.map +1 -0
  82. package/dist/prompts/index.js +57 -0
  83. package/dist/prompts/index.js.map +1 -0
  84. package/dist/prompts/perspectives.d.ts +7 -0
  85. package/dist/prompts/perspectives.d.ts.map +1 -0
  86. package/dist/prompts/perspectives.js +65 -0
  87. package/dist/prompts/perspectives.js.map +1 -0
  88. package/dist/prompts/red-team.d.ts +6 -0
  89. package/dist/prompts/red-team.d.ts.map +1 -0
  90. package/dist/prompts/red-team.js +83 -0
  91. package/dist/prompts/red-team.js.map +1 -0
  92. package/dist/prompts/reframe.d.ts +6 -0
  93. package/dist/prompts/reframe.d.ts.map +1 -0
  94. package/dist/prompts/reframe.js +71 -0
  95. package/dist/prompts/reframe.js.map +1 -0
  96. package/dist/prompts/tradeoffs.d.ts +6 -0
  97. package/dist/prompts/tradeoffs.d.ts.map +1 -0
  98. package/dist/prompts/tradeoffs.js +87 -0
  99. package/dist/prompts/tradeoffs.js.map +1 -0
  100. package/dist/prompts/types.d.ts +14 -0
  101. package/dist/prompts/types.d.ts.map +1 -0
  102. package/dist/prompts/types.js +2 -0
  103. package/dist/prompts/types.js.map +1 -0
  104. package/dist/providers/duck-provider-enhanced.d.ts +2 -1
  105. package/dist/providers/duck-provider-enhanced.d.ts.map +1 -1
  106. package/dist/providers/duck-provider-enhanced.js +55 -6
  107. package/dist/providers/duck-provider-enhanced.js.map +1 -1
  108. package/dist/providers/enhanced-manager.d.ts +2 -1
  109. package/dist/providers/enhanced-manager.d.ts.map +1 -1
  110. package/dist/providers/enhanced-manager.js +3 -3
  111. package/dist/providers/enhanced-manager.js.map +1 -1
  112. package/dist/providers/manager.d.ts +3 -1
  113. package/dist/providers/manager.d.ts.map +1 -1
  114. package/dist/providers/manager.js +4 -2
  115. package/dist/providers/manager.js.map +1 -1
  116. package/dist/providers/provider.d.ts +3 -1
  117. package/dist/providers/provider.d.ts.map +1 -1
  118. package/dist/providers/provider.js +43 -3
  119. package/dist/providers/provider.js.map +1 -1
  120. package/dist/server.d.ts +1 -0
  121. package/dist/server.d.ts.map +1 -1
  122. package/dist/server.js +48 -7
  123. package/dist/server.js.map +1 -1
  124. package/dist/services/function-bridge.d.ts +3 -1
  125. package/dist/services/function-bridge.d.ts.map +1 -1
  126. package/dist/services/function-bridge.js +40 -1
  127. package/dist/services/function-bridge.js.map +1 -1
  128. package/package.json +1 -1
  129. package/src/config/config.ts +187 -1
  130. package/src/config/types.ts +73 -0
  131. package/src/guardrails/context.ts +37 -0
  132. package/src/guardrails/errors.ts +46 -0
  133. package/src/guardrails/index.ts +20 -0
  134. package/src/guardrails/plugins/base-plugin.ts +103 -0
  135. package/src/guardrails/plugins/index.ts +5 -0
  136. package/src/guardrails/plugins/pattern-blocker.ts +190 -0
  137. package/src/guardrails/plugins/pii-redactor/detectors.ts +200 -0
  138. package/src/guardrails/plugins/pii-redactor/index.ts +203 -0
  139. package/src/guardrails/plugins/pii-redactor/pseudonymizer.ts +91 -0
  140. package/src/guardrails/plugins/rate-limiter.ts +142 -0
  141. package/src/guardrails/plugins/token-limiter.ts +155 -0
  142. package/src/guardrails/service.ts +209 -0
  143. package/src/guardrails/types.ts +120 -0
  144. package/src/prompts/architecture.ts +111 -0
  145. package/src/prompts/assumptions.ts +80 -0
  146. package/src/prompts/blindspots.ts +79 -0
  147. package/src/prompts/diverge-converge.ts +92 -0
  148. package/src/prompts/index.ts +63 -0
  149. package/src/prompts/perspectives.ts +73 -0
  150. package/src/prompts/red-team.ts +91 -0
  151. package/src/prompts/reframe.ts +78 -0
  152. package/src/prompts/tradeoffs.ts +95 -0
  153. package/src/prompts/types.ts +14 -0
  154. package/src/providers/duck-provider-enhanced.ts +76 -7
  155. package/src/providers/enhanced-manager.ts +5 -3
  156. package/src/providers/manager.ts +6 -3
  157. package/src/providers/provider.ts +57 -6
  158. package/src/server.ts +55 -6
  159. package/src/services/function-bridge.ts +53 -2
  160. package/tests/guardrails/config.test.ts +267 -0
  161. package/tests/guardrails/errors.test.ts +109 -0
  162. package/tests/guardrails/plugins/pattern-blocker.test.ts +309 -0
  163. package/tests/guardrails/plugins/pii-redactor.test.ts +1004 -0
  164. package/tests/guardrails/plugins/rate-limiter.test.ts +310 -0
  165. package/tests/guardrails/plugins/token-limiter.test.ts +216 -0
  166. package/tests/guardrails/service.test.ts +911 -0
  167. package/tests/mcp-bridge.test.ts +248 -0
  168. package/tests/prompts.test.ts +314 -0
  169. package/tests/providers.test.ts +739 -0
@@ -2,6 +2,8 @@ import { jest } from '@jest/globals';
2
2
  import { ApprovalService } from '../src/services/approval';
3
3
  import { FunctionBridge } from '../src/services/function-bridge';
4
4
  import { MCPClientManager } from '../src/services/mcp-client-manager';
5
+ import { GuardrailsService } from '../src/guardrails/service';
6
+ import { GuardrailBlockError } from '../src/guardrails/errors';
5
7
 
6
8
  describe('MCP Bridge', () => {
7
9
  let approvalService: ApprovalService;
@@ -288,4 +290,250 @@ describe('MCP Bridge', () => {
288
290
  expect(functions[0].description).toBe('[filesystem] Read a file');
289
291
  });
290
292
  });
293
+
294
+ describe('FunctionBridge with Guardrails', () => {
295
+ let guardedFunctionBridge: FunctionBridge;
296
+ let mockGuardrailsService: {
297
+ isEnabled: jest.Mock;
298
+ createContext: jest.Mock;
299
+ execute: jest.Mock;
300
+ };
301
+
302
+ beforeEach(() => {
303
+ // Create mock guardrails service
304
+ mockGuardrailsService = {
305
+ isEnabled: jest.fn().mockReturnValue(true),
306
+ createContext: jest.fn().mockImplementation((params) => ({
307
+ requestId: 'test-request-id',
308
+ toolName: params.toolName,
309
+ toolArgs: params.toolArgs,
310
+ violations: [],
311
+ modifications: [],
312
+ metadata: new Map(),
313
+ })),
314
+ execute: jest.fn().mockResolvedValue({ action: 'allow', context: {} }),
315
+ };
316
+
317
+ guardedFunctionBridge = new FunctionBridge(
318
+ mcpManager,
319
+ approvalService,
320
+ [],
321
+ 'never', // Never require approval for tests
322
+ {},
323
+ mockGuardrailsService as unknown as GuardrailsService
324
+ );
325
+ });
326
+
327
+ it('should execute pre_tool_input guardrails before tool execution', async () => {
328
+ // Mock the MCP manager to simulate a connected server
329
+ jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'test result' });
330
+
331
+ await guardedFunctionBridge.handleFunctionCall(
332
+ 'TestDuck',
333
+ 'mcp__test_server__test_tool',
334
+ { _mcp_server: 'test_server', _mcp_tool: 'test_tool', arg1: 'value1' }
335
+ );
336
+
337
+ expect(mockGuardrailsService.isEnabled).toHaveBeenCalled();
338
+ expect(mockGuardrailsService.createContext).toHaveBeenCalledWith(
339
+ expect.objectContaining({
340
+ toolName: 'test_server:test_tool',
341
+ })
342
+ );
343
+ expect(mockGuardrailsService.execute).toHaveBeenCalledWith('pre_tool_input', expect.any(Object));
344
+ });
345
+
346
+ it('should execute post_tool_output guardrails after tool execution', async () => {
347
+ jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'test result' });
348
+
349
+ await guardedFunctionBridge.handleFunctionCall(
350
+ 'TestDuck',
351
+ 'mcp__test_server__test_tool',
352
+ { _mcp_server: 'test_server', _mcp_tool: 'test_tool' }
353
+ );
354
+
355
+ // Should be called twice: pre_tool_input and post_tool_output
356
+ expect(mockGuardrailsService.execute).toHaveBeenCalledTimes(2);
357
+ expect(mockGuardrailsService.execute).toHaveBeenNthCalledWith(1, 'pre_tool_input', expect.any(Object));
358
+ expect(mockGuardrailsService.execute).toHaveBeenNthCalledWith(2, 'post_tool_output', expect.any(Object));
359
+ });
360
+
361
+ it('should block tool input when pre_tool_input guardrails return block', async () => {
362
+ mockGuardrailsService.execute.mockResolvedValueOnce({
363
+ action: 'block',
364
+ blockedBy: 'pattern_blocker',
365
+ blockReason: 'Sensitive data detected in tool arguments',
366
+ context: {},
367
+ });
368
+
369
+ const callToolSpy = jest.spyOn(mcpManager, 'callTool');
370
+
371
+ await expect(
372
+ guardedFunctionBridge.handleFunctionCall(
373
+ 'TestDuck',
374
+ 'mcp__test_server__test_tool',
375
+ { _mcp_server: 'test_server', _mcp_tool: 'test_tool', secret: 'password123' }
376
+ )
377
+ ).rejects.toThrow("Request blocked by guardrail 'pattern_blocker': Sensitive data detected in tool arguments");
378
+
379
+ // Should NOT call the MCP tool when blocked
380
+ expect(callToolSpy).not.toHaveBeenCalled();
381
+ });
382
+
383
+ it('should block tool output when post_tool_output guardrails return block', async () => {
384
+ jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ sensitiveData: 'should_be_blocked' });
385
+
386
+ // Pre-tool allows, post-tool blocks
387
+ mockGuardrailsService.execute
388
+ .mockResolvedValueOnce({ action: 'allow', context: {} })
389
+ .mockResolvedValueOnce({
390
+ action: 'block',
391
+ blockedBy: 'pii_redactor',
392
+ blockReason: 'Sensitive data in tool output',
393
+ context: {},
394
+ });
395
+
396
+ await expect(
397
+ guardedFunctionBridge.handleFunctionCall(
398
+ 'TestDuck',
399
+ 'mcp__test_server__test_tool',
400
+ { _mcp_server: 'test_server', _mcp_tool: 'test_tool' }
401
+ )
402
+ ).rejects.toThrow("Request blocked by guardrail 'pii_redactor': Sensitive data in tool output");
403
+ });
404
+
405
+ it('should modify tool args when pre_tool_input guardrails return modify', async () => {
406
+ const modifiedArgs = { arg1: '[REDACTED]', _mcp_server: 'test_server', _mcp_tool: 'test_tool' };
407
+
408
+ mockGuardrailsService.execute.mockImplementation((phase) => {
409
+ if (phase === 'pre_tool_input') {
410
+ return Promise.resolve({
411
+ action: 'modify',
412
+ context: { toolArgs: modifiedArgs },
413
+ });
414
+ }
415
+ return Promise.resolve({ action: 'allow', context: {} });
416
+ });
417
+
418
+ mockGuardrailsService.createContext.mockReturnValue({
419
+ requestId: 'test-id',
420
+ toolName: 'test_server:test_tool',
421
+ toolArgs: modifiedArgs,
422
+ violations: [],
423
+ modifications: [],
424
+ metadata: new Map(),
425
+ });
426
+
427
+ const callToolSpy = jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'ok' });
428
+
429
+ await guardedFunctionBridge.handleFunctionCall(
430
+ 'TestDuck',
431
+ 'mcp__test_server__test_tool',
432
+ { _mcp_server: 'test_server', _mcp_tool: 'test_tool', arg1: 'sensitive_value' }
433
+ );
434
+
435
+ // The MCP tool should receive the modified args
436
+ expect(callToolSpy).toHaveBeenCalledWith(
437
+ 'test_server',
438
+ 'test_tool',
439
+ expect.objectContaining({ arg1: '[REDACTED]' })
440
+ );
441
+ });
442
+
443
+ it('should modify tool result when post_tool_output guardrails return modify', async () => {
444
+ const sharedContext: {
445
+ requestId: string;
446
+ toolName: string;
447
+ toolArgs: Record<string, unknown>;
448
+ toolResult: unknown;
449
+ violations: unknown[];
450
+ modifications: unknown[];
451
+ metadata: Map<string, unknown>;
452
+ } = {
453
+ requestId: 'test-id',
454
+ toolName: 'test_server:test_tool',
455
+ toolArgs: {},
456
+ toolResult: undefined,
457
+ violations: [],
458
+ modifications: [],
459
+ metadata: new Map(),
460
+ };
461
+
462
+ mockGuardrailsService.createContext.mockReturnValue(sharedContext);
463
+
464
+ mockGuardrailsService.execute.mockImplementation((phase) => {
465
+ if (phase === 'post_tool_output') {
466
+ sharedContext.toolResult = { redacted: '[SENSITIVE DATA REMOVED]' };
467
+ return Promise.resolve({
468
+ action: 'modify',
469
+ context: sharedContext,
470
+ });
471
+ }
472
+ return Promise.resolve({ action: 'allow', context: sharedContext });
473
+ });
474
+
475
+ jest.spyOn(mcpManager, 'callTool').mockResolvedValue({
476
+ sensitiveField: 'actual_password_here'
477
+ });
478
+
479
+ const result = await guardedFunctionBridge.handleFunctionCall(
480
+ 'TestDuck',
481
+ 'mcp__test_server__test_tool',
482
+ { _mcp_server: 'test_server', _mcp_tool: 'test_tool' }
483
+ );
484
+
485
+ expect(result.success).toBe(true);
486
+ expect(result.data).toEqual({ redacted: '[SENSITIVE DATA REMOVED]' });
487
+ });
488
+
489
+ it('should skip guardrails when service is disabled', async () => {
490
+ mockGuardrailsService.isEnabled.mockReturnValue(false);
491
+
492
+ jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'test' });
493
+
494
+ const result = await guardedFunctionBridge.handleFunctionCall(
495
+ 'TestDuck',
496
+ 'mcp__test_server__test_tool',
497
+ { _mcp_server: 'test_server', _mcp_tool: 'test_tool' }
498
+ );
499
+
500
+ expect(result.success).toBe(true);
501
+ expect(mockGuardrailsService.createContext).not.toHaveBeenCalled();
502
+ expect(mockGuardrailsService.execute).not.toHaveBeenCalled();
503
+ });
504
+
505
+ it('should work without guardrails service (undefined)', async () => {
506
+ const bridgeWithoutGuardrails = new FunctionBridge(
507
+ mcpManager,
508
+ approvalService,
509
+ [],
510
+ 'never'
511
+ // No guardrails service
512
+ );
513
+
514
+ jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'test' });
515
+
516
+ const result = await bridgeWithoutGuardrails.handleFunctionCall(
517
+ 'TestDuck',
518
+ 'mcp__test_server__test_tool',
519
+ { _mcp_server: 'test_server', _mcp_tool: 'test_tool' }
520
+ );
521
+
522
+ expect(result.success).toBe(true);
523
+ });
524
+
525
+ it('should re-throw GuardrailBlockError without wrapping', async () => {
526
+ mockGuardrailsService.execute.mockRejectedValueOnce(
527
+ new GuardrailBlockError('custom_plugin', 'Custom block reason')
528
+ );
529
+
530
+ await expect(
531
+ guardedFunctionBridge.handleFunctionCall(
532
+ 'TestDuck',
533
+ 'mcp__test_server__test_tool',
534
+ { _mcp_server: 'test_server', _mcp_tool: 'test_tool' }
535
+ )
536
+ ).rejects.toThrow("Request blocked by guardrail 'custom_plugin': Custom block reason");
537
+ });
538
+ });
291
539
  });
@@ -0,0 +1,314 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { getPrompts, getPrompt, PROMPTS } from '../src/prompts/index.js';
3
+
4
+ describe('Prompts', () => {
5
+ describe('getPrompts', () => {
6
+ it('should return all 8 prompts', () => {
7
+ const prompts = getPrompts();
8
+ expect(prompts).toHaveLength(8);
9
+ });
10
+
11
+ it('should return prompts with required fields', () => {
12
+ const prompts = getPrompts();
13
+ for (const prompt of prompts) {
14
+ expect(prompt).toHaveProperty('name');
15
+ expect(prompt).toHaveProperty('description');
16
+ expect(typeof prompt.name).toBe('string');
17
+ expect(typeof prompt.description).toBe('string');
18
+ expect(prompt.name.length).toBeGreaterThan(0);
19
+ expect(prompt.description.length).toBeGreaterThan(0);
20
+ }
21
+ });
22
+
23
+ it('should return prompts with arguments array', () => {
24
+ const prompts = getPrompts();
25
+ for (const prompt of prompts) {
26
+ expect(Array.isArray(prompt.arguments)).toBe(true);
27
+ }
28
+ });
29
+
30
+ it('should not expose buildMessages function', () => {
31
+ const prompts = getPrompts();
32
+ for (const prompt of prompts) {
33
+ expect(prompt).not.toHaveProperty('buildMessages');
34
+ }
35
+ });
36
+
37
+ it('should have unique prompt names', () => {
38
+ const prompts = getPrompts();
39
+ const names = prompts.map((p) => p.name);
40
+ const uniqueNames = new Set(names);
41
+ expect(uniqueNames.size).toBe(names.length);
42
+ });
43
+ });
44
+
45
+ describe('getPrompt', () => {
46
+ it('should throw for unknown prompt', () => {
47
+ expect(() => getPrompt('nonexistent', {})).toThrow('Unknown prompt: nonexistent');
48
+ });
49
+
50
+ it('should return description and messages', () => {
51
+ const result = getPrompt('reframe', { problem: 'Test problem' });
52
+ expect(result).toHaveProperty('description');
53
+ expect(result).toHaveProperty('messages');
54
+ expect(Array.isArray(result.messages)).toBe(true);
55
+ expect(result.messages.length).toBeGreaterThan(0);
56
+ });
57
+
58
+ it('should throw for missing required arguments', () => {
59
+ expect(() => getPrompt('perspectives', {})).toThrow();
60
+ expect(() => getPrompt('perspectives', { problem: 'test' })).toThrow(); // missing perspectives
61
+ });
62
+
63
+ it('should wrap errors with prompt context', () => {
64
+ try {
65
+ getPrompt('perspectives', {});
66
+ fail('Should have thrown');
67
+ } catch (e) {
68
+ expect((e as Error).message).toMatch(/^Failed to build prompt "perspectives":/);
69
+ expect((e as Error).message).toContain('problem argument is required');
70
+ }
71
+ });
72
+
73
+ it('should handle empty string arguments as missing', () => {
74
+ expect(() => getPrompt('perspectives', { problem: '', perspectives: 'test' })).toThrow(
75
+ 'problem argument is required'
76
+ );
77
+ });
78
+
79
+ it('should handle whitespace-only arguments as valid', () => {
80
+ // Whitespace is truthy in JS, so it passes validation (this is intentional)
81
+ const result = getPrompt('perspectives', { problem: ' ', perspectives: 'test' });
82
+ expect(result.messages).toHaveLength(1);
83
+ });
84
+ });
85
+
86
+ describe('perspectives prompt', () => {
87
+ it('should require problem and perspectives arguments', () => {
88
+ expect(() => getPrompt('perspectives', {})).toThrow('problem argument is required');
89
+ expect(() => getPrompt('perspectives', { problem: 'test' })).toThrow(
90
+ 'perspectives argument is required'
91
+ );
92
+ });
93
+
94
+ it('should build valid messages with required args', () => {
95
+ const result = getPrompt('perspectives', {
96
+ problem: 'Test problem',
97
+ perspectives: 'security, performance',
98
+ });
99
+ expect(result.messages).toHaveLength(1);
100
+ expect(result.messages[0].role).toBe('user');
101
+ expect(result.messages[0].content).toHaveProperty('type', 'text');
102
+ });
103
+
104
+ it('should include optional context in message', () => {
105
+ const result = getPrompt('perspectives', {
106
+ problem: 'Test problem',
107
+ perspectives: 'security',
108
+ context: 'Additional context',
109
+ });
110
+ const text = (result.messages[0].content as { type: string; text: string }).text;
111
+ expect(text).toContain('Additional context');
112
+ });
113
+ });
114
+
115
+ describe('assumptions prompt', () => {
116
+ it('should require plan argument', () => {
117
+ expect(() => getPrompt('assumptions', {})).toThrow('plan argument is required');
118
+ });
119
+
120
+ it('should build valid messages', () => {
121
+ const result = getPrompt('assumptions', { plan: 'Test plan' });
122
+ expect(result.messages).toHaveLength(1);
123
+ const text = (result.messages[0].content as { type: string; text: string }).text;
124
+ expect(text).toContain('Test plan');
125
+ });
126
+
127
+ it('should include optional constraints and concerns', () => {
128
+ const result = getPrompt('assumptions', {
129
+ plan: 'Test plan',
130
+ constraints: 'Must be fast',
131
+ concerns: 'Worried about scale',
132
+ });
133
+ const text = (result.messages[0].content as { type: string; text: string }).text;
134
+ expect(text).toContain('Must be fast');
135
+ expect(text).toContain('Worried about scale');
136
+ });
137
+ });
138
+
139
+ describe('blindspots prompt', () => {
140
+ it('should require proposal argument', () => {
141
+ expect(() => getPrompt('blindspots', {})).toThrow('proposal argument is required');
142
+ });
143
+
144
+ it('should build valid messages', () => {
145
+ const result = getPrompt('blindspots', { proposal: 'Test proposal' });
146
+ expect(result.messages).toHaveLength(1);
147
+ });
148
+ });
149
+
150
+ describe('tradeoffs prompt', () => {
151
+ it('should require options and criteria arguments', () => {
152
+ expect(() => getPrompt('tradeoffs', {})).toThrow('options argument is required');
153
+ expect(() => getPrompt('tradeoffs', { options: 'A, B' })).toThrow(
154
+ 'criteria argument is required'
155
+ );
156
+ });
157
+
158
+ it('should build valid messages with required args', () => {
159
+ const result = getPrompt('tradeoffs', {
160
+ options: 'Option A, Option B',
161
+ criteria: 'cost, speed',
162
+ });
163
+ expect(result.messages).toHaveLength(1);
164
+ });
165
+ });
166
+
167
+ describe('red_team prompt', () => {
168
+ it('should require target argument', () => {
169
+ expect(() => getPrompt('red_team', {})).toThrow('target argument is required');
170
+ });
171
+
172
+ it('should build valid messages', () => {
173
+ const result = getPrompt('red_team', { target: 'Test system' });
174
+ expect(result.messages).toHaveLength(1);
175
+ const text = (result.messages[0].content as { type: string; text: string }).text;
176
+ expect(text).toContain('Test system');
177
+ });
178
+ });
179
+
180
+ describe('reframe prompt', () => {
181
+ it('should require problem argument', () => {
182
+ expect(() => getPrompt('reframe', {})).toThrow('problem argument is required');
183
+ });
184
+
185
+ it('should include three reframing types', () => {
186
+ const result = getPrompt('reframe', { problem: 'Test problem' });
187
+ const text = (result.messages[0].content as { type: string; text: string }).text;
188
+ expect(text).toContain('HIGHER ABSTRACTION');
189
+ expect(text).toContain('INVERSION');
190
+ expect(text).toContain('SIMPLIFICATION');
191
+ });
192
+ });
193
+
194
+ describe('architecture prompt', () => {
195
+ it('should require design, workloads, and priorities arguments', () => {
196
+ expect(() => getPrompt('architecture', {})).toThrow('design argument is required');
197
+ expect(() => getPrompt('architecture', { design: 'test' })).toThrow(
198
+ 'workloads argument is required'
199
+ );
200
+ expect(() => getPrompt('architecture', { design: 'test', workloads: 'test' })).toThrow(
201
+ 'priorities argument is required'
202
+ );
203
+ });
204
+
205
+ it('should build valid messages with all required args', () => {
206
+ const result = getPrompt('architecture', {
207
+ design: 'Microservices',
208
+ workloads: '1000 req/s',
209
+ priorities: 'latency, cost',
210
+ });
211
+ expect(result.messages).toHaveLength(1);
212
+ });
213
+
214
+ it('should include cross-cutting concerns', () => {
215
+ const result = getPrompt('architecture', {
216
+ design: 'test',
217
+ workloads: 'test',
218
+ priorities: 'test',
219
+ });
220
+ const text = (result.messages[0].content as { type: string; text: string }).text;
221
+ expect(text).toContain('Scalability');
222
+ expect(text).toContain('Reliability');
223
+ expect(text).toContain('Operational Complexity');
224
+ expect(text).toContain('Developer Experience');
225
+ expect(text).toContain('Cost Efficiency');
226
+ });
227
+ });
228
+
229
+ describe('diverge_converge prompt', () => {
230
+ it('should require challenge argument', () => {
231
+ expect(() => getPrompt('diverge_converge', {})).toThrow('challenge argument is required');
232
+ });
233
+
234
+ it('should include diverge and converge phases', () => {
235
+ const result = getPrompt('diverge_converge', { challenge: 'Test challenge' });
236
+ const text = (result.messages[0].content as { type: string; text: string }).text;
237
+ expect(text).toContain('PHASE 1: DIVERGE');
238
+ expect(text).toContain('PHASE 2: CONVERGE');
239
+ });
240
+
241
+ it('should use default width and criteria when not provided', () => {
242
+ const result = getPrompt('diverge_converge', { challenge: 'Test challenge' });
243
+ const text = (result.messages[0].content as { type: string; text: string }).text;
244
+ expect(text).toContain('balanced');
245
+ expect(text).toContain('feasibility, impact, and effort required');
246
+ });
247
+ });
248
+
249
+ describe('PROMPTS registry', () => {
250
+ it('should have all expected prompts', () => {
251
+ const expectedNames = [
252
+ 'perspectives',
253
+ 'assumptions',
254
+ 'blindspots',
255
+ 'tradeoffs',
256
+ 'red_team',
257
+ 'reframe',
258
+ 'architecture',
259
+ 'diverge_converge',
260
+ ];
261
+ for (const name of expectedNames) {
262
+ expect(PROMPTS).toHaveProperty(name);
263
+ }
264
+ });
265
+
266
+ it('should have buildMessages function for all prompts', () => {
267
+ for (const [name, prompt] of Object.entries(PROMPTS)) {
268
+ expect(typeof prompt.buildMessages).toBe('function');
269
+ expect(prompt.name).toBe(name);
270
+ }
271
+ });
272
+ });
273
+
274
+ describe('MCP spec compliance', () => {
275
+ it('should return messages with valid role (user or assistant)', () => {
276
+ const result = getPrompt('reframe', { problem: 'test' });
277
+ for (const message of result.messages) {
278
+ expect(['user', 'assistant']).toContain(message.role);
279
+ }
280
+ });
281
+
282
+ it('should return messages with valid content type', () => {
283
+ const result = getPrompt('reframe', { problem: 'test' });
284
+ for (const message of result.messages) {
285
+ const content = message.content as { type: string; text?: string };
286
+ expect(['text', 'image', 'resource']).toContain(content.type);
287
+ if (content.type === 'text') {
288
+ expect(typeof content.text).toBe('string');
289
+ }
290
+ }
291
+ });
292
+
293
+ it('should preserve user input in generated messages', () => {
294
+ const userInput = 'Special chars: <>&"\' and unicode: 日本語';
295
+ const result = getPrompt('reframe', { problem: userInput });
296
+ const text = (result.messages[0].content as { type: string; text: string }).text;
297
+ expect(text).toContain(userInput);
298
+ });
299
+
300
+ it('should handle very long inputs without truncation', () => {
301
+ const longInput = 'x'.repeat(50000);
302
+ const result = getPrompt('reframe', { problem: longInput });
303
+ const text = (result.messages[0].content as { type: string; text: string }).text;
304
+ expect(text).toContain(longInput);
305
+ });
306
+
307
+ it('should handle inputs with newlines and special whitespace', () => {
308
+ const multilineInput = 'Line 1\nLine 2\n\tTabbed line\r\nWindows line';
309
+ const result = getPrompt('reframe', { problem: multilineInput });
310
+ const text = (result.messages[0].content as { type: string; text: string }).text;
311
+ expect(text).toContain(multilineInput);
312
+ });
313
+ });
314
+ });