mcp-rubber-duck 1.9.4 → 1.10.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 (55) hide show
  1. package/.eslintrc.json +3 -1
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +54 -10
  4. package/assets/ext-apps-compare.png +0 -0
  5. package/assets/ext-apps-debate.png +0 -0
  6. package/assets/ext-apps-usage-stats.png +0 -0
  7. package/assets/ext-apps-vote.png +0 -0
  8. package/audit-ci.json +3 -1
  9. package/dist/server.d.ts +5 -2
  10. package/dist/server.d.ts.map +1 -1
  11. package/dist/server.js +414 -498
  12. package/dist/server.js.map +1 -1
  13. package/dist/tools/compare-ducks.d.ts.map +1 -1
  14. package/dist/tools/compare-ducks.js +19 -0
  15. package/dist/tools/compare-ducks.js.map +1 -1
  16. package/dist/tools/duck-debate.d.ts.map +1 -1
  17. package/dist/tools/duck-debate.js +24 -0
  18. package/dist/tools/duck-debate.js.map +1 -1
  19. package/dist/tools/duck-vote.d.ts.map +1 -1
  20. package/dist/tools/duck-vote.js +23 -0
  21. package/dist/tools/duck-vote.js.map +1 -1
  22. package/dist/tools/get-usage-stats.d.ts.map +1 -1
  23. package/dist/tools/get-usage-stats.js +13 -0
  24. package/dist/tools/get-usage-stats.js.map +1 -1
  25. package/dist/ui/compare-ducks/mcp-app.html +187 -0
  26. package/dist/ui/duck-debate/mcp-app.html +182 -0
  27. package/dist/ui/duck-vote/mcp-app.html +168 -0
  28. package/dist/ui/usage-stats/mcp-app.html +192 -0
  29. package/jest.config.js +1 -0
  30. package/package.json +7 -3
  31. package/src/server.ts +491 -523
  32. package/src/tools/compare-ducks.ts +20 -0
  33. package/src/tools/duck-debate.ts +27 -0
  34. package/src/tools/duck-vote.ts +24 -0
  35. package/src/tools/get-usage-stats.ts +14 -0
  36. package/src/ui/compare-ducks/app.ts +88 -0
  37. package/src/ui/compare-ducks/mcp-app.html +102 -0
  38. package/src/ui/duck-debate/app.ts +111 -0
  39. package/src/ui/duck-debate/mcp-app.html +97 -0
  40. package/src/ui/duck-vote/app.ts +128 -0
  41. package/src/ui/duck-vote/mcp-app.html +83 -0
  42. package/src/ui/usage-stats/app.ts +156 -0
  43. package/src/ui/usage-stats/mcp-app.html +107 -0
  44. package/tests/duck-debate.test.ts +3 -1
  45. package/tests/duck-vote.test.ts +3 -1
  46. package/tests/tool-annotations.test.ts +208 -41
  47. package/tests/tools/compare-ducks-ui.test.ts +135 -0
  48. package/tests/tools/compare-ducks.test.ts +3 -1
  49. package/tests/tools/duck-debate-ui.test.ts +234 -0
  50. package/tests/tools/duck-vote-ui.test.ts +172 -0
  51. package/tests/tools/get-usage-stats.test.ts +3 -1
  52. package/tests/tools/usage-stats-ui.test.ts +130 -0
  53. package/tests/ui-build.test.ts +53 -0
  54. package/tsconfig.json +1 -1
  55. package/vite.config.ts +19 -0
@@ -1,8 +1,10 @@
1
- import { describe, it, expect, jest, beforeEach } from '@jest/globals';
1
+ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
2
2
  import { Tool } from '@modelcontextprotocol/sdk/types.js';
3
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
3
5
 
4
6
  // We need to test the tool annotations from RubberDuckServer
5
- // Since getTools() is a private method, we'll test through the server's tool listing
7
+ // Using the proper MCP protocol to list tools via Client + InMemoryTransport
6
8
 
7
9
  // Mock dependencies before importing the server
8
10
  jest.mock('../src/utils/logger');
@@ -29,17 +31,32 @@ import { RubberDuckServer } from '../src/server.js';
29
31
  */
30
32
  describe('Tool Annotations', () => {
31
33
  let server: RubberDuckServer;
34
+ let client: Client;
32
35
  let tools: Tool[];
33
36
 
34
- beforeEach(() => {
37
+ beforeEach(async () => {
35
38
  // Set up minimal environment for server initialization
36
39
  process.env.OPENAI_API_KEY = 'test-key';
37
40
 
38
41
  server = new RubberDuckServer();
39
42
 
40
- // Access private getTools method via reflection for testing
43
+ // Create in-memory client-server pair
44
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
45
+
46
+ // Connect server (access underlying McpServer)
41
47
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
- tools = (server as any).getTools();
48
+ await (server as any).server.connect(serverTransport);
49
+
50
+ client = new Client({ name: 'test-client', version: '1.0.0' });
51
+ await client.connect(clientTransport);
52
+
53
+ // List tools via proper MCP protocol
54
+ const result = await client.listTools();
55
+ tools = result.tools;
56
+ });
57
+
58
+ afterEach(async () => {
59
+ await client.close();
43
60
  });
44
61
 
45
62
  // Helper to find a tool by name
@@ -355,8 +372,11 @@ describe('Tool Annotations', () => {
355
372
  });
356
373
 
357
374
  describe('Base tools count', () => {
358
- it('should have 12 base tools', () => {
359
- // Base tools (without MCP-specific tools which are conditionally added)
375
+ it('should have exactly 12 base tools', () => {
376
+ expect(tools).toHaveLength(12);
377
+ });
378
+
379
+ it('should have all expected base tool names', () => {
360
380
  const baseToolNames = [
361
381
  'ask_duck',
362
382
  'chat_with_duck',
@@ -377,13 +397,159 @@ describe('Tool Annotations', () => {
377
397
  }
378
398
  });
379
399
  });
400
+
401
+ describe('Tool input schemas (Zod migration correctness)', () => {
402
+ /**
403
+ * These tests verify that the JSON Schema → Zod conversion
404
+ * preserved required fields, property names, and types correctly.
405
+ */
406
+
407
+ it('ask_duck should have prompt as required and provider/model/temperature as optional', () => {
408
+ const tool = findTool('ask_duck');
409
+ expect(tool?.inputSchema.required).toContain('prompt');
410
+ expect(tool?.inputSchema.required).not.toContain('provider');
411
+ expect(tool?.inputSchema.required).not.toContain('model');
412
+ expect(tool?.inputSchema.required).not.toContain('temperature');
413
+ expect(tool?.inputSchema.properties).toHaveProperty('prompt');
414
+ expect(tool?.inputSchema.properties).toHaveProperty('provider');
415
+ expect(tool?.inputSchema.properties).toHaveProperty('model');
416
+ expect(tool?.inputSchema.properties).toHaveProperty('temperature');
417
+ });
418
+
419
+ it('chat_with_duck should have conversation_id and message as required', () => {
420
+ const tool = findTool('chat_with_duck');
421
+ expect(tool?.inputSchema.required).toContain('conversation_id');
422
+ expect(tool?.inputSchema.required).toContain('message');
423
+ expect(tool?.inputSchema.required).not.toContain('provider');
424
+ expect(tool?.inputSchema.required).not.toContain('model');
425
+ });
426
+
427
+ it('clear_conversations should have no required properties', () => {
428
+ const tool = findTool('clear_conversations');
429
+ // No inputSchema properties expected (no args tool)
430
+ const required = tool?.inputSchema.required || [];
431
+ expect(required).toHaveLength(0);
432
+ });
433
+
434
+ it('compare_ducks should have prompt as required and providers/model optional', () => {
435
+ const tool = findTool('compare_ducks');
436
+ expect(tool?.inputSchema.required).toContain('prompt');
437
+ expect(tool?.inputSchema.required).not.toContain('providers');
438
+ expect(tool?.inputSchema.properties?.providers).toHaveProperty('type', 'array');
439
+ });
440
+
441
+ it('duck_vote should have question and options as required', () => {
442
+ const tool = findTool('duck_vote');
443
+ expect(tool?.inputSchema.required).toContain('question');
444
+ expect(tool?.inputSchema.required).toContain('options');
445
+ expect(tool?.inputSchema.required).not.toContain('voters');
446
+ expect(tool?.inputSchema.required).not.toContain('require_reasoning');
447
+ });
448
+
449
+ it('duck_judge should have responses as required with nested object schema', () => {
450
+ const tool = findTool('duck_judge');
451
+ expect(tool?.inputSchema.required).toContain('responses');
452
+ expect(tool?.inputSchema.required).not.toContain('judge');
453
+ expect(tool?.inputSchema.required).not.toContain('criteria');
454
+ expect(tool?.inputSchema.required).not.toContain('persona');
455
+ // responses should be an array type
456
+ const responses = tool?.inputSchema.properties?.responses as Record<string, unknown>;
457
+ expect(responses?.type).toBe('array');
458
+ });
459
+
460
+ it('duck_iterate should have prompt, providers, and mode as required', () => {
461
+ const tool = findTool('duck_iterate');
462
+ expect(tool?.inputSchema.required).toContain('prompt');
463
+ expect(tool?.inputSchema.required).toContain('providers');
464
+ expect(tool?.inputSchema.required).toContain('mode');
465
+ expect(tool?.inputSchema.required).not.toContain('iterations');
466
+ });
467
+
468
+ it('duck_debate should have prompt and format as required', () => {
469
+ const tool = findTool('duck_debate');
470
+ expect(tool?.inputSchema.required).toContain('prompt');
471
+ expect(tool?.inputSchema.required).toContain('format');
472
+ expect(tool?.inputSchema.required).not.toContain('rounds');
473
+ expect(tool?.inputSchema.required).not.toContain('providers');
474
+ expect(tool?.inputSchema.required).not.toContain('synthesizer');
475
+ });
476
+
477
+ it('get_usage_stats should have no required properties (period has default)', () => {
478
+ const tool = findTool('get_usage_stats');
479
+ const required = tool?.inputSchema.required || [];
480
+ expect(required).not.toContain('period');
481
+ });
482
+
483
+ it('all tools should have descriptions', () => {
484
+ for (const tool of tools) {
485
+ expect(tool.description).toBeDefined();
486
+ expect(typeof tool.description).toBe('string');
487
+ expect(tool.description!.length).toBeGreaterThan(0);
488
+ }
489
+ });
490
+ });
491
+
492
+ describe('Prompts registration', () => {
493
+ it('should register all 8 prompts via MCP protocol', async () => {
494
+ const result = await client.listPrompts();
495
+ expect(result.prompts).toHaveLength(8);
496
+ });
497
+
498
+ it('should register prompts with correct names', async () => {
499
+ const result = await client.listPrompts();
500
+ const names = result.prompts.map((p) => p.name);
501
+ const expectedNames = [
502
+ 'perspectives',
503
+ 'assumptions',
504
+ 'blindspots',
505
+ 'tradeoffs',
506
+ 'red_team',
507
+ 'reframe',
508
+ 'architecture',
509
+ 'diverge_converge',
510
+ ];
511
+ for (const name of expectedNames) {
512
+ expect(names).toContain(name);
513
+ }
514
+ });
515
+
516
+ it('should register prompts with descriptions', async () => {
517
+ const result = await client.listPrompts();
518
+ for (const prompt of result.prompts) {
519
+ expect(prompt.description).toBeDefined();
520
+ expect(typeof prompt.description).toBe('string');
521
+ expect(prompt.description!.length).toBeGreaterThan(0);
522
+ }
523
+ });
524
+
525
+ it('should return prompt messages via getPrompt', async () => {
526
+ const result = await client.getPrompt({
527
+ name: 'reframe',
528
+ arguments: { problem: 'Test problem' },
529
+ });
530
+ expect(result.messages).toBeDefined();
531
+ expect(result.messages.length).toBeGreaterThan(0);
532
+ expect(result.messages[0].role).toBe('user');
533
+ });
534
+
535
+ it('should return prompt messages containing the user input', async () => {
536
+ const result = await client.getPrompt({
537
+ name: 'perspectives',
538
+ arguments: { problem: 'My test problem', perspectives: 'security, perf' },
539
+ });
540
+ const text = (result.messages[0].content as { type: string; text: string }).text;
541
+ expect(text).toContain('My test problem');
542
+ expect(text).toContain('security, perf');
543
+ });
544
+ });
380
545
  });
381
546
 
382
547
  describe('MCP-specific Tool Annotations', () => {
383
548
  let server: RubberDuckServer;
549
+ let client: Client;
384
550
  let tools: Tool[];
385
551
 
386
- beforeEach(() => {
552
+ beforeEach(async () => {
387
553
  // Enable MCP bridge for these tests
388
554
  process.env.OPENAI_API_KEY = 'test-key';
389
555
  process.env.MCP_BRIDGE_ENABLED = 'true';
@@ -393,13 +559,24 @@ describe('MCP-specific Tool Annotations', () => {
393
559
 
394
560
  server = new RubberDuckServer();
395
561
 
396
- // Access private getTools method via reflection for testing
562
+ // Create in-memory client-server pair
563
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
564
+
565
+ // Connect server (access underlying McpServer)
397
566
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
398
- tools = (server as any).getTools();
567
+ await (server as any).server.connect(serverTransport);
568
+
569
+ client = new Client({ name: 'test-client', version: '1.0.0' });
570
+ await client.connect(clientTransport);
571
+
572
+ // List tools via proper MCP protocol
573
+ const result = await client.listTools();
574
+ tools = result.tools;
399
575
  });
400
576
 
401
- afterEach(() => {
577
+ afterEach(async () => {
402
578
  delete process.env.MCP_BRIDGE_ENABLED;
579
+ await client.close();
403
580
  });
404
581
 
405
582
  // Helper to find a tool by name
@@ -407,8 +584,12 @@ describe('MCP-specific Tool Annotations', () => {
407
584
  return tools.find((t) => t.name === name);
408
585
  };
409
586
 
410
- // Note: MCP tools are only added when mcpEnabled is true in the server
411
- // These tests may not find the tools if MCP is not properly initialized
587
+ it('should register 15 tools when MCP bridge is enabled', () => {
588
+ expect(tools).toHaveLength(15);
589
+ expect(findTool('get_pending_approvals')).toBeDefined();
590
+ expect(findTool('approve_mcp_request')).toBeDefined();
591
+ expect(findTool('mcp_status')).toBeDefined();
592
+ });
412
593
 
413
594
  describe('get_pending_approvals (when MCP enabled)', () => {
414
595
  /**
@@ -417,18 +598,14 @@ describe('MCP-specific Tool Annotations', () => {
417
598
  * - readOnlyHint: true - Only reads approval state
418
599
  * - openWorldHint: NOT set - Pure local operation
419
600
  */
420
- it('should be marked as read-only when present', () => {
601
+ it('should be marked as read-only', () => {
421
602
  const tool = findTool('get_pending_approvals');
422
- if (tool) {
423
- expect(tool.annotations?.readOnlyHint).toBe(true);
424
- }
603
+ expect(tool?.annotations?.readOnlyHint).toBe(true);
425
604
  });
426
605
 
427
- it('should be explicitly marked as NOT open-world when present', () => {
606
+ it('should be explicitly marked as NOT open-world', () => {
428
607
  const tool = findTool('get_pending_approvals');
429
- if (tool) {
430
- expect(tool.annotations?.openWorldHint).toBe(false);
431
- }
608
+ expect(tool?.annotations?.openWorldHint).toBe(false);
432
609
  });
433
610
  });
434
611
 
@@ -441,25 +618,19 @@ describe('MCP-specific Tool Annotations', () => {
441
618
  * - readOnlyHint: NOT set - Modifies approval state
442
619
  * - openWorldHint: NOT set - Pure local operation
443
620
  */
444
- it('should be marked as idempotent when present', () => {
621
+ it('should be marked as idempotent', () => {
445
622
  const tool = findTool('approve_mcp_request');
446
- if (tool) {
447
- expect(tool.annotations?.idempotentHint).toBe(true);
448
- }
623
+ expect(tool?.annotations?.idempotentHint).toBe(true);
449
624
  });
450
625
 
451
- it('should NOT be marked as read-only when present', () => {
626
+ it('should NOT be marked as read-only', () => {
452
627
  const tool = findTool('approve_mcp_request');
453
- if (tool) {
454
- expect(tool.annotations?.readOnlyHint).toBeUndefined();
455
- }
628
+ expect(tool?.annotations?.readOnlyHint).toBeUndefined();
456
629
  });
457
630
 
458
- it('should be explicitly marked as NOT open-world when present', () => {
631
+ it('should be explicitly marked as NOT open-world', () => {
459
632
  const tool = findTool('approve_mcp_request');
460
- if (tool) {
461
- expect(tool.annotations?.openWorldHint).toBe(false);
462
- }
633
+ expect(tool?.annotations?.openWorldHint).toBe(false);
463
634
  });
464
635
  });
465
636
 
@@ -471,18 +642,14 @@ describe('MCP-specific Tool Annotations', () => {
471
642
  * - readOnlyHint: true - Only reads status information
472
643
  * - openWorldHint: true - Communicates with MCP servers
473
644
  */
474
- it('should be marked as read-only when present', () => {
645
+ it('should be marked as read-only', () => {
475
646
  const tool = findTool('mcp_status');
476
- if (tool) {
477
- expect(tool.annotations?.readOnlyHint).toBe(true);
478
- }
647
+ expect(tool?.annotations?.readOnlyHint).toBe(true);
479
648
  });
480
649
 
481
- it('should be marked as open-world when present', () => {
650
+ it('should be marked as open-world', () => {
482
651
  const tool = findTool('mcp_status');
483
- if (tool) {
484
- expect(tool.annotations?.openWorldHint).toBe(true);
485
- }
652
+ expect(tool?.annotations?.openWorldHint).toBe(true);
486
653
  });
487
654
  });
488
655
  });
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2
+ import { compareDucksTool } from '../../src/tools/compare-ducks.js';
3
+ import { ProviderManager } from '../../src/providers/manager.js';
4
+
5
+ // Mock dependencies
6
+ jest.mock('../../src/utils/logger');
7
+ jest.mock('../../src/providers/manager.js');
8
+
9
+ describe('compareDucksTool structured JSON', () => {
10
+ let mockProviderManager: jest.Mocked<ProviderManager>;
11
+
12
+ const mockResponses = [
13
+ {
14
+ provider: 'openai',
15
+ nickname: 'OpenAI Duck',
16
+ content: 'TypeScript is great!',
17
+ model: 'gpt-4',
18
+ latency: 150,
19
+ cached: false,
20
+ usage: {
21
+ prompt_tokens: 10,
22
+ completion_tokens: 20,
23
+ total_tokens: 30,
24
+ },
25
+ },
26
+ {
27
+ provider: 'groq',
28
+ nickname: 'Groq Duck',
29
+ content: 'TypeScript rocks!',
30
+ model: 'llama-3.1-70b',
31
+ latency: 80,
32
+ cached: true,
33
+ usage: {
34
+ prompt_tokens: 10,
35
+ completion_tokens: 15,
36
+ total_tokens: 25,
37
+ },
38
+ },
39
+ ];
40
+
41
+ beforeEach(() => {
42
+ mockProviderManager = {
43
+ compareDucks: jest.fn().mockResolvedValue(mockResponses),
44
+ } as unknown as jest.Mocked<ProviderManager>;
45
+ });
46
+
47
+ it('should return two content items: text and JSON', async () => {
48
+ const result = await compareDucksTool(mockProviderManager, { prompt: 'Test' });
49
+
50
+ expect(result.content).toHaveLength(2);
51
+ expect(result.content[0].type).toBe('text');
52
+ expect(result.content[1].type).toBe('text');
53
+ });
54
+
55
+ it('should have valid JSON in the second content item', async () => {
56
+ const result = await compareDucksTool(mockProviderManager, { prompt: 'Test' });
57
+
58
+ const data = JSON.parse(result.content[1].text) as unknown[];
59
+ expect(Array.isArray(data)).toBe(true);
60
+ });
61
+
62
+ it('should include all provider data in JSON', async () => {
63
+ const result = await compareDucksTool(mockProviderManager, { prompt: 'Test' });
64
+
65
+ const data = JSON.parse(result.content[1].text) as {
66
+ provider: string;
67
+ nickname: string;
68
+ model: string;
69
+ content: string;
70
+ latency: number;
71
+ tokens: { prompt: number; completion: number; total: number } | null;
72
+ cached: boolean;
73
+ error?: string;
74
+ }[];
75
+
76
+ expect(data).toHaveLength(2);
77
+ expect(data[0].provider).toBe('openai');
78
+ expect(data[0].nickname).toBe('OpenAI Duck');
79
+ expect(data[0].model).toBe('gpt-4');
80
+ expect(data[0].content).toBe('TypeScript is great!');
81
+ expect(data[0].latency).toBe(150);
82
+ expect(data[0].tokens).toEqual({ prompt: 10, completion: 20, total: 30 });
83
+ expect(data[0].cached).toBe(false);
84
+
85
+ expect(data[1].provider).toBe('groq');
86
+ expect(data[1].cached).toBe(true);
87
+ });
88
+
89
+ it('should include error info for failed responses', async () => {
90
+ mockProviderManager.compareDucks.mockResolvedValue([
91
+ mockResponses[0],
92
+ {
93
+ provider: 'groq',
94
+ nickname: 'Groq Duck',
95
+ content: 'Error: API key invalid',
96
+ model: '',
97
+ latency: 0,
98
+ cached: false,
99
+ },
100
+ ]);
101
+
102
+ const result = await compareDucksTool(mockProviderManager, { prompt: 'Test' });
103
+ const data = JSON.parse(result.content[1].text) as { error?: string }[];
104
+
105
+ expect(data[0].error).toBeUndefined();
106
+ expect(data[1].error).toBe('Error: API key invalid');
107
+ });
108
+
109
+ it('should handle null tokens when usage is missing', async () => {
110
+ mockProviderManager.compareDucks.mockResolvedValue([
111
+ {
112
+ provider: 'openai',
113
+ nickname: 'OpenAI Duck',
114
+ content: 'Response',
115
+ model: 'gpt-4',
116
+ latency: 100,
117
+ cached: false,
118
+ },
119
+ ]);
120
+
121
+ const result = await compareDucksTool(mockProviderManager, { prompt: 'Test' });
122
+ const data = JSON.parse(result.content[1].text) as { tokens: unknown }[];
123
+
124
+ expect(data[0].tokens).toBeNull();
125
+ });
126
+
127
+ it('should preserve text content identical to before', async () => {
128
+ const result = await compareDucksTool(mockProviderManager, { prompt: 'Test' });
129
+
130
+ // First item is text, should contain original format
131
+ expect(result.content[0].text).toContain('OpenAI Duck');
132
+ expect(result.content[0].text).toContain('Groq Duck');
133
+ expect(result.content[0].text).toContain('2/2 ducks responded successfully');
134
+ });
135
+ });
@@ -58,10 +58,12 @@ describe('compareDucksTool', () => {
58
58
  undefined,
59
59
  { model: undefined }
60
60
  );
61
- expect(result.content).toHaveLength(1);
61
+ expect(result.content).toHaveLength(2);
62
62
  expect(result.content[0].type).toBe('text');
63
63
  expect(result.content[0].text).toContain('Asked:');
64
64
  expect(result.content[0].text).toContain('What is TypeScript?');
65
+ expect(result.content[1].type).toBe('text');
66
+ expect(() => JSON.parse(result.content[1].text)).not.toThrow();
65
67
  });
66
68
 
67
69
  it('should display all duck responses', async () => {